From 15756d307f802f2c3b22e7dced7427a66cbe77de Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 31 May 2026 23:36:14 +0800 Subject: [PATCH] feat(tools): TaskCreate family + Monitor (background tasks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements §0.1's "TaskCreate 全系" — async background tasks that run a sub-agent concurrently with the main turn (cron covers *recurring*; this covers *background*). Previously entirely missing. core (tasks/manager.ts): TaskManager — runner-agnostic. create() starts a task without blocking, pipes streamed output into the task buffer (onChunk) and flips status on settle; get/list/output/update/stop/wait. Each task is {id,description,status,output,createdAt,finishedAt}. tools (tools/task-manage.ts): TaskCreate / TaskList / TaskGet / TaskOutput / TaskUpdate / TaskStop / Monitor, driving ctx.tasks. Monitor awaits completion. All no-op gracefully when ctx.tasks is absent. agent loop: at depth 0 (top-level only), wires ctx.tasks = TaskManager whose runner backs each task with runSubAgent + a per-task AbortController (so TaskStop cancels just that task). runSubAgent gained an optional `signal`. The 7 tools are in SUBAGENT_TOOL_DENYLIST (a sub-agent can't spawn tasks) and MCP_SERVE_EXCLUDE. Registered in BUILTIN_TOOLS (now 26). Tests: +10 — TaskManager (create/wait/fail/stop/streaming/list/update) and the tools (create+Monitor round-trip, list/output, stop, no-manager guard, unknown ids). Core 618 green. Note: v1 surfaces final output on completion; per-chunk streaming from a live sub-agent is a follow-up (the manager already supports onChunk). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/agent.ts | 31 +++- packages/core/src/index.ts | 10 + packages/core/src/mcp/serve.ts | 7 + packages/core/src/tasks/manager.test.ts | 84 +++++++++ packages/core/src/tasks/manager.ts | 121 ++++++++++++ packages/core/src/tools/registry.ts | 2 + packages/core/src/tools/task-manage.test.ts | 71 ++++++++ packages/core/src/tools/task-manage.ts | 192 ++++++++++++++++++++ packages/core/src/types.ts | 8 + 9 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tasks/manager.test.ts create mode 100644 packages/core/src/tasks/manager.ts create mode 100644 packages/core/src/tools/task-manage.test.ts create mode 100644 packages/core/src/tools/task-manage.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 8629728..a377cfe 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -4,6 +4,7 @@ import { compact, shouldCompact } from './compaction/index.js'; import type { PermissionRules } from './config/types.js'; import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js'; +import { TaskManager } from './tasks/manager.js'; import type { HookDispatcher } from './hooks/index.js'; import type { Mode } from './types.js'; import type { Provider } from './providers/types.js'; @@ -102,6 +103,14 @@ const SUBAGENT_TOOL_DENYLIST = new Set([ 'EnterPlanMode', 'ExitPlanMode', 'AskUserQuestion', + // Background tasks are top-level only (a sub-agent has no task manager). + 'TaskCreate', + 'TaskList', + 'TaskGet', + 'TaskOutput', + 'TaskUpdate', + 'TaskStop', + 'Monitor', ]); /** Default turn cap for a sub-agent run when its frontmatter doesn't set one. */ const DEFAULT_SUBAGENT_MAX_TURNS = 12; @@ -209,7 +218,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { // tool, see the denylist below; this is belt-and-suspenders). const depth = opts.subAgentDepth ?? 0; if (depth < MAX_SUBAGENT_DEPTH) { - toolCtx.runSubAgent = async ({ prompt, agentType }) => { + toolCtx.runSubAgent = async ({ prompt, agentType, signal }) => { // Resolve a named sub-agent from disk (lazy import keeps node:fs out of // browser bundles; failures degrade to a generic sub-agent prompt). let systemPrompt = @@ -268,7 +277,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { temperature: opts.temperature, maxTurns: subMaxTurns, cwd: opts.cwd, - signal: opts.signal, + // A background task passes its own signal so TaskStop can cancel just + // that task; foreground sub-agents inherit the main run's signal. + signal: signal ?? opts.signal, mode: opts.mode, permissions: opts.permissions, hooks: opts.hooks, @@ -353,6 +364,22 @@ export async function runAgent(opts: RunAgentOptions): Promise { } } + // Background tasks (TaskCreate family) — only at the top level, backed by the + // sub-agent runner. Each task gets its own AbortController so TaskStop cancels + // just that task. A sub-agent (depth ≥ 1) gets no manager → can't spawn tasks. + if (depth === 0 && toolCtx.runSubAgent) { + const runSub = toolCtx.runSubAgent; + toolCtx.tasks = new TaskManager((spec) => { + const ac = new AbortController(); + const done = runSub({ + prompt: spec.prompt, + agentType: spec.agentType, + signal: ac.signal, + }).then((r) => r.text); + return { done, abort: () => ac.abort() }; + }); + } + const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }; let turnsUsed = 0; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8094737..084cc30 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -299,6 +299,16 @@ export { type ProviderImagePayload, } from './vision/index.js'; +// Background tasks (M3.15.3 — TaskCreate family + Monitor) +export { + TaskManager, + type Task, + type TaskStatus, + type TaskRunner, + type TaskRunHandle, + type CreateTaskSpec, +} from './tasks/manager.js'; + // IPC protocol (M6-rest — renderer ↔ main process type-safe channels) export { newTurnId, diff --git a/packages/core/src/mcp/serve.ts b/packages/core/src/mcp/serve.ts index 76657e1..5261e78 100644 --- a/packages/core/src/mcp/serve.ts +++ b/packages/core/src/mcp/serve.ts @@ -25,6 +25,13 @@ export const MCP_SERVE_EXCLUDE = new Set([ 'CronCreate', 'CronList', 'CronDelete', + 'TaskCreate', + 'TaskList', + 'TaskGet', + 'TaskOutput', + 'TaskUpdate', + 'TaskStop', + 'Monitor', ]); /** The subset of `tools` that is safe to expose over an MCP stdio server. */ diff --git a/packages/core/src/tasks/manager.test.ts b/packages/core/src/tasks/manager.test.ts new file mode 100644 index 0000000..1ad958e --- /dev/null +++ b/packages/core/src/tasks/manager.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { TaskManager, type TaskRunHandle } from './manager.js'; + +function deferred(): { + promise: Promise; + resolve: (v: string) => void; + reject: (e: unknown) => void; +} { + let resolve!: (v: string) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('TaskManager', () => { + it('create returns a running task; wait → completed + final output', async () => { + const d = deferred(); + const mgr = new TaskManager(() => ({ done: d.promise, abort: () => {} })); + const t = mgr.create({ description: 'x', prompt: 'do x' }); + expect(t.status).toBe('running'); + expect(t.id).toMatch(/^task-/); + + d.resolve('the result'); + const done = await mgr.wait(t.id); + expect(done?.status).toBe('completed'); + expect(done?.output).toBe('the result'); + expect(done?.finishedAt).toBeTruthy(); + }); + + it('a failed runner marks the task failed', async () => { + const d = deferred(); + const mgr = new TaskManager(() => ({ done: d.promise, abort: () => {} })); + const t = mgr.create({ description: 'x', prompt: 'y' }); + d.reject(new Error('boom')); + const done = await mgr.wait(t.id); + expect(done?.status).toBe('failed'); + expect(done?.output).toMatch(/boom/); + }); + + it('stop aborts the run + marks stopped; second stop is a no-op', async () => { + let aborted = false; + const mgr = new TaskManager( + () => + ({ done: new Promise(() => {}), abort: () => (aborted = true) }) as TaskRunHandle, + ); + const t = mgr.create({ description: 'x', prompt: 'y' }); + expect(mgr.stop(t.id)).toBe(true); + expect(aborted).toBe(true); + expect(mgr.get(t.id)?.status).toBe('stopped'); + expect(mgr.stop(t.id)).toBe(false); + }); + + it('a streaming runner accumulates output via onChunk (kept over final text)', async () => { + const d = deferred(); + let emit!: (c: string) => void; + const mgr = new TaskManager(() => ({ + done: d.promise, + abort: () => {}, + onChunk: (cb) => (emit = cb), + })); + const t = mgr.create({ description: 'x', prompt: 'y' }); + emit('chunk1 '); + emit('chunk2'); + expect(mgr.output(t.id)).toBe('chunk1 chunk2'); + d.resolve('final-ignored'); + await mgr.wait(t.id); + expect(mgr.output(t.id)).toBe('chunk1 chunk2'); + expect(mgr.get(t.id)?.status).toBe('completed'); + }); + + it('list / get / update / unknown-id behaviour', async () => { + const mgr = new TaskManager(() => ({ done: Promise.resolve('r'), abort: () => {} })); + const t = mgr.create({ description: 'orig', prompt: 'p' }); + expect(mgr.list().map((x) => x.id)).toEqual([t.id]); + expect(mgr.update(t.id, { description: 'renamed' })).toBe(true); + expect(mgr.get(t.id)?.description).toBe('renamed'); + expect(mgr.get('nope')).toBeUndefined(); + expect(mgr.update('nope', { description: 'x' })).toBe(false); + expect(mgr.output('nope')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/tasks/manager.ts b/packages/core/src/tasks/manager.ts new file mode 100644 index 0000000..dd82728 --- /dev/null +++ b/packages/core/src/tasks/manager.ts @@ -0,0 +1,121 @@ +// Background tasks — the agent spawns a long-running sub-agent that runs +// concurrently with the main turn; TaskList/Get/Output/Stop/Monitor inspect +// and control it. Spec: docs/DEVELOPMENT_PLAN.md §3.15.3 (TaskCreate family). +// +// The manager is runner-agnostic: TaskCreate hands it a `runner` (the agent +// loop wires one backed by runSubAgent) which it invokes WITHOUT blocking the +// caller, piping streamed output into the task buffer and flipping status when +// it settles. + +export type TaskStatus = 'running' | 'completed' | 'failed' | 'stopped'; + +export interface Task { + id: string; + description: string; + status: TaskStatus; + /** Accumulated output (streamed chunks for a live runner, else final text). */ + output: string; + createdAt: string; + finishedAt?: string; +} + +export interface TaskRunHandle { + /** Resolves with the task's final text when the run completes. */ + done: Promise; + /** Abort the run (best-effort). */ + abort: () => void; + /** Register a streamed-output sink (optional — runners may not stream). */ + onChunk?: (cb: (chunk: string) => void) => void; +} + +export interface CreateTaskSpec { + description: string; + prompt: string; + agentType?: string; +} + +/** Runner the host supplies: starts the work and returns a handle. */ +export type TaskRunner = (spec: CreateTaskSpec) => TaskRunHandle; + +export class TaskManager { + private readonly tasks = new Map(); + private readonly handles = new Map(); + private seq = 0; + + constructor(private readonly runner: TaskRunner) {} + + private newId(): string { + return `task-${(this.seq++).toString(36)}`; + } + + /** Start a background task; returns the task record immediately. */ + create(spec: CreateTaskSpec): Task { + const id = this.newId(); + const task: Task = { + id, + description: spec.description, + status: 'running', + output: '', + createdAt: new Date().toISOString(), + }; + this.tasks.set(id, task); + const handle = this.runner(spec); + this.handles.set(id, handle); + handle.onChunk?.((chunk) => { + const t = this.tasks.get(id); + if (t && t.status === 'running') t.output += chunk; + }); + handle.done.then( + (text) => this.settle(id, 'completed', text), + (err) => this.settle(id, 'failed', `Error: ${(err as Error).message}`), + ); + return { ...task }; + } + + private settle(id: string, status: TaskStatus, finalText: string): void { + const t = this.tasks.get(id); + if (!t || t.status !== 'running') return; // already stopped/settled + // For non-streaming runners output is empty until now → use the final text. + if (!t.output) t.output = finalText; + t.status = status; + t.finishedAt = new Date().toISOString(); + } + + get(id: string): Task | undefined { + const t = this.tasks.get(id); + return t ? { ...t } : undefined; + } + + list(): Task[] { + return [...this.tasks.values()].map((t) => ({ ...t })); + } + + output(id: string): string | undefined { + return this.tasks.get(id)?.output; + } + + /** Abort a running task. Returns false if unknown or already finished. */ + stop(id: string): boolean { + const t = this.tasks.get(id); + if (!t || t.status !== 'running') return false; + this.handles.get(id)?.abort(); + t.status = 'stopped'; + t.finishedAt = new Date().toISOString(); + return true; + } + + /** Update mutable task metadata (currently the description). */ + update(id: string, patch: { description?: string }): boolean { + const t = this.tasks.get(id); + if (!t) return false; + if (patch.description !== undefined) t.description = patch.description; + return true; + } + + /** Await a task's completion (resolves immediately if already settled). */ + async wait(id: string): Promise { + const handle = this.handles.get(id); + if (handle) await handle.done.catch(() => undefined); + return this.get(id); + } +} diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index dc29a63..e44be4f 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -6,6 +6,7 @@ import { AskUserQuestionTool } from './ask-user.js'; import { BashTool } from './bash.js'; import { CronCreateTool, CronDeleteTool, CronListTool } from './cron-tools.js'; import { EditTool } from './edit.js'; +import { TASK_TOOLS } from './task-manage.js'; import { EnterPlanModeTool } from './enter-plan.js'; import { ExitPlanModeTool } from './exit-plan.js'; import { GlobTool } from './glob.js'; @@ -47,6 +48,7 @@ export const BUILTIN_TOOLS: ToolHandler[] = [ CronCreateTool, CronListTool, CronDeleteTool, + ...TASK_TOOLS, ]; export class ToolRegistry { diff --git a/packages/core/src/tools/task-manage.test.ts b/packages/core/src/tools/task-manage.test.ts new file mode 100644 index 0000000..eb8b59a --- /dev/null +++ b/packages/core/src/tools/task-manage.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { TaskManager } from '../tasks/manager.js'; +import type { ToolContext } from '../types.js'; +import { + MonitorTool, + TaskCreateTool, + TaskListTool, + TaskOutputTool, + TaskStopTool, +} from './task-manage.js'; + +function ctxWith(manager?: TaskManager): ToolContext { + return { cwd: '/tmp', tasks: manager }; +} + +describe('task tools', () => { + it('TaskCreate starts a task and Monitor awaits its output', async () => { + const mgr = new TaskManager((spec) => ({ + done: Promise.resolve(`did: ${spec.prompt}`), + abort: () => {}, + })); + const ctx = ctxWith(mgr); + const created = await TaskCreateTool.execute({ prompt: 'analyze logs' }, ctx); + const id = (created.data as { id: string }).id; + expect(created.content).toMatch(/Started background task/); + + const mon = await MonitorTool.execute({ id }, ctx); + expect(mon.isError ?? false).toBe(false); + expect(mon.content).toContain('completed'); + expect(mon.content).toContain('did: analyze logs'); + }); + + it('TaskList + TaskOutput reflect created tasks', async () => { + const mgr = new TaskManager(() => ({ done: Promise.resolve('out'), abort: () => {} })); + const ctx = ctxWith(mgr); + const { data } = await TaskCreateTool.execute({ prompt: 'p', description: 'job A' }, ctx); + const id = (data as { id: string }).id; + await mgr.wait(id); + + const list = await TaskListTool.execute({}, ctx); + expect(list.content).toContain('job A'); + expect(list.content).toContain('[completed]'); + + const out = await TaskOutputTool.execute({ id }, ctx); + expect(out.content).toBe('out'); + }); + + it('TaskStop cancels a running task', async () => { + const mgr = new TaskManager(() => ({ done: new Promise(() => {}), abort: () => {} })); + const ctx = ctxWith(mgr); + const { data } = await TaskCreateTool.execute({ prompt: 'long' }, ctx); + const id = (data as { id: string }).id; + const stop = await TaskStopTool.execute({ id }, ctx); + expect(stop.content).toMatch(/Stopped task/); + expect(mgr.get(id)?.status).toBe('stopped'); + }); + + it('tools error gracefully without a task manager (e.g. sub-agent)', async () => { + const ctx = ctxWith(undefined); + const r = await TaskCreateTool.execute({ prompt: 'x' }, ctx); + expect(r.isError).toBe(true); + expect(r.content).toMatch(/unavailable here/); + expect((await TaskListTool.execute({}, ctx)).isError).toBe(true); + }); + + it('Monitor/TaskOutput report unknown ids', async () => { + const ctx = ctxWith(new TaskManager(() => ({ done: Promise.resolve(''), abort: () => {} }))); + expect((await MonitorTool.execute({ id: 'nope' }, ctx)).isError).toBe(true); + expect((await TaskOutputTool.execute({ id: 'nope' }, ctx)).isError).toBe(true); + }); +}); diff --git a/packages/core/src/tools/task-manage.ts b/packages/core/src/tools/task-manage.ts new file mode 100644 index 0000000..7ff198d --- /dev/null +++ b/packages/core/src/tools/task-manage.ts @@ -0,0 +1,192 @@ +// TaskCreate / TaskList / TaskGet / TaskOutput / TaskUpdate / TaskStop / Monitor +// — drive the background-task manager (ctx.tasks). Spec: §3.15.3. +// +// A background task runs a sub-agent concurrently with the main turn; the agent +// kicks one off with TaskCreate, keeps working, then Monitor/TaskOutput collect +// the result. All no-op gracefully when ctx.tasks is absent (sub-agent / no host). + +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; + +function noManager(): ToolResult { + return { + content: 'Background tasks are unavailable here (only the top-level agent can create them).', + isError: true, + }; +} + +export const TaskCreateTool: ToolHandler = { + name: 'TaskCreate', + definition: { + name: 'TaskCreate', + description: + 'Start a background task: a sub-agent runs `prompt` concurrently while you keep working. Returns a task id immediately — use Monitor/TaskOutput to collect the result, TaskStop to cancel.', + inputSchema: { + type: 'object', + properties: { + description: { type: 'string', description: 'Short label for the task.' }, + prompt: { type: 'string', description: 'What the background sub-agent should do.' }, + agentType: { type: 'string', description: 'Optional named sub-agent to use.' }, + }, + required: ['prompt'], + }, + }, + async execute(raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const input = raw as { description?: string; prompt?: string; agentType?: string }; + if (!input.prompt) return { content: 'Error: prompt is required.', isError: true }; + const task = ctx.tasks.create({ + description: input.description ?? input.prompt.slice(0, 60), + prompt: input.prompt, + agentType: input.agentType, + }); + return { + content: `Started background task ${task.id} ("${task.description}"). Use Monitor("${task.id}") to await it.`, + data: { id: task.id }, + }; + }, +}; + +export const TaskListTool: ToolHandler = { + name: 'TaskList', + definition: { + name: 'TaskList', + description: 'List background tasks (id, status, description).', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + async execute(_raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const tasks = ctx.tasks.list(); + if (tasks.length === 0) return { content: 'No background tasks.' }; + return { + content: tasks.map((t) => `${t.id} [${t.status}] ${t.description}`).join('\n'), + data: { tasks }, + }; + }, +}; + +export const TaskGetTool: ToolHandler = { + name: 'TaskGet', + definition: { + name: 'TaskGet', + description: "Get a background task's status + metadata by id.", + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + async execute(raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const { id } = raw as { id?: string }; + const task = id ? ctx.tasks.get(id) : undefined; + if (!task) return { content: `No task "${id}".`, isError: true }; + return { + content: `${task.id} [${task.status}] "${task.description}" (created ${task.createdAt}${task.finishedAt ? `, finished ${task.finishedAt}` : ''})`, + data: { task }, + }; + }, +}; + +export const TaskOutputTool: ToolHandler = { + name: 'TaskOutput', + definition: { + name: 'TaskOutput', + description: "Read a background task's output so far (empty until it produces results).", + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + async execute(raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const { id } = raw as { id?: string }; + const task = id ? ctx.tasks.get(id) : undefined; + if (!task) return { content: `No task "${id}".`, isError: true }; + return { + content: task.output || `(task ${task.id} is ${task.status} with no output yet)`, + data: { id: task.id, status: task.status }, + }; + }, +}; + +export const TaskUpdateTool: ToolHandler = { + name: 'TaskUpdate', + definition: { + name: 'TaskUpdate', + description: "Update a background task's description.", + inputSchema: { + type: 'object', + properties: { id: { type: 'string' }, description: { type: 'string' } }, + required: ['id', 'description'], + }, + }, + async execute(raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const { id, description } = raw as { id?: string; description?: string }; + if (!id || description === undefined) { + return { content: 'Error: id and description are required.', isError: true }; + } + return ctx.tasks.update(id, { description }) + ? { content: `Updated task ${id}.` } + : { content: `No task "${id}".`, isError: true }; + }, +}; + +export const TaskStopTool: ToolHandler = { + name: 'TaskStop', + definition: { + name: 'TaskStop', + description: 'Cancel a running background task by id.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + async execute(raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const { id } = raw as { id?: string }; + if (!id) return { content: 'Error: id is required.', isError: true }; + return ctx.tasks.stop(id) + ? { content: `Stopped task ${id}.` } + : { content: `Task "${id}" is unknown or already finished.`, isError: true }; + }, +}; + +export const MonitorTool: ToolHandler = { + name: 'Monitor', + definition: { + name: 'Monitor', + description: + 'Wait for a background task to finish and return its final output. Blocks until the task completes, fails, or is stopped.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + async execute(raw, ctx: ToolContext): Promise { + if (!ctx.tasks) return noManager(); + const { id } = raw as { id?: string }; + if (!id) return { content: 'Error: id is required.', isError: true }; + if (!ctx.tasks.get(id)) return { content: `No task "${id}".`, isError: true }; + const task = await ctx.tasks.wait(id); + if (!task) return { content: `No task "${id}".`, isError: true }; + return { + content: `Task ${task.id} ${task.status}.\n\n${task.output}`, + isError: task.status === 'failed', + data: { status: task.status }, + }; + }, +}; + +export const TASK_TOOLS: ToolHandler[] = [ + TaskCreateTool, + TaskListTool, + TaskGetTool, + TaskOutputTool, + TaskUpdateTool, + TaskStopTool, + MonitorTool, +]; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c0131c0..13c2b75 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -142,6 +142,8 @@ export interface ToolContext { prompt: string; agentType?: string; description?: string; + /** Per-call abort (used by background tasks so TaskStop can cancel one). */ + signal?: AbortSignal; }) => Promise<{ text: string; turnsUsed: number; agentType: string }>; /** * Active git worktree the agent has entered via EnterWorktree. While set, @@ -149,6 +151,12 @@ export interface ToolContext { * worktree and restore the original cwd. Mutated in place by those tools. */ worktree?: { path: string; branch: string; source: string; originalCwd: string }; + /** + * Background-task manager (the TaskCreate family + Monitor). Supplied by the + * agent loop at the top level; absent in sub-agents (so a background task + * can't spawn more) and in the renderer. + */ + tasks?: import('./tasks/manager.js').TaskManager; } export interface ToolResult {