@chances-ai/engine 27.0.0 → 28.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,26 @@ export const TASK_TOOL_NAME = "task";
15
15
  * same well-known name. (7.5 §14.1) Exported so `buildEngine`'s isolated
16
16
  * per-session ToolRegistry can drop+re-register the stateful pty closure. */
17
17
  export const PTY_TOOL_NAME = "pty";
18
+ /** (7.8 §3.2) The coordinator's orchestration tool names. `buildChildEngine`
19
+ * drops these from every child's tool surface (anti-recursion): a worker MUST
20
+ * NOT be able to spawn / message / stop workers — multi-level orchestration is a
21
+ * later EXTENSION, not v1 (north-star 3.5). Defined here (not in
22
+ * `coordinator-tools.ts`) so `buildChildEngine` can reference them without a
23
+ * cycle (coordinator-tools imports `createChildAgentRuntime` from this file). */
24
+ export const SPAWN_WORKER_TOOL_NAME = "spawn_worker";
25
+ export const SEND_MESSAGE_TOOL_NAME = "send_message";
26
+ export const STOP_WORKER_TOOL_NAME = "stop_worker";
27
+ export const DISMISS_WORKER_TOOL_NAME = "dismiss_worker";
28
+ export const CREATE_TEAM_TOOL_NAME = "create_team";
29
+ export const LIST_WORKERS_TOOL_NAME = "list_workers";
30
+ export const COORDINATOR_TOOL_NAMES = new Set([
31
+ SPAWN_WORKER_TOOL_NAME,
32
+ SEND_MESSAGE_TOOL_NAME,
33
+ STOP_WORKER_TOOL_NAME,
34
+ DISMISS_WORKER_TOOL_NAME,
35
+ CREATE_TEAM_TOOL_NAME,
36
+ LIST_WORKERS_TOOL_NAME,
37
+ ]);
18
38
  /** Upper bound on the child's `maxTurns`. The parent's config-supplied value
19
39
  * is honored; this clamp only ensures a misconfigured 1 000-turn budget
20
40
  * doesn't let a single `task` invocation wedge the agent for hours. */
@@ -85,38 +105,311 @@ function strArg(args, key, required) {
85
105
  return v;
86
106
  }
87
107
  /**
88
- * Builds the `task` built-in: a model-callable tool that spins up a child
89
- * `AgentEngine` with its own ephemeral session, runs to completion, and
90
- * returns the child's final assistant text.
108
+ * (7.8 §3.2 / codex R1-§3 MUST) The SHARED child-agent build persona
109
+ * resolution + tool filtering + cross-provider fork gate + worktree creation +
110
+ * per-subagent PTY registration + child `AgentEngine` construction. Extracted
111
+ * verbatim from `createTaskTool.execute` so the one-shot `task` AND the
112
+ * persistent `spawn_worker` build IDENTICALLY (no drift). The CALLER owns the
113
+ * run orchestration + WHEN to finalize: the one-shot finalizes the worktree +
114
+ * drains PTY right after its single run; a persistent worker defers both to
115
+ * terminal close (else the worktree would be torn out between messages).
91
116
  *
92
- * **Register per-engine, not at app boot.** This factory must be called
93
- * from inside `buildEngine` (per session), not once when the app starts.
94
- * Two reasons:
95
- * 1. The child engine consumes whatever tools are in the registry *at the
96
- * moment the child runs* — MCP-bridged tools come and go between
97
- * sessions, and the child should see the same surface the parent sees
98
- * *now*, not the surface from boot.
99
- * 2. The closure captures the parent's `bus` / `gate` / `memory`
100
- * references; rebuilds replace those references when the engine is
101
- * re-created (e.g. on `/resume`). Boot-time registration would freeze
102
- * stale dependencies and quietly drift across respawns.
103
- *
104
- * **Anti-recursion.** The child's tool registry excludes `task` itself —
105
- * a model that can call `task` recursively without depth limits will
106
- * fan out subagents until the process exhausts memory. v1 keeps the
107
- * blanket exclusion; v2+ may relax it with an explicit depth cap.
108
- *
109
- * **No `PluginHost` passthrough.** Plugins already observe the parent's
110
- * activity via bus events; running their hooks on every child turn would
111
- * either double-fire (afterToolCall for the same parent-level decision)
112
- * or silently confuse plugins that assume a single conversation lifecycle.
113
- * Revisit if a real need emerges.
114
- *
115
- * **Shared `EventBus`.** Child token usage, tool calls, assistant deltas
116
- * fire on the parent's bus. `chances stats` and the TUI therefore already
117
- * surface subagent cost without any new channels — the only signal that
118
- * an event came from a child is the surrounding tool-call frame.
117
+ * `agentIdMode` controls event-tagging identity (the one byte-level difference
118
+ * between callers, preserved exactly):
119
+ * - `"none"` → sync one-shot: `{agentName}` (no agentId; the parent labels
120
+ * sync children at the tool:call/result boundary).
121
+ * - `"deferred"` background one-shot: `{agentId:"", agentName}`; the caller
122
+ * sets `.agentId` to the registry task id after `launch()`.
123
+ * - `{agentId}` → persistent worker: tagged from birth.
124
+ */
125
+ export async function buildChildEngine(deps, args, childMaxTurns, agentIdMode, creationSignal) {
126
+ const catalog = deps.agents;
127
+ const prompt = args.prompt;
128
+ const subagentType = args.subagentType;
129
+ let agent;
130
+ if (subagentType !== undefined && subagentType.length > 0) {
131
+ if (!catalog || catalog.size === 0) {
132
+ return {
133
+ ok: false,
134
+ output: `subagent_type '${subagentType}' was provided but no agent catalog is configured for this session.`,
135
+ };
136
+ }
137
+ agent = catalog.get(subagentType);
138
+ if (!agent) {
139
+ const available = [...catalog.keys()].sort().join(", ");
140
+ return { ok: false, output: `Unknown agent type '${subagentType}'. Available: ${available}` };
141
+ }
142
+ }
143
+ // Build the child registry from a single computed survivor list (drop `task`;
144
+ // intersect persona allow; subtract persona disallow — all fail-closed).
145
+ const parentNames = new Set();
146
+ const parentByName = new Map();
147
+ for (const t of deps.parentTools.list()) {
148
+ // Drop `task` (recursion) AND the coordinator orchestration tools — a child
149
+ // must not spawn/message/stop workers (anti-recursion, 7.8 §3.2).
150
+ if (t.name === TASK_TOOL_NAME || COORDINATOR_TOOL_NAMES.has(t.name))
151
+ continue;
152
+ parentNames.add(t.name);
153
+ parentByName.set(t.name, t);
154
+ }
155
+ if (agent) {
156
+ if (agent.tools !== "*") {
157
+ const unknown = agent.tools.filter((n) => !parentNames.has(n));
158
+ if (unknown.length > 0) {
159
+ const available = [...parentNames].sort().join(", ");
160
+ return {
161
+ ok: false,
162
+ output: `Agent '${agent.name}' references unknown tool(s) in 'tools': ${unknown.join(", ")}. Available tools: ${available}`,
163
+ };
164
+ }
165
+ }
166
+ const unknownDisallowed = agent.disallowedTools.filter((n) => !parentNames.has(n));
167
+ if (unknownDisallowed.length > 0) {
168
+ const available = [...parentNames].sort().join(", ");
169
+ return {
170
+ ok: false,
171
+ output: `Agent '${agent.name}' references unknown tool(s) in 'disallowedTools': ${unknownDisallowed.join(", ")}. Available tools: ${available}`,
172
+ };
173
+ }
174
+ }
175
+ const allowSet = agent && agent.tools !== "*" ? new Set(agent.tools) : parentNames;
176
+ const denySet = new Set(agent?.disallowedTools ?? []);
177
+ // Resolve effective isolation (input override > frontmatter > none).
178
+ const inputIsolation = args.isolation;
179
+ let isolationMode = "none";
180
+ if (inputIsolation !== undefined) {
181
+ if (inputIsolation !== "worktree" && inputIsolation !== "none") {
182
+ return {
183
+ ok: false,
184
+ output: `Invalid 'isolation' value '${inputIsolation}'. Expected 'worktree' or 'none'.`,
185
+ };
186
+ }
187
+ isolationMode = inputIsolation;
188
+ }
189
+ else if (agent?.isolation === "worktree") {
190
+ isolationMode = "worktree";
191
+ }
192
+ const childTools = new ToolRegistry();
193
+ const mcpFilterActive = isolationMode === "worktree" && !agent?.unsafeAllowMutatingMcp;
194
+ let mcpToolsFiltered = 0;
195
+ for (const t of parentByName.values()) {
196
+ if (!allowSet.has(t.name))
197
+ continue;
198
+ if (denySet.has(t.name))
199
+ continue;
200
+ if (t.name === PTY_TOOL_NAME)
201
+ continue;
202
+ if (mcpFilterActive && isMcpToolName(t.name) && !isReadOnlyMcpCategory(t.category)) {
203
+ mcpToolsFiltered++;
204
+ continue;
205
+ }
206
+ childTools.register(t);
207
+ }
208
+ // Resolve model override (persona model MUST resolve through the registry).
209
+ let childSelection;
210
+ if (agent?.model) {
211
+ if (!deps.registry) {
212
+ return {
213
+ ok: false,
214
+ output: `Agent '${agent.name}' specifies model '${agent.model}' but the task tool was wired without a ModelRegistry; cannot validate.`,
215
+ };
216
+ }
217
+ const resolved = deps.registry.get(agent.model);
218
+ if (!resolved) {
219
+ return {
220
+ ok: false,
221
+ output: `Agent '${agent.name}' specifies model '${agent.model}' which is not in the registry. Run 'chances doctor' to see configured providers and known models.`,
222
+ };
223
+ }
224
+ childSelection = new ModelSelection({ model: agent.model });
225
+ }
226
+ const childTurnsBudget = agent?.maxTurns
227
+ ? Math.min(Math.max(1, agent.maxTurns), CHILD_MAX_TURNS_CEILING)
228
+ : childMaxTurns;
229
+ // fork-from-parent resolution (BEFORE worktree, so a gate refusal returns
230
+ // cleanly with nothing to tear down).
231
+ const inputContext = args.context;
232
+ if (inputContext !== undefined && inputContext !== "fork" && inputContext !== "fresh") {
233
+ return {
234
+ ok: false,
235
+ output: `Invalid 'context' value '${inputContext}'. Expected 'fork' or 'fresh'.`,
236
+ };
237
+ }
238
+ const forkRequested = inputContext === "fork";
239
+ let effectiveSelection = childSelection;
240
+ let forkedChildSession;
241
+ let firstPrompt = prompt;
242
+ if (forkRequested) {
243
+ if (!deps.parentSession) {
244
+ return {
245
+ ok: false,
246
+ output: "context:'fork' was requested but the task tool was wired without a parent session; fork is unavailable. Use a fresh subagent with a self-contained prompt.",
247
+ };
248
+ }
249
+ const parentChoice = deps.parentSelection?.get() ?? {};
250
+ const parentModel = deps.router.pick({
251
+ preferredModel: parentChoice.model,
252
+ preferredProvider: parentChoice.provider,
253
+ needsTools: true,
254
+ }).model;
255
+ if (!effectiveSelection) {
256
+ effectiveSelection = new ModelSelection({
257
+ provider: parentChoice.provider,
258
+ model: parentChoice.model,
259
+ });
260
+ }
261
+ const childChoice = effectiveSelection.get();
262
+ const childModel = deps.router.pick({
263
+ preferredModel: childChoice.model,
264
+ preferredProvider: childChoice.provider,
265
+ needsTools: true,
266
+ }).model;
267
+ if (childModel.provider !== parentModel.provider && !deps.allowCrossProviderFork) {
268
+ return {
269
+ ok: false,
270
+ output: `context:'fork' would send the full conversation transcript to provider '${childModel.provider}' (model ${childModel.id}), which differs from this session's provider '${parentModel.provider}'. Set config.agent.allowCrossProviderFork=true to permit cross-provider forks, or drop the persona model override.`,
271
+ };
272
+ }
273
+ forkedChildSession = SessionManager.forkFrom(deps.parentSession, "subagent");
274
+ firstPrompt = buildForkDirective(prompt, isolationMode === "worktree");
275
+ const inheritedText = forkedChildSession.messages().map(messageToText).join("\n");
276
+ const toolDefsText = childTools
277
+ .list()
278
+ .map((t) => `${t.name}\n${t.description}\n${JSON.stringify(t.parameters)}`)
279
+ .join("\n");
280
+ const systemText = deps.memory?.asSystemContext() ?? "";
281
+ const estimate = estimateTokens(`${systemText}\n${toolDefsText}\n${inheritedText}\n${firstPrompt}`) +
282
+ SYSTEM_BASE_RESERVE_TOKENS;
283
+ const budget = deps.forkMaxContextTokens ??
284
+ (childModel.contextWindow
285
+ ? Math.floor(childModel.contextWindow * FORK_WINDOW_FRACTION)
286
+ : CONSERVATIVE_FORK_DEFAULT);
287
+ if (estimate > budget) {
288
+ return {
289
+ ok: false,
290
+ output: `Fork context (~${estimate} tokens, incl. tools + system) exceeds the budget (${budget} tokens) for model ${childModel.id}. Spawn a fresh subagent with an explicit prompt, /compact the conversation first, or raise config.agent.forkMaxContextTokens.`,
291
+ };
292
+ }
293
+ }
294
+ // Create the worktree (cancellation flows in via `creationSignal`).
295
+ let worktreeHandle;
296
+ if (isolationMode === "worktree") {
297
+ try {
298
+ worktreeHandle = await createAgentWorktree(deps.workspaceRoot, { signal: creationSignal });
299
+ }
300
+ catch (e) {
301
+ if (e instanceof WorktreeError) {
302
+ return {
303
+ ok: false,
304
+ output: `Isolation worktree could not be created (${e.code}): ${e.message}. Hint: ensure the workspace is a git repository and 'git' is on PATH.`,
305
+ };
306
+ }
307
+ throw e;
308
+ }
309
+ }
310
+ const worktreeCwd = worktreeHandle?.path;
311
+ const agentName = agent?.name ?? "default";
312
+ const agentContext = agentIdMode === "none"
313
+ ? { agentName }
314
+ : agentIdMode === "deferred"
315
+ ? { agentId: "", agentName }
316
+ : { agentId: agentIdMode.agentId, agentName };
317
+ // Per-subagent PTY instance scoped to a fresh agentId.
318
+ const childPtyAgentId = createId("sub");
319
+ if (deps.ptySessions && deps.nativeCreatePtySession) {
320
+ childTools.register(createPtyTool({
321
+ registry: deps.ptySessions,
322
+ agentId: childPtyAgentId,
323
+ agentName,
324
+ workspaceRoot: deps.workspaceRoot,
325
+ defaultCwd: worktreeCwd ?? deps.workspaceRoot,
326
+ worktreeCwd,
327
+ createHandle: deps.nativeCreatePtySession,
328
+ }));
329
+ }
330
+ const childSession = forkedChildSession ?? SessionManager.create("subagent");
331
+ const engine = new AgentEngine({
332
+ bus: deps.bus,
333
+ router: deps.router,
334
+ tools: childTools,
335
+ gate: deps.gate,
336
+ getApprovalMode: deps.getApprovalMode,
337
+ resolveMcpMentions: deps.resolveMcpMentions,
338
+ session: childSession,
339
+ memory: deps.memory,
340
+ workspaceRoot: deps.workspaceRoot,
341
+ maxTurns: childTurnsBudget,
342
+ // A subagent is non-interactive — a max-turns ceiling must FAIL (so the
343
+ // parent's task result surfaces it), not pause for a "continue?".
344
+ pauseOnMaxTurns: false,
345
+ suppressTerminalErrors: true,
346
+ suppressLifecycleEvents: true,
347
+ agentContext,
348
+ systemBaseOverride: agent?.systemPrompt,
349
+ selection: effectiveSelection,
350
+ worktreeCwd,
351
+ });
352
+ return {
353
+ ok: true,
354
+ engine,
355
+ agentName,
356
+ agentContext,
357
+ firstPrompt,
358
+ worktreeHandle,
359
+ childPtyAgentId,
360
+ mcpToolsFiltered,
361
+ };
362
+ }
363
+ /**
364
+ * (7.8 §3.2) Wrap a built child engine into a {@link ChildAgentRuntime} for the
365
+ * persistent-worker `WorkerRegistry` — `runMessage` runs one message against the
366
+ * persistent child session (context accumulates across messages), and
367
+ * `finalize`/`drainPty` defer the worktree retention + PTY drain to terminal
368
+ * close. Returns the fork-wrapped `firstPrompt` for the registry's first mailbox
369
+ * entry. The minted `agentId` tags the worker's events from birth.
119
370
  */
371
+ export async function createChildAgentRuntime(deps, args, childMaxTurns, creationSignal) {
372
+ const agentId = createId("worker");
373
+ const built = await buildChildEngine(deps, args, childMaxTurns, { agentId }, creationSignal);
374
+ if (!built.ok)
375
+ return built;
376
+ const { engine, agentName, worktreeHandle, childPtyAgentId, mcpToolsFiltered } = built;
377
+ const drain = makeDrainChildPty(deps, childPtyAgentId);
378
+ const runtime = {
379
+ agentId,
380
+ agentName,
381
+ runMessage: async (prompt, signal) => {
382
+ const startedAt = Date.now();
383
+ try {
384
+ const result = await engine.runTurn(prompt, tokenFromSignal(signal), {
385
+ expandMentions: false,
386
+ });
387
+ return {
388
+ ok: true,
389
+ text: result.text.trim() || "(worker finished without producing any text)",
390
+ durationMs: Date.now() - startedAt,
391
+ tokens: { input: result.inputTokens, output: result.outputTokens },
392
+ };
393
+ }
394
+ catch (e) {
395
+ if (signal.aborted || (e instanceof AppError && e.code === ErrorCode.Cancelled)) {
396
+ return { ok: false, text: "Worker message cancelled", durationMs: Date.now() - startedAt };
397
+ }
398
+ const msg = e instanceof AppError
399
+ ? `Worker failed (${e.code}): ${e.message}`
400
+ : `Worker failed: ${e.message || String(e)}`;
401
+ return { ok: false, text: msg, durationMs: Date.now() - startedAt };
402
+ }
403
+ },
404
+ finalize: async () => {
405
+ // Probe + cleanup ONCE at terminal close. Returns the retention banner
406
+ // (or mcp-filter notice) to surface, or undefined when nothing was kept.
407
+ return finalizeWorktreeBanner(worktreeHandle, deps.workspaceRoot, mcpToolsFiltered);
408
+ },
409
+ drainPty: drain,
410
+ };
411
+ return { ok: true, runtime, firstPrompt: built.firstPrompt };
412
+ }
120
413
  export function createTaskTool(deps) {
121
414
  const childMaxTurns = Math.min(Math.max(1, deps.maxTurns ?? DEFAULT_MAX_TURNS), CHILD_MAX_TURNS_CEILING);
122
415
  const baseDescription = "Delegate a self-contained sub-task to a child agent. By default the child starts with a FRESH, empty conversation (it sees only your `prompt`) — use this when a task benefits from a clean context: focused file exploration, isolated refactor planning, scoped research. Set `context: \"fork\"` to instead give the child a copy of THIS conversation's completed history (everything you've already read/run), for \"continue this exact line of work\" tasks. The child has access to the same tools as you (except `task` itself — no recursion) and shares your permission gate, so session approvals carry through. The child runs to completion and returns its final assistant text as a single payload. Token usage / cost flow back to this session's telemetry.";
@@ -224,362 +517,47 @@ export function createTaskTool(deps) {
224
517
  },
225
518
  async execute(args, ctx) {
226
519
  const prompt = strArg(args, "prompt", true);
227
- const subagentType = strArg(args, "subagent_type", false);
228
- let agent;
229
- if (subagentType !== undefined && subagentType.length > 0) {
230
- if (!catalog || catalog.size === 0) {
231
- return {
232
- ok: false,
233
- output: `subagent_type '${subagentType}' was provided but no agent catalog is configured for this session.`,
234
- };
235
- }
236
- agent = catalog.get(subagentType);
237
- if (!agent) {
238
- const available = [...catalog.keys()].sort().join(", ");
239
- return {
240
- ok: false,
241
- output: `Unknown agent type '${subagentType}'. Available: ${available}`,
242
- };
243
- }
244
- }
245
- // Build the child registry from a single computed survivor list.
246
- // 1. Snapshot parent → drop `task` (recursion still blocked in 3.3).
247
- // 2. If persona has explicit `tools`, intersect (fail-closed on
248
- // unknown names — codex Round-1 #5).
249
- // 3. If persona has `disallowedTools`, subtract (same fail-closed
250
- // semantics; unknown disallow name → ok:false rather than
251
- // leaving the real tool reachable).
252
- // 4. Construct one ToolRegistry from the final list. ToolRegistry's
253
- // v1 surface only exposes `register` + `list`, so we never
254
- // register-then-remove — we compute survivors first, then build.
255
- const parentNames = new Set();
256
- const parentByName = new Map();
257
- for (const t of deps.parentTools.list()) {
258
- if (t.name === TASK_TOOL_NAME)
259
- continue;
260
- parentNames.add(t.name);
261
- parentByName.set(t.name, t);
262
- }
263
- if (agent) {
264
- if (agent.tools !== "*") {
265
- const unknown = agent.tools.filter((n) => !parentNames.has(n));
266
- if (unknown.length > 0) {
267
- const available = [...parentNames].sort().join(", ");
268
- return {
269
- ok: false,
270
- output: `Agent '${agent.name}' references unknown tool(s) in 'tools': ${unknown.join(", ")}. Available tools: ${available}`,
271
- };
272
- }
273
- }
274
- const unknownDisallowed = agent.disallowedTools.filter((n) => !parentNames.has(n));
275
- if (unknownDisallowed.length > 0) {
276
- const available = [...parentNames].sort().join(", ");
277
- return {
278
- ok: false,
279
- output: `Agent '${agent.name}' references unknown tool(s) in 'disallowedTools': ${unknownDisallowed.join(", ")}. Available tools: ${available}`,
280
- };
281
- }
282
- }
283
- const allowSet = agent && agent.tools !== "*"
284
- ? new Set(agent.tools)
285
- : parentNames; // `*` or no agent → full surface (minus `task`)
286
- const denySet = new Set(agent?.disallowedTools ?? []);
287
- // (4.1) Resolve effective isolation mode. Order:
288
- // 1) `task` tool input `isolation` (model-callable override)
289
- // 2) agent-catalog frontmatter `isolation`
290
- // 3) default 'none'
291
- // The schema enum makes this self-documenting; we still validate
292
- // the value defensively because `JSONValue` is permissive.
293
- const inputIsolation = strArg(args, "isolation", false);
294
- let isolationMode = "none";
295
- if (inputIsolation !== undefined) {
296
- if (inputIsolation !== "worktree" && inputIsolation !== "none") {
297
- return {
298
- ok: false,
299
- output: `Invalid 'isolation' value '${inputIsolation}'. Expected 'worktree' or 'none'.`,
300
- };
301
- }
302
- isolationMode = inputIsolation;
303
- }
304
- else if (agent?.isolation === "worktree") {
305
- isolationMode = "worktree";
306
- }
307
- const childTools = new ToolRegistry();
308
- const mcpFilterActive = isolationMode === "worktree" && !agent?.unsafeAllowMutatingMcp;
309
- let mcpToolsFiltered = 0;
310
- for (const t of parentByName.values()) {
311
- if (!allowSet.has(t.name))
312
- continue;
313
- if (denySet.has(t.name))
314
- continue;
315
- // (5.1) Parent's `pty` instance is closed over the parent's
316
- // agentId — registering it as-is for the child would create
317
- // child sessions in the parent's ownership bucket, defeating
318
- // D10 symmetric isolation and the `drainOwnedBy(childAgentId)`
319
- // contract. Skip here and re-register with the child's id below.
320
- if (t.name === PTY_TOOL_NAME)
321
- continue;
322
- // (4.1 — Round-1 MUST-FIX #2) MCP tools run in their own
323
- // process; the worktree's cwd does NOT confine their writes.
324
- // Inside an isolated subagent, filter MCP-source tools (named
325
- // `mcp__<server>__<tool>`) to those whose declared category is
326
- // in the read-only set. Unannotated tools default to
327
- // `integration` which we treat as potentially mutating.
328
- // Opt-out via frontmatter `unsafeAllowMutatingMcp: true`.
329
- if (mcpFilterActive && isMcpToolName(t.name) && !isReadOnlyMcpCategory(t.category)) {
330
- mcpToolsFiltered++;
331
- continue;
332
- }
333
- childTools.register(t);
334
- }
335
- // Resolve model override. `agent.model` MUST resolve through the
336
- // registry; misses fail closed (codex Round-1 #4).
337
- let childSelection;
338
- if (agent?.model) {
339
- if (!deps.registry) {
340
- return {
341
- ok: false,
342
- output: `Agent '${agent.name}' specifies model '${agent.model}' but the task tool was wired without a ModelRegistry; cannot validate.`,
343
- };
344
- }
345
- const resolved = deps.registry.get(agent.model);
346
- if (!resolved) {
347
- return {
348
- ok: false,
349
- output: `Agent '${agent.name}' specifies model '${agent.model}' which is not in the registry. Run 'chances doctor' to see configured providers and known models.`,
350
- };
351
- }
352
- childSelection = new ModelSelection({ model: agent.model });
353
- }
354
- const childTurnsBudget = agent?.maxTurns
355
- ? Math.min(Math.max(1, agent.maxTurns), CHILD_MAX_TURNS_CEILING)
356
- : childMaxTurns;
357
- // (5.5) fork-from-parent resolution. Runs BEFORE any worktree is created
358
- // so a gate refusal returns cleanly with nothing to tear down. All the
359
- // fork-only state (the deep-cloned child session + the model selection
360
- // the child runs under) is computed once here, at execute time, so the
361
- // background path's deferred `run()` closure forks the parent at SPAWN
362
- // time, not at run time (D7).
363
- const inputContext = strArg(args, "context", false);
364
- if (inputContext !== undefined && inputContext !== "fork" && inputContext !== "fresh") {
365
- return {
366
- ok: false,
367
- output: `Invalid 'context' value '${inputContext}'. Expected 'fork' or 'fresh'.`,
368
- };
369
- }
370
- const forkRequested = inputContext === "fork";
371
- // `effectiveSelection` is what the child engine runs under: a persona
372
- // `model` override always wins; a fork with no override inherits the
373
- // parent's CURRENT selection (pinned now); a fresh subagent keeps today's
374
- // behaviour (undefined ⇒ engine default).
375
- let effectiveSelection = childSelection;
376
- let forkedChildSession;
377
- // (D6) The prompt the child actually receives. For a fork it is wrapped
378
- // with the directive (built here so the budget estimate counts it too —
379
- // it depends only on `isolationMode`, not the not-yet-created worktree);
380
- // a fresh subagent passes the prompt as-is.
381
- let childPrompt = prompt;
382
- if (forkRequested) {
383
- if (!deps.parentSession) {
384
- return {
385
- ok: false,
386
- output: "context:'fork' was requested but the task tool was wired without a parent session; fork is unavailable. Use a fresh subagent with a self-contained prompt.",
387
- };
388
- }
389
- // Resolve effective parent + child model descriptors through the router
390
- // (same fallback the engine uses), so the budget + cross-provider gate
391
- // see the model the child will actually run on.
392
- const parentChoice = deps.parentSelection?.get() ?? {};
393
- const parentModel = deps.router.pick({
394
- preferredModel: parentChoice.model,
395
- preferredProvider: parentChoice.provider,
396
- needsTools: true,
397
- }).model;
398
- if (!effectiveSelection) {
399
- // No persona override: inherit the parent's current model, pinned at
400
- // execute time so a later `/model` switch (or background defer) can't
401
- // move the child off the model the user forked on.
402
- effectiveSelection = new ModelSelection({
403
- provider: parentChoice.provider,
404
- model: parentChoice.model,
405
- });
406
- }
407
- const childChoice = effectiveSelection.get();
408
- const childModel = deps.router.pick({
409
- preferredModel: childChoice.model,
410
- preferredProvider: childChoice.provider,
411
- needsTools: true,
412
- }).model;
413
- // (D11) Cross-provider transcript-disclosure gate (fail-closed; not
414
- // bypassable by auto-approve modes since it refuses before the gate).
415
- if (childModel.provider !== parentModel.provider && !deps.allowCrossProviderFork) {
416
- return {
417
- ok: false,
418
- output: `context:'fork' would send the full conversation transcript to provider '${childModel.provider}' (model ${childModel.id}), which differs from this session's provider '${parentModel.provider}'. Set config.agent.allowCrossProviderFork=true to permit cross-provider forks, or drop the persona model override.`,
419
- };
420
- }
421
- // (D5 / R2 MUST-FIX) Model-aware budget. Fork the parent ONCE here (used
422
- // for both the estimate and as the child's seeded session) and build the
423
- // directive now so the estimate covers the SAME shape the child engine
424
- // will send (`engine.ts` runTurnImpl: system + tool schemas + inherited
425
- // messages + the directive-wrapped user turn) — not just the inherited
426
- // history. Under-counting the tool schemas / directive would let a fork
427
- // pass this guard and still overflow the model's window on its first
428
- // request, which is exactly what this preflight exists to prevent.
429
- forkedChildSession = SessionManager.forkFrom(deps.parentSession, "subagent");
430
- childPrompt = buildForkDirective(prompt, isolationMode === "worktree");
431
- const inheritedText = forkedChildSession.messages().map(messageToText).join("\n");
432
- // `childTools` is fully populated here except the per-agent `pty`
433
- // instance (registered in each branch below) — a one-tool undercount
434
- // the window fraction + base reserve absorb. Memory context is the
435
- // other model-visible system addition we can size cheaply; plan/worktree
436
- // reminders + the base prompt are covered by SYSTEM_BASE_RESERVE_TOKENS.
437
- const toolDefsText = childTools
438
- .list()
439
- .map((t) => `${t.name}\n${t.description}\n${JSON.stringify(t.parameters)}`)
440
- .join("\n");
441
- const systemText = deps.memory?.asSystemContext() ?? "";
442
- const estimate = estimateTokens(`${systemText}\n${toolDefsText}\n${inheritedText}\n${childPrompt}`) +
443
- SYSTEM_BASE_RESERVE_TOKENS;
444
- const budget = deps.forkMaxContextTokens ??
445
- (childModel.contextWindow
446
- ? Math.floor(childModel.contextWindow * FORK_WINDOW_FRACTION)
447
- : CONSERVATIVE_FORK_DEFAULT);
448
- if (estimate > budget) {
449
- return {
450
- ok: false,
451
- output: `Fork context (~${estimate} tokens, incl. tools + system) exceeds the budget (${budget} tokens) for model ${childModel.id}. Spawn a fresh subagent with an explicit prompt, /compact the conversation first, or raise config.agent.forkMaxContextTokens.`,
452
- };
453
- }
454
- }
455
- // (5.1) Drain helper: called on every subagent termination
456
- // path (sync return / sync throw / background return). The
457
- // registry only kills sessions still owned by `childAgentId`
458
- // — sessions the parent has `pty.adopt()`-ed are re-bound to
459
- // the parent's agentId and are no-ops here. Failure to drain
460
- // is logged once on the bus but never blocks the caller; a
461
- // 2-second deadline matches `AsyncTaskRegistry.killAll` from
462
- // 3.4 so engine teardown shape is uniform.
463
- const drainChildPty = async (childAgentId) => {
464
- if (!deps.ptySessions)
465
- return;
466
- try {
467
- // (5.1 codex Round-2 SHOULD-FIX #1) Capture survivors and
468
- // surface them on the bus so operators see when a subagent
469
- // PTY ignored TERM-then-KILL past the 2 s deadline. Matches
470
- // the CLI shutdown's `pty dispose` survivor log so the two
471
- // teardown paths report the same shape.
472
- const { survivors } = await deps.ptySessions.drainOwnedBy(childAgentId, 2_000);
473
- if (survivors.length > 0) {
474
- deps.bus.emit({
475
- type: "log",
476
- level: "warn",
477
- message: `subagent ${childAgentId}: ${survivors.length} pty session(s) past 2s drain deadline: ${survivors.join(", ")}`,
478
- });
479
- }
480
- }
481
- catch (e) {
482
- deps.bus.emit({
483
- type: "log",
484
- level: "warn",
485
- message: `pty drainOwnedBy(${childAgentId}) failed: ${e.message ?? e}`,
486
- });
487
- }
488
- };
489
- // 3.4 branch: `run_in_background:true` ONLY when the registry was
490
- // wired. When undefined, the field is silently ignored (codex
491
- // Round-1 SHOULD-FIX #1 — see design "Registry-absent fallback").
520
+ // 3.4 branch: `run_in_background:true` ONLY when the registry was wired.
521
+ // When undefined, the field is silently ignored (codex Round-1 SHOULD #1).
492
522
  const wantBackground = deps.backgroundTasks !== undefined && boolArg(args, "run_in_background") === true;
493
- const agentName = agent?.name ?? "default";
494
- // (4.1) Create the worktree BEFORE spawning the child engine
495
- // synchronously for the sync branch, before launching for the
496
- // background branch. The child engine receives the worktree
497
- // path as `worktreeCwd`. On any failure path (including
498
- // cancellation), `worktreeHandle.cleanup()` is invoked from a
499
- // `finally` block (sync) or the registry's settle handler
500
- // (background). Cleanup is idempotent so double-invocation is
501
- // safe. Round-1 MUST-FIX #3 — `signal` flows into the spawn.
502
- let worktreeHandle;
503
- if (isolationMode === "worktree") {
504
- try {
505
- worktreeHandle = await createAgentWorktree(deps.workspaceRoot, {
506
- signal: ctx.signal,
507
- });
508
- }
509
- catch (e) {
510
- if (e instanceof WorktreeError) {
511
- return {
512
- ok: false,
513
- output: `Isolation worktree could not be created (${e.code}): ${e.message}. Hint: ensure the workspace is a git repository and 'git' is on PATH.`,
514
- };
515
- }
516
- throw e;
517
- }
518
- }
523
+ // (7.8 §3.2) Build the child agent via the SHARED helper (persona / tool
524
+ // filtering / fork gates / worktree / PTY / engine). The one byte-level
525
+ // difference between the two one-shot paths is event-tagging identity:
526
+ // - sync → `"none"` ({agentName}; the parent labels sync
527
+ // children at its own tool:call/result boundary).
528
+ // - background `"deferred"` ({agentId:"", agentName}); we set
529
+ // `.agentId` to the registry task id after `launch()`.
530
+ const built = await buildChildEngine(deps, {
531
+ prompt,
532
+ subagentType: strArg(args, "subagent_type", false),
533
+ isolation: strArg(args, "isolation", false),
534
+ context: strArg(args, "context", false),
535
+ }, childMaxTurns, wantBackground ? "deferred" : "none", ctx.signal);
536
+ if (!built.ok)
537
+ return { ok: false, output: built.output };
538
+ const { engine, agentName, agentContext, firstPrompt, worktreeHandle, mcpToolsFiltered } = built;
519
539
  const worktreeCwd = worktreeHandle?.path;
520
- // (5.5 / D6) `childPrompt` was finalised in the fork block above (directive
521
- // for a fork, raw prompt for a fresh subagent). `isolationMode` — which
522
- // the directive's worktree caveat depends on — is resolved before that
523
- // block, so no recompute is needed here even though the worktree itself is
524
- // created later.
540
+ // (5.1) Drain the child's PTY sessions on every termination path. Bound
541
+ // to the single child PTY agentId minted inside `buildChildEngine`.
542
+ const drainChildPty = makeDrainChildPty(deps, built.childPtyAgentId);
525
543
  if (wantBackground) {
526
544
  // The registry runs `run(signal)` under its own unlinked
527
- // AbortController — parent's `ctx.signal` does NOT bind. The
528
- // child engine's loop honors `signal` via `tokenFromSignal`,
529
- // same path 3.3 sync subagents use.
530
- //
531
- // Closure capture of the task id: `launch()` is synchronous and
532
- // returns the handle before the registry kicks `run()` into
533
- // microtask land. The child engine's `agentContext` is a SHARED
534
- // mutable object — we set `.agentId` after `launch()` returns,
535
- // and because `AgentEngine.emit` re-reads `ctx.agentId` per
536
- // emit, the engine will pick up the registry-issued id by the
537
- // time the child's first data frame fires.
538
- const agentContextObj = {
539
- agentId: "",
540
- agentName,
541
- };
542
- // (5.1) PTY-ownership agentId for the child. Minted here, NOT
543
- // re-using `handle.taskId` (that's pinned to AsyncTaskRegistry
544
- // semantics; PTY ownership is a separate concern). The drain
545
- // path at the end of `run()` calls `drainOwnedBy(ptyAgentId)`,
546
- // matching sessions the model started through this subagent's
547
- // `pty` tool. Adopted sessions have re-bound to the parent's
548
- // agentId and are no-ops there.
549
- const childPtyAgentId = createId("sub");
550
- if (deps.ptySessions && deps.nativeCreatePtySession) {
551
- childTools.register(createPtyTool({
552
- registry: deps.ptySessions,
553
- agentId: childPtyAgentId,
554
- agentName,
555
- workspaceRoot: deps.workspaceRoot,
556
- // (5.1 codex Round-2 MUST-FIX #3) Isolated subagent's
557
- // PTY commands default to the worktree path, not the
558
- // parent's. Without this override, `pty.start({command:
559
- // "pwd"})` from inside an isolated subagent would
560
- // execute against the parent checkout.
561
- defaultCwd: worktreeCwd ?? deps.workspaceRoot,
562
- worktreeCwd,
563
- createHandle: deps.nativeCreatePtySession,
564
- }));
565
- }
545
+ // AbortController — parent's `ctx.signal` does NOT bind. `launch()` is
546
+ // synchronous and returns the handle before the registry kicks `run()`
547
+ // into microtask land; the child engine's `agentContext` is the shared
548
+ // mutable object from `buildChildEngine`, so setting `.agentId` after
549
+ // launch lands before the child's first data frame fires.
566
550
  try {
567
551
  const handle = deps.backgroundTasks.launch({
568
552
  name: agentName,
569
553
  prompt,
570
- // (5.7) Carry the worktree's RELATIVE path + branch (PII-free) so
571
- // the OTel exporter can stamp `chances.gen_ai.worktree.*` on the
572
- // synthesized background span. Absolute path is never carried.
554
+ // (5.7) Carry the worktree's RELATIVE path + branch (PII-free) for
555
+ // the OTel exporter. Relative to the worktree's OWN canonicalRoot
556
+ // (codex R2 Q4) so a caller cwd inside a pre-existing worktree
557
+ // can't leak an absolute-ish `..` path.
573
558
  ...(worktreeHandle
574
559
  ? {
575
560
  worktree: {
576
- // RELATIVE to the worktree's OWN `canonicalRoot` (codex R2
577
- // Q4) — not `deps.workspaceRoot`, which can differ when the
578
- // caller's cwd sits inside a pre-existing user worktree, in
579
- // which case `relative()` could emit `..` and leak an
580
- // absolute-ish path. Against canonicalRoot this is always a
581
- // clean `.chances/worktrees/agent-…`. The exporter guards
582
- // again defensively before stamping.
583
561
  relativePath: relative(worktreeHandle.canonicalRoot, worktreeHandle.path),
584
562
  branch: worktreeHandle.branch,
585
563
  },
@@ -587,55 +565,18 @@ export function createTaskTool(deps) {
587
565
  : {}),
588
566
  run: async (signal) => {
589
567
  const startedAt = Date.now();
590
- const childSession = forkedChildSession ?? SessionManager.create("subagent");
591
- const childEngine = new AgentEngine({
592
- bus: deps.bus,
593
- router: deps.router,
594
- tools: childTools,
595
- gate: deps.gate,
596
- getApprovalMode: deps.getApprovalMode,
597
- // (5.4 D12) inherit the parent's MCP mention resolver.
598
- resolveMcpMentions: deps.resolveMcpMentions,
599
- session: childSession,
600
- memory: deps.memory,
601
- workspaceRoot: deps.workspaceRoot,
602
- maxTurns: childTurnsBudget,
603
- // (7.7 §6) A subagent is non-interactive — a max-turns ceiling
604
- // must FAIL (so the parent's task result surfaces it), not pause
605
- // for a "continue?" no one can answer.
606
- pauseOnMaxTurns: false,
607
- suppressTerminalErrors: true,
608
- // 3.4: background child suppresses lifecycle events
609
- // (turn:start/turn:end/error) so the TUI's `busy` flag
610
- // doesn't flip when the background advances a turn.
611
- suppressLifecycleEvents: true,
612
- systemBaseOverride: agent?.systemPrompt,
613
- selection: effectiveSelection,
614
- // 3.4: stamp every data-frame event the child emits
615
- // with the registry-issued task id + agent name so
616
- // subscribers can demultiplex parent vs. child output.
617
- agentContext: agentContextObj,
618
- // (4.1) Worktree isolation — child engine flips its
619
- // `ToolContext.workspaceRoot`/`cwd` to the worktree
620
- // path through AsyncLocalStorage.
621
- worktreeCwd,
622
- });
623
568
  const childToken = tokenFromSignal(signal);
624
569
  try {
625
- const result = await childEngine.runTurn(childPrompt, childToken, { expandMentions: false });
626
- // (Round-2 SHOULD-FIX #2) Re-check cancellation BEFORE
627
- // finalize. If a `kill` arrived after the model finished
628
- // its last tool call but before this point, the registry
629
- // will record the task as cancelled the worktree
630
- // should be cleaned up, not retained as success.
570
+ const result = await engine.runTurn(firstPrompt, childToken, {
571
+ expandMentions: false,
572
+ });
573
+ // (Round-2 SHOULD-FIX #2) Re-check cancellation BEFORE finalize:
574
+ // a `kill` after the last tool call but before here should clean
575
+ // up the worktree, not retain it as success.
631
576
  if (signal.aborted) {
632
577
  if (worktreeHandle)
633
578
  await worktreeHandle.cleanup();
634
- return {
635
- ok: false,
636
- text: "Subagent cancelled",
637
- durationMs: Date.now() - startedAt,
638
- };
579
+ return { ok: false, text: "Subagent cancelled", durationMs: Date.now() - startedAt };
639
580
  }
640
581
  const finalText = result.text.trim() || "(subagent finished without producing any text)";
641
582
  const wrapped = await finalizeWorktree(worktreeHandle, deps.workspaceRoot, finalText, mcpToolsFiltered);
@@ -643,22 +584,14 @@ export function createTaskTool(deps) {
643
584
  ok: true,
644
585
  text: wrapped,
645
586
  durationMs: Date.now() - startedAt,
646
- tokens: {
647
- input: result.inputTokens,
648
- output: result.outputTokens,
649
- },
587
+ tokens: { input: result.inputTokens, output: result.outputTokens },
650
588
  };
651
589
  }
652
590
  catch (e) {
653
- // Failure / cancel: tear down the worktree (idempotent).
654
591
  if (worktreeHandle)
655
592
  await worktreeHandle.cleanup();
656
593
  if (e instanceof AppError && e.code === ErrorCode.Cancelled) {
657
- return {
658
- ok: false,
659
- text: "Subagent cancelled",
660
- durationMs: Date.now() - startedAt,
661
- };
594
+ return { ok: false, text: "Subagent cancelled", durationMs: Date.now() - startedAt };
662
595
  }
663
596
  const msg = e instanceof AppError
664
597
  ? `Subagent failed (${e.code}): ${e.message}`
@@ -666,20 +599,17 @@ export function createTaskTool(deps) {
666
599
  return { ok: false, text: msg, durationMs: Date.now() - startedAt };
667
600
  }
668
601
  finally {
669
- // (5.1) Drain every still-running PTY session owned by
670
- // the child. Adopted sessions re-bound to the parent
671
- // are no-ops here. Fires before the registry surfaces
672
- // the completion notification so the model never sees
673
- // a `<task-notification>` for a subagent that still
674
- // holds live shells in the background.
675
- await drainChildPty(childPtyAgentId);
602
+ // Drain before the registry surfaces the completion
603
+ // notification, so the model never sees a `<task-notification>`
604
+ // for a subagent that still holds live shells.
605
+ await drainChildPty();
676
606
  }
677
607
  },
678
608
  });
679
- agentContextObj.agentId = handle.taskId;
609
+ agentContext.agentId = handle.taskId;
680
610
  const body = [
681
611
  `(background-launched) task_id=${handle.taskId} name=${agentName}`,
682
- ...(isolationMode === "worktree" && worktreeHandle
612
+ ...(worktreeCwd && worktreeHandle
683
613
  ? [`isolation=worktree path=${relative(deps.workspaceRoot, worktreeHandle.path) || worktreeHandle.path} branch=${worktreeHandle.branch}`]
684
614
  : []),
685
615
  "",
@@ -688,72 +618,19 @@ export function createTaskTool(deps) {
688
618
  return { ok: true, output: body };
689
619
  }
690
620
  catch (e) {
691
- // launch() failed — if we already created a worktree, tear it
692
- // down so we don't leak it into the next stale-GC pass.
621
+ // launch() failed — tear down any worktree so it doesn't leak.
693
622
  if (worktreeHandle)
694
623
  await worktreeHandle.cleanup();
695
- // Capacity refusal or registry-disposed surfaces as a labeled
696
- // AppError(Tool); fold it into ok:false so the model sees the
697
- // recovery message.
698
624
  if (e instanceof AppError && e.code === ErrorCode.Tool) {
699
625
  return { ok: false, output: e.message };
700
626
  }
701
627
  throw e;
702
628
  }
703
629
  }
704
- // Synchronous path. (3.6 codex Round-1 MUST-FIX #3) The 3.4
705
- // background path correctly suppresses lifecycle frames and tags
706
- // data frames with the agent identity; the sync path was missed
707
- // — child `turn:start`/`turn:end`/`usage:turn` flowed onto the
708
- // shared bus untagged, which (a) would flip TUI `busy` state
709
- // for any subscriber that toggles on the bare event, and (b)
710
- // would corrupt the 3.6 OTel exporter's `Map<turnId, Span>` by
711
- // opening a second `invoke_agent` span inside the parent turn.
712
- // Same shape as background: suppress lifecycle, tag data frames
713
- // with `{agentName}` (no agentId — sync subagents complete before
714
- // the parent's `tool:call`/`tool:result` boundary closes, so the
715
- // parent already labels them at that level).
716
- const syncChildPtyAgentId = createId("sub");
717
- if (deps.ptySessions && deps.nativeCreatePtySession) {
718
- childTools.register(createPtyTool({
719
- registry: deps.ptySessions,
720
- agentId: syncChildPtyAgentId,
721
- agentName,
722
- workspaceRoot: deps.workspaceRoot,
723
- // (5.1 codex Round-2 MUST-FIX #3) Same worktree-cwd default
724
- // override as the background branch above.
725
- defaultCwd: worktreeCwd ?? deps.workspaceRoot,
726
- worktreeCwd,
727
- createHandle: deps.nativeCreatePtySession,
728
- }));
729
- }
730
- const childSession = forkedChildSession ?? SessionManager.create("subagent");
731
- const child = new AgentEngine({
732
- bus: deps.bus,
733
- router: deps.router,
734
- tools: childTools,
735
- gate: deps.gate,
736
- getApprovalMode: deps.getApprovalMode,
737
- // (5.4 D12) inherit the parent's MCP mention resolver.
738
- resolveMcpMentions: deps.resolveMcpMentions,
739
- session: childSession,
740
- memory: deps.memory,
741
- workspaceRoot: deps.workspaceRoot,
742
- maxTurns: childTurnsBudget,
743
- // (7.7 §6) Non-interactive subagent → fail on the ceiling, never pause.
744
- pauseOnMaxTurns: false,
745
- suppressTerminalErrors: true,
746
- suppressLifecycleEvents: true,
747
- agentContext: { agentName },
748
- systemBaseOverride: agent?.systemPrompt,
749
- selection: effectiveSelection,
750
- // (4.1) Worktree isolation — same wiring as the background
751
- // branch above.
752
- worktreeCwd,
753
- });
630
+ // Synchronous path.
754
631
  const childToken = tokenFromSignal(ctx.signal);
755
632
  try {
756
- const result = await child.runTurn(childPrompt, childToken, { expandMentions: false });
633
+ const result = await engine.runTurn(firstPrompt, childToken, { expandMentions: false });
757
634
  const body = result.text.trim();
758
635
  if (!body) {
759
636
  if (worktreeHandle)
@@ -774,12 +651,7 @@ export function createTaskTool(deps) {
774
651
  return { ok: false, output: `Subagent failed: ${e.message || String(e)}` };
775
652
  }
776
653
  finally {
777
- // (5.1) Drain every PTY session owned by the sync subagent.
778
- // Same drain shape as the background branch — adopted
779
- // sessions re-bound to the parent are no-ops here. Fires
780
- // before this `execute` returns so the parent's next turn
781
- // never sees `pty.list()` results for a defunct sync child.
782
- await drainChildPty(syncChildPtyAgentId);
654
+ await drainChildPty();
783
655
  }
784
656
  },
785
657
  };
@@ -888,6 +760,90 @@ function appendMcpFilterNotice(body, n) {
888
760
  return body;
889
761
  return `${body}\n\n[notice: ${n} mutating MCP tool(s) were hidden from this subagent due to active isolation]`;
890
762
  }
763
+ /** (5.1 / 7.8 §3.2) PTY drain closure bound to a child's PTY agentId. Extracted
764
+ * from the prior inline `drainChildPty` closure so the one-shot `task` paths AND
765
+ * the persistent worker's `drainPty` share it. Kills sessions still owned by
766
+ * `childAgentId` (adopted-to-parent sessions are no-ops); failure is logged once
767
+ * on the bus, never blocks the caller; 2s deadline matches `killAll`. */
768
+ function makeDrainChildPty(deps, childAgentId) {
769
+ return async () => {
770
+ if (!deps.ptySessions)
771
+ return;
772
+ try {
773
+ const { survivors } = await deps.ptySessions.drainOwnedBy(childAgentId, 2_000);
774
+ if (survivors.length > 0) {
775
+ deps.bus.emit({
776
+ type: "log",
777
+ level: "warn",
778
+ message: `subagent ${childAgentId}: ${survivors.length} pty session(s) past 2s drain deadline: ${survivors.join(", ")}`,
779
+ });
780
+ }
781
+ }
782
+ catch (e) {
783
+ deps.bus.emit({
784
+ type: "log",
785
+ level: "warn",
786
+ message: `pty drainOwnedBy(${childAgentId}) failed: ${e.message ?? e}`,
787
+ });
788
+ }
789
+ };
790
+ }
791
+ /** (7.8 §3.2) The worktree-retention decision for a PERSISTENT worker — same
792
+ * three-probe policy as {@link finalizeWorktree} (diff/status/unmerged-commits,
793
+ * fail-closed on probe error), but returns just the retention BANNER (or the
794
+ * mcp-filter notice) to surface, with NO body to wrap (the worker delivered its
795
+ * results per-message already). `undefined` ⇒ nothing kept, nothing to say. */
796
+ async function finalizeWorktreeBanner(handle, parentWorkspace, mcpToolsFiltered) {
797
+ const mcpNotice = mcpToolsFiltered > 0
798
+ ? `[notice: ${mcpToolsFiltered} mutating MCP tool(s) were hidden from this worker due to active isolation]`
799
+ : undefined;
800
+ if (!handle)
801
+ return mcpNotice;
802
+ let shortstat = "";
803
+ let statusEmpty = true;
804
+ let unmergedCommits = 0;
805
+ let probeFailed = false;
806
+ try {
807
+ shortstat = await gitDiffShortstat(handle.path);
808
+ }
809
+ catch {
810
+ probeFailed = true;
811
+ }
812
+ try {
813
+ statusEmpty = await gitStatusEmpty(handle.path);
814
+ }
815
+ catch {
816
+ probeFailed = true;
817
+ }
818
+ try {
819
+ unmergedCommits = await gitUnmergedCommitCount(handle.canonicalRoot, handle.branch);
820
+ }
821
+ catch {
822
+ probeFailed = true;
823
+ }
824
+ const hasChanges = shortstat.length > 0 || !statusEmpty || unmergedCommits > 0;
825
+ if (!hasChanges && !probeFailed) {
826
+ await handle.cleanup();
827
+ return mcpNotice;
828
+ }
829
+ const relPath = relative(parentWorkspace, handle.path) || handle.path;
830
+ const reasons = [];
831
+ if (shortstat.length > 0)
832
+ reasons.push(shortstat);
833
+ if (!statusEmpty)
834
+ reasons.push("untracked or staged files present");
835
+ if (unmergedCommits > 0)
836
+ reasons.push(`${unmergedCommits} commit(s) on ${handle.branch} not on HEAD`);
837
+ if (probeFailed && reasons.length === 0)
838
+ reasons.push("probe error during retention check — worktree kept defensively");
839
+ return [
840
+ `[worktree retained: ${relPath} on branch ${handle.branch}]`,
841
+ `[diff: ${reasons.join("; ")}]`,
842
+ mcpNotice,
843
+ ]
844
+ .filter(Boolean)
845
+ .join("\n");
846
+ }
891
847
  /** Boolean arg reader. Returns true only when the value is literal `true`;
892
848
  * everything else (false, missing, wrong type) returns false. We avoid
893
849
  * coercion (truthy strings etc.) because schema-level safety matters here. */