feat: structured task system with dependency graph (task_create/list/get/update)

Replaces the flat todo_write approach for non-trivial multi-step work with a
structured, incrementally-updated task store — dependencies, ownership, status,
metadata — ported from origin/v0.6.0 and adapted to the local Session/ctx.

- src/tools/task.ts: TaskStore (in-memory, per-session) + four read-only tools:
  task_create (returns id), task_list (summaries), task_get (full details),
  task_update (status pending|in_progress|completed|deleted, rename, claim via
  owner, addBlocks/addBlockedBy dependency edges, merge-patch metadata — null
  deletes a key). Self-refs/unknown ids ignored; direct 2-cycles skipped;
  delete prunes dangling refs. Schemas declared before use (fixes a TDZ in the
  v0.6.0 version). todo_write kept alongside for simpler cases.
- types.ts: ctx.taskStore. session.ts: TaskStore on every Session. loop.ts:
  expose session.taskStore in the tool ctx + on sub-agent sessions.
- tools/index.ts: register the four task tools.
- task.test.ts: 9 cases (create/list/get/update status, dependency edges both
  ways, self-ref/unknown/2-cycle guards, delete+prune, metadata merge-patch,
  absent-store error).
- README: document the structured tasks.

Verified: typecheck clean, build 275.10 KB, 301 tests pass (+9).
This commit is contained in:
kim
2026-08-21 14:07:16 +09:00
parent 5438780a55
commit dad0915d62
7 changed files with 408 additions and 2 deletions
+2 -2
View File
@@ -90,9 +90,9 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
- **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history.
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write`, `task_create`, `task_list`, `task_get`, `task_update` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm <mode>`.
- **Task checklists**: for multi-step work the model can call `todo_write` to show a live checklist (`☐`/`◐`/`☑`) in the transcript instead of silently working through a list you can't see progress on.
- **Structured tasks**: for multi-step work the model can call `task_create`/`task_list`/`task_get`/`task_update` to track units of work with a dependency graph (`blocks`/`blockedBy`), ownership (`owner`), status, and free-form metadata — created incrementally rather than replaced wholesale. The older flat `todo_write` live checklist (`☐`/`◐`/`☑`) remains for simpler cases.
- **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told.
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
+3
View File
@@ -15,6 +15,7 @@ import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
import { repairPartialJson } from "../toolcalling/partialJson.js";
import { notifyFileChanged } from "../codeintel/lspManager.js";
import { TaskStore } from "../tools/task.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
import { estimateTokens } from "../utils/tokens.js";
import { runHooksForEvent } from "../hooks/runner.js";
@@ -511,6 +512,7 @@ async function gateAndRun(
session.todos = todos;
emit({ type: "todos_update", todos });
},
taskStore: session.taskStore,
};
// PreToolUse fires before permission modes apply — a hook's block can't be bypassed by
@@ -717,6 +719,7 @@ async function runSubAgentTurn(
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
// checklist has nowhere to render even if it called todo_write.
todos: [],
taskStore: new TaskStore(),
// SHARED with the parent (not independent) — this is the whole point of the mutation gate: a
// batch of parallel sub-agents all funnel their mutating calls through the parent's single
// gate, so they can't race on the one permission slot or interleave filesystem writes. A
+6
View File
@@ -9,6 +9,7 @@ import type { ConfirmFn } from "../permissions/types.js";
import { TOOLS } from "../tools/index.js";
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
import type { TodoItem, ToolDef } from "../tools/types.js";
import { TaskStore } from "../tools/task.js";
import { estimateTokens } from "../utils/tokens.js";
import { buildSystemPrompt } from "./systemPrompt.js";
@@ -86,6 +87,9 @@ export interface Session {
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
todos: TodoItem[];
/** Structured task store backing the task_create/list/get/update tools — session-scoped,
* in-memory (not persisted), and independent of the flat `todos` checklist. */
taskStore: TaskStore;
/** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents
* spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their
* parent's session, so without a lock two of them could simultaneously call a mutating tool,
@@ -138,6 +142,7 @@ export function createSession(
mutationCommitLength: null,
projectInstructions,
todos: [],
taskStore: new TaskStore(),
mutationGate: Promise.resolve(),
};
}
@@ -184,6 +189,7 @@ export function createSessionFromRecord(
mutationCommitLength: null,
projectInstructions,
todos: [],
taskStore: new TaskStore(),
mutationGate: Promise.resolve(),
};
}
+5
View File
@@ -11,6 +11,7 @@ import { listFilesTool } from "./listFiles.js";
import { notebookEditTool } from "./notebookEdit.js";
import { readFileTool } from "./readFile.js";
import { todoWriteTool } from "./todoWrite.js";
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool } from "./task.js";
import { webFetchTool } from "./webFetch.js";
import { webSearchTool } from "./webSearch.js";
import { writeFileTool } from "./writeFile.js";
@@ -35,6 +36,10 @@ export const TOOLS: ToolDef[] = [
bashKillTool,
gitCommitTool,
todoWriteTool,
taskCreateTool,
taskListTool,
taskGetTool,
taskUpdateTool,
agentTool,
];
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool, TaskStore } from "./task.js";
import type { ToolContext } from "./types.js";
function ctxWithStore(): { ctx: ToolContext; store: TaskStore } {
const store = new TaskStore();
return { ctx: { taskStore: store } as ToolContext, store };
}
describe("task tools", () => {
it("task_create creates a pending task and returns it with an id", async () => {
const { ctx, store } = ctxWithStore();
const out = (await taskCreateTool.handler({ subject: "Fix bug", description: "details" }, ctx)) as {
id: string;
task: { status: string; blocks: string[]; blockedBy: string[] };
};
expect(out.id).toMatch(/^t\d+$/);
expect(out.task.status).toBe("pending");
expect(out.task.blocks).toEqual([]);
expect(out.task.blockedBy).toEqual([]);
expect(store.list()).toHaveLength(1);
});
it("task_list returns all task summaries", async () => {
const { ctx } = ctxWithStore();
await taskCreateTool.handler({ subject: "A", description: "x" }, ctx);
await taskCreateTool.handler({ subject: "B", description: "y" }, ctx);
const out = (await taskListTool.handler({}, ctx)) as { tasks: { subject: string }[] };
expect(out.tasks.map((t) => t.subject)).toEqual(["A", "B"]);
});
it("task_get returns full details and errors on unknown id", async () => {
const { ctx } = ctxWithStore();
const created = (await taskCreateTool.handler({ subject: "A", description: "long desc" }, ctx)) as {
id: string;
task: { description: string };
};
const got = (await taskGetTool.handler({ taskId: created.id }, ctx)) as { task: { description: string } };
expect(got.task.description).toBe("long desc");
const miss = (await taskGetTool.handler({ taskId: "nope" }, ctx)) as { error: string };
expect(miss.error).toMatch(/not found/);
});
it("task_update sets status and marks in_progress/completed", async () => {
const { ctx } = ctxWithStore();
const created = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const upd = (await taskUpdateTool.handler({ taskId: created.id, status: "in_progress" }, ctx)) as {
task: { status: string };
};
expect(upd.task.status).toBe("in_progress");
const done = (await taskUpdateTool.handler({ taskId: created.id, status: "completed" }, ctx)) as {
task: { status: string };
};
expect(done.task.status).toBe("completed");
});
it("addBlocks/addBlockedBy link two tasks both ways", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
// B is blocked by A (one-directional: sets B.blockedBy, not A.blocks)
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
expect(bAfter.task.blockedBy).toContain(a.id);
// Add the back-ref explicitly: A blocks B.
await taskUpdateTool.handler({ taskId: a.id, addBlocks: [b.id] }, ctx);
const aAfter = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blocks: string[] } };
expect(aAfter.task.blocks).toContain(b.id);
});
it("ignores self-refs, unknown ids, and direct 2-cycles", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
// self-ref ignored
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [a.id] }, ctx);
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
// unknown id ignored
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: ["zzz"] }, ctx);
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
// B waits on A; now make A wait on B — should be skipped (2-cycle)
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [b.id] }, ctx);
expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]);
});
it("status deleted removes the task and prunes dangling refs", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string };
const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string };
await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx);
const del = (await taskUpdateTool.handler({ taskId: a.id, status: "deleted" }, ctx)) as { deleted: string };
expect(del.deleted).toBe(a.id);
// B no longer blocked by the removed A
const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } };
expect(bAfter.task.blockedBy).toEqual([]);
expect(((await taskListTool.handler({}, ctx)) as { tasks: unknown[] }).tasks).toHaveLength(1);
});
it("metadata merge-patch: set keys, null deletes", async () => {
const { ctx } = ctxWithStore();
const a = (await taskCreateTool.handler({ subject: "A", description: "x", metadata: { k: 1 } }, ctx)) as { id: string };
await taskUpdateTool.handler({ taskId: a.id, metadata: { k2: "v" } }, ctx);
let t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
expect(t.task.metadata).toEqual({ k: 1, k2: "v" });
await taskUpdateTool.handler({ taskId: a.id, metadata: { k: null } }, ctx);
t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record<string, unknown> } };
expect(t.task.metadata).toEqual({ k2: "v" });
});
it("returns an error when taskStore is absent", async () => {
const ctx = {} as ToolContext;
const out = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { error: string };
expect(out.error).toMatch(/not available/);
});
});
+272
View File
@@ -0,0 +1,272 @@
import { z } from "zod";
import type { ToolDef } from "./types.js";
/** A task's lifecycle state. `deleted` is only used as an update target (it removes the task); it is
* never a stored status. */
export type TaskStatus = "pending" | "in_progress" | "completed";
/** A structured, trackable unit of work. Tasks form a dependency graph via `blocks`/`blockedBy`
* (each lists the other's task ids), can be owned/claimed by a named agent, and carry free-form
* metadata. Unlike the flat todo list, tasks are created and updated incrementally (not replaced
* wholesale) so dependencies and ownership can be expressed. */
export interface Task {
id: string;
subject: string;
description: string;
/** Present-continuous label shown in a spinner while the task is in_progress (e.g. "Running tests"). */
activeForm?: string;
status: TaskStatus;
/** Who has claimed the task (an agent name). Unset = unclaimed. */
owner?: string;
/** Ids of tasks THIS task blocks (i.e. that depend on it). */
blocks: string[];
/** Ids of tasks that must be completed before this one can start. */
blockedBy: string[];
/** Free-form metadata; merge-patched on update (set a key to null to delete it). */
metadata?: Record<string, unknown>;
}
/** A compact, list-view projection of a Task — enough to render the checklist without the long
* description/metadata. */
export interface TaskSummary {
id: string;
subject: string;
status: TaskStatus;
owner?: string;
blockedBy: string[];
}
/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a
* snapshot through an optional `onChange` callback the loop wires up, so each create/update can
* refresh a UI checklist. Purely in-memory (per-session); not persisted. */
export class TaskStore {
private tasks = new Map<string, Task>();
private seq = 0;
private emitter: ((tasks: TaskSummary[]) => void) | undefined;
/** Wired by the host so the store can broadcast a snapshot after each mutation. */
setEmitter(emit: (tasks: TaskSummary[]) => void): void {
this.emitter = emit;
}
private nextId(): string {
this.seq += 1;
return `t${this.seq}`;
}
private snapshot(): TaskSummary[] {
return [...this.tasks.values()].map((t) => ({
id: t.id,
subject: t.subject,
status: t.status,
owner: t.owner,
blockedBy: [...t.blockedBy],
}));
}
private emit(): void {
this.emitter?.(this.snapshot());
}
create(input: {
subject: string;
description: string;
activeForm?: string;
metadata?: Record<string, unknown>;
}): Task {
const task: Task = {
id: this.nextId(),
subject: input.subject,
description: input.description,
activeForm: input.activeForm,
status: "pending",
blocks: [],
blockedBy: [],
metadata: input.metadata ? { ...input.metadata } : undefined,
};
this.tasks.set(task.id, task);
this.emit();
return task;
}
list(): TaskSummary[] {
return this.snapshot();
}
get(id: string): Task | undefined {
return this.tasks.get(id);
}
/** Applies an update. `status: "deleted"` removes the task (and prunes dangling block/blockedBy
* refs). Returns the updated task, or undefined if the task was deleted or doesn't exist. */
update(
id: string,
updates: {
status?: TaskStatus | "deleted";
subject?: string;
description?: string;
activeForm?: string;
owner?: string;
addBlocks?: string[];
addBlockedBy?: string[];
metadata?: Record<string, unknown>;
},
): Task | undefined {
const task = this.tasks.get(id);
if (!task) return undefined;
if (updates.status === "deleted") {
this.remove(id);
return undefined;
}
if (updates.status) task.status = updates.status;
if (updates.subject !== undefined) task.subject = updates.subject;
if (updates.description !== undefined) task.description = updates.description;
if (updates.activeForm !== undefined) task.activeForm = updates.activeForm;
if (updates.owner !== undefined) task.owner = updates.owner;
if (updates.addBlocks) {
for (const b of updates.addBlocks) {
// Only link to existing OTHER tasks; ignore self-refs, unknown ids, and duplicates.
if (b !== id && this.tasks.has(b) && !task.blocks.includes(b)) task.blocks.push(b);
}
}
if (updates.addBlockedBy) {
for (const b of updates.addBlockedBy) {
if (b === id || !this.tasks.has(b) || task.blockedBy.includes(b)) continue;
// Skip a direct 2-cycle: if b is already waiting on this task, don't make them wait on each other.
const other = this.tasks.get(b);
if (other && other.blockedBy.includes(id)) continue;
task.blockedBy.push(b);
}
}
if (updates.metadata) {
task.metadata = mergeMetadata(task.metadata, updates.metadata);
}
this.emit();
return task;
}
private remove(id: string): void {
this.tasks.delete(id);
// Prune dangling dependency refs in surviving tasks.
for (const other of this.tasks.values()) {
other.blocks = other.blocks.filter((b) => b !== id);
other.blockedBy = other.blockedBy.filter((b) => b !== id);
}
this.emit();
}
}
/** Merge-patches metadata: a null value deletes the key, any other value sets it. */
function mergeMetadata(
existing: Record<string, unknown> | undefined,
patch: Record<string, unknown>,
): Record<string, unknown> | undefined {
const out: Record<string, unknown> = { ...(existing ?? {}) };
for (const [k, v] of Object.entries(patch)) {
if (v === null) delete out[k];
else out[k] = v;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Returns a deep-enough copy of a task so handing it back in a tool result can't let the caller
* mutate the store's internal object. */
function serializeTask(t: Task): Task {
return {
...t,
blocks: [...t.blocks],
blockedBy: [...t.blockedBy],
metadata: t.metadata ? { ...t.metadata } : undefined,
};
}
const metadataSchema = z.record(z.string(), z.any()).optional();
const taskCreateSchema = z.object({
subject: z.string().min(1).describe("A brief, actionable title in imperative form (e.g. 'Fix authentication bug')."),
description: z.string().describe("What needs to be done, in enough detail to act on."),
activeForm: z
.string()
.optional()
.describe("Present-continuous label shown in the spinner while in_progress (e.g. 'Running tests'). Optional."),
metadata: metadataSchema,
});
export const taskCreateTool: ToolDef<z.infer<typeof taskCreateSchema>> = {
name: "task_create",
description:
"Create a structured task to track a unit of multi-step work. Use for non-trivial work (3+ steps) so progress is " +
"visible and dependencies can be expressed. Returns the new task with its id. Call task_list to see all tasks, " +
"task_get for full details, and task_update to set status, add dependencies (addBlocks/addBlockedBy), or claim ownership.",
schema: taskCreateSchema,
// Purely informational (tracks state in-memory, never touches the filesystem) — no confirmation prompt.
mutating: false,
handler: async (args, ctx) => {
if (!ctx.taskStore) return { error: "Task tracking is not available in this context." };
const task = ctx.taskStore.create(args);
return { id: task.id, task: serializeTask(task) };
},
};
const taskListSchema = z.object({});
export const taskListTool: ToolDef<z.infer<typeof taskListSchema>> = {
name: "task_list",
description:
"List all tasks with their id, subject, status, owner, and what blocks them. Use this to see overall progress and " +
"find the next available task to claim.",
schema: taskListSchema,
mutating: false,
handler: async (_args, ctx) => {
return { tasks: ctx.taskStore?.list() ?? [] };
},
};
const taskGetSchema = z.object({ taskId: z.string().min(1) });
export const taskGetTool: ToolDef<z.infer<typeof taskGetSchema>> = {
name: "task_get",
description:
"Get a task's full details (description, activeForm, blocks, blockedBy, metadata). Use before starting a task to " +
"verify its blockedBy list is empty — if it isn't, the blocking tasks must complete first.",
schema: taskGetSchema,
mutating: false,
handler: async (args, ctx) => {
const task = ctx.taskStore?.get(args.taskId);
return task ? { task: serializeTask(task) } : { error: `Task ${args.taskId} not found.` };
},
};
const taskUpdateSchema = z.object({
taskId: z.string().min(1),
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
subject: z.string().optional(),
description: z.string().optional(),
activeForm: z.string().optional(),
owner: z.string().optional(),
addBlocks: z.array(z.string()).optional(),
addBlockedBy: z.array(z.string()).optional(),
metadata: metadataSchema,
});
export const taskUpdateTool: ToolDef<z.infer<typeof taskUpdateSchema>> = {
name: "task_update",
description:
"Update a task: set status (pending|in_progress|completed — or 'deleted' to remove it), rename subject/description, " +
"set owner to claim it, add dependencies via addBlocks/addBlockedBy (task ids), or merge-patch metadata (set a key " +
"to null to delete it). Mark a task in_progress when starting it and completed when done. Verify blockedBy is empty " +
"before starting. Returns the updated task, or { deleted: id } when status is 'deleted'.",
schema: taskUpdateSchema,
mutating: false,
handler: async (args, ctx) => {
const store = ctx.taskStore;
if (!store) return { error: "Task tracking is not available in this context." };
if (!store.get(args.taskId)) return { error: `Task ${args.taskId} not found.` };
if (args.status === "deleted") {
store.update(args.taskId, args);
return { deleted: args.taskId };
}
const task = store.update(args.taskId, args);
return task ? { task: serializeTask(task) } : { deleted: args.taskId };
},
};
+4
View File
@@ -1,4 +1,5 @@
import type { z } from "zod";
import type { TaskStore } from "./task.js";
export interface SubAgentTask {
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
@@ -45,6 +46,9 @@ export interface ToolContext {
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
* future tool context is built without one — every session-backed context provides it. */
setTodos?: (todos: TodoItem[]) => void;
/** The session's structured task store (used by the task_create/list/get/update tools). Absent
* only if a tool context is built without one — every session-backed context provides it. */
taskStore?: TaskStore;
/** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool
* polls `requested` and, once true, detaches into the background job registry instead of
* awaiting completion. Absent for tools that don't support backgrounding. */