@xrkseek/harness 0.5.9 → 0.5.10

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.
Files changed (92) hide show
  1. package/node_modules/@xrkseek/core-agent-loop/dist/index.d.ts.map +1 -1
  2. package/node_modules/@xrkseek/core-agent-loop/dist/index.js +4 -1
  3. package/node_modules/@xrkseek/core-agent-loop/dist/index.js.map +1 -1
  4. package/node_modules/@xrkseek/core-agent-loop/dist/llm-retry.d.ts +0 -2
  5. package/node_modules/@xrkseek/core-agent-loop/dist/llm-retry.d.ts.map +1 -1
  6. package/node_modules/@xrkseek/core-agent-loop/dist/llm-retry.js +7 -3
  7. package/node_modules/@xrkseek/core-agent-loop/dist/llm-retry.js.map +1 -1
  8. package/node_modules/@xrkseek/core-agent-loop/dist/settle-batch.d.ts +5 -0
  9. package/node_modules/@xrkseek/core-agent-loop/dist/settle-batch.d.ts.map +1 -1
  10. package/node_modules/@xrkseek/core-agent-loop/dist/settle-batch.js +1 -0
  11. package/node_modules/@xrkseek/core-agent-loop/dist/settle-batch.js.map +1 -1
  12. package/node_modules/@xrkseek/core-tools/dist/subagent-prompt.d.ts.map +1 -1
  13. package/node_modules/@xrkseek/core-tools/dist/subagent-prompt.js +9 -3
  14. package/node_modules/@xrkseek/core-tools/dist/subagent-prompt.js.map +1 -1
  15. package/node_modules/@xrkseek/exec-fs/dist/index.d.ts.map +1 -1
  16. package/node_modules/@xrkseek/exec-fs/dist/index.js +3 -1
  17. package/node_modules/@xrkseek/exec-fs/dist/index.js.map +1 -1
  18. package/node_modules/@xrkseek/exec-web/dist/tools.d.ts +1 -1
  19. package/node_modules/@xrkseek/exec-web/dist/tools.d.ts.map +1 -1
  20. package/node_modules/@xrkseek/exec-web/dist/tools.js +10 -0
  21. package/node_modules/@xrkseek/exec-web/dist/tools.js.map +1 -1
  22. package/node_modules/@xrkseek/llm/dist/index.d.ts.map +1 -1
  23. package/node_modules/@xrkseek/llm/dist/index.js +8 -2
  24. package/node_modules/@xrkseek/llm/dist/index.js.map +1 -1
  25. package/node_modules/@xrkseek/preset-harness/dist/tsconfig.tsbuildinfo +1 -1
  26. package/node_modules/@xrkseek/protocol/dist/session-events.d.ts +5 -0
  27. package/node_modules/@xrkseek/protocol/dist/session-events.d.ts.map +1 -1
  28. package/node_modules/@xrkseek/protocol/dist/session-events.js.map +1 -1
  29. package/node_modules/@xrkseek/protocol/dist/validate.d.ts.map +1 -1
  30. package/node_modules/@xrkseek/protocol/dist/validate.js +5 -0
  31. package/node_modules/@xrkseek/protocol/dist/validate.js.map +1 -1
  32. package/node_modules/@xrkseek/server-face/dist/adapt/wire-event.d.ts.map +1 -1
  33. package/node_modules/@xrkseek/server-face/dist/adapt/wire-event.js +29 -2
  34. package/node_modules/@xrkseek/server-face/dist/adapt/wire-event.js.map +1 -1
  35. package/node_modules/@xrkseek/server-face/dist/agent-team-roles.d.ts +40 -0
  36. package/node_modules/@xrkseek/server-face/dist/agent-team-roles.d.ts.map +1 -1
  37. package/node_modules/@xrkseek/server-face/dist/agent-team-roles.js +89 -1
  38. package/node_modules/@xrkseek/server-face/dist/agent-team-roles.js.map +1 -1
  39. package/node_modules/@xrkseek/server-face/dist/agent-team-tasks.d.ts +5 -0
  40. package/node_modules/@xrkseek/server-face/dist/agent-team-tasks.d.ts.map +1 -1
  41. package/node_modules/@xrkseek/server-face/dist/agent-team-tasks.js +24 -0
  42. package/node_modules/@xrkseek/server-face/dist/agent-team-tasks.js.map +1 -1
  43. package/node_modules/@xrkseek/server-face/dist/context.d.ts +17 -4
  44. package/node_modules/@xrkseek/server-face/dist/context.d.ts.map +1 -1
  45. package/node_modules/@xrkseek/server-face/dist/handlers/job.d.ts.map +1 -1
  46. package/node_modules/@xrkseek/server-face/dist/handlers/job.js +1 -0
  47. package/node_modules/@xrkseek/server-face/dist/handlers/job.js.map +1 -1
  48. package/node_modules/@xrkseek/server-face/dist/handlers/session.d.ts.map +1 -1
  49. package/node_modules/@xrkseek/server-face/dist/handlers/session.js +46 -16
  50. package/node_modules/@xrkseek/server-face/dist/handlers/session.js.map +1 -1
  51. package/node_modules/@xrkseek/server-face/dist/handlers/subagent.d.ts.map +1 -1
  52. package/node_modules/@xrkseek/server-face/dist/handlers/subagent.js +15 -0
  53. package/node_modules/@xrkseek/server-face/dist/handlers/subagent.js.map +1 -1
  54. package/node_modules/@xrkseek/server-face/dist/index.d.ts +2 -2
  55. package/node_modules/@xrkseek/server-face/dist/index.d.ts.map +1 -1
  56. package/node_modules/@xrkseek/server-face/dist/index.js +1 -1
  57. package/node_modules/@xrkseek/server-face/dist/index.js.map +1 -1
  58. package/node_modules/@xrkseek/server-face/dist/model-catalog.d.ts +6 -0
  59. package/node_modules/@xrkseek/server-face/dist/model-catalog.d.ts.map +1 -1
  60. package/node_modules/@xrkseek/server-face/dist/model-catalog.js +19 -0
  61. package/node_modules/@xrkseek/server-face/dist/model-catalog.js.map +1 -1
  62. package/node_modules/@xrkseek/server-face/dist/ralph-tool.d.ts.map +1 -1
  63. package/node_modules/@xrkseek/server-face/dist/ralph-tool.js +15 -12
  64. package/node_modules/@xrkseek/server-face/dist/ralph-tool.js.map +1 -1
  65. package/node_modules/@xrkseek/server-face/dist/runtime.d.ts.map +1 -1
  66. package/node_modules/@xrkseek/server-face/dist/runtime.js +77 -14
  67. package/node_modules/@xrkseek/server-face/dist/runtime.js.map +1 -1
  68. package/node_modules/@xrkseek/server-face/dist/session-cwd.d.ts +12 -0
  69. package/node_modules/@xrkseek/server-face/dist/session-cwd.d.ts.map +1 -1
  70. package/node_modules/@xrkseek/server-face/dist/session-cwd.js +14 -0
  71. package/node_modules/@xrkseek/server-face/dist/session-cwd.js.map +1 -1
  72. package/node_modules/@xrkseek/server-face/dist/session-status.d.ts +3 -1
  73. package/node_modules/@xrkseek/server-face/dist/session-status.d.ts.map +1 -1
  74. package/node_modules/@xrkseek/server-face/dist/session-status.js +28 -4
  75. package/node_modules/@xrkseek/server-face/dist/session-status.js.map +1 -1
  76. package/node_modules/@xrkseek/server-face/dist/settings-agent-tools.d.ts.map +1 -1
  77. package/node_modules/@xrkseek/server-face/dist/settings-agent-tools.js +13 -1
  78. package/node_modules/@xrkseek/server-face/dist/settings-agent-tools.js.map +1 -1
  79. package/node_modules/@xrkseek/server-face/dist/settings-schemas.d.ts.map +1 -1
  80. package/node_modules/@xrkseek/server-face/dist/settings-schemas.js +12 -0
  81. package/node_modules/@xrkseek/server-face/dist/settings-schemas.js.map +1 -1
  82. package/node_modules/@xrkseek/server-face/dist/subagent-registry.d.ts +27 -0
  83. package/node_modules/@xrkseek/server-face/dist/subagent-registry.d.ts.map +1 -1
  84. package/node_modules/@xrkseek/server-face/dist/subagent-registry.js +69 -0
  85. package/node_modules/@xrkseek/server-face/dist/subagent-registry.js.map +1 -1
  86. package/node_modules/@xrkseek/server-face/dist/subagent-tools.d.ts.map +1 -1
  87. package/node_modules/@xrkseek/server-face/dist/subagent-tools.js +281 -55
  88. package/node_modules/@xrkseek/server-face/dist/subagent-tools.js.map +1 -1
  89. package/node_modules/@xrkseek/server-host/dist/index.d.ts.map +1 -1
  90. package/node_modules/@xrkseek/server-host/dist/index.js +4 -14
  91. package/node_modules/@xrkseek/server-host/dist/index.js.map +1 -1
  92. package/package.json +1 -1
@@ -4,15 +4,16 @@ import { dispatchFaceMethod } from "./dispatch.js";
4
4
  import { lastAssistantBodyText } from "./adapt/subagent-notice.js";
5
5
  import { boundChildAnswer, SUBAGENT_ANSWER_INLINE_BYTES, } from "./adapt/subagent-answer-bound.js";
6
6
  export { boundChildAnswer, SUBAGENT_ANSWER_INLINE_BYTES };
7
+ import { subagentOwnedEvents } from "./subagent-registry.js";
7
8
  import { DEFAULT_MAX_ACTIVE_CHILDREN, DEFAULT_MAX_DEPTH, resolveAgentPresetProfile, } from "./presets-catalog.js";
8
9
  import { effectiveSessionAgentPreset } from "./session-agent-preset.js";
9
10
  import { resolveSessionCwd } from "./session-cwd.js";
10
11
  import { ExternalAgentError, parseExternalAgentKind, parseExternalAgentProduct, runExternalAgentTurn, supportsExternalContinuable, startExternalContinuable, isChildSessionActive, } from "./external-agent-runtime.js";
11
12
  import { createSubagentWorktree, finalizeSubagentWorktree, formatWorktreeResult, worktreeContextNote, worktreeSkippedForRemote, } from "./subagent-worktree.js";
12
- import { resolveSessionModelSelection } from "./model-catalog.js";
13
+ import { resolveSessionModelSelection, resolveSubagentModelSetting, } from "./model-catalog.js";
13
14
  import { selectSessionModel } from "./select-session-model.js";
14
15
  import { appendOutputContract, buildOutputSchemaRetryMessage, coerceOutputSchema, validateOutputAgainstSchema, } from "./agent-team-output.js";
15
- import { applySpawnRoleReminder, parseAgentTeamSpawnRole, } from "./agent-team-roles.js";
16
+ import { applySubagentSpawnPreamble, parseAgentTeamSpawnRole, rootUserAuthorizationBlock, } from "./agent-team-roles.js";
16
17
  import { isAgentTeamRole, } from "./agent-team-graph.js";
17
18
  import { bindRalphTool } from "./ralph-tool.js";
18
19
  const FOREGROUND_WAIT_MS = 10 * 60 * 1000;
@@ -106,7 +107,13 @@ async function waitDrainIdle(runtime, sessionId, signal, timeoutMs = FOREGROUND_
106
107
  // for its child would otherwise hang the tool batch forever. The join
107
108
  // stays attached in the background — the caller cancels the child right
108
109
  // after this rejects, which is what actually drains it.
109
- await abortable(run(sessionId), signal);
110
+ //
111
+ // The race must also carry the timeout: latch.run() joins until the
112
+ // child's chain *actually* settles, so a child stuck on a tool that
113
+ // ignores abort pins the parent's turn for as long as it hangs. The
114
+ // timeout used to apply only to the polling fallback below — i.e. never
115
+ // on the Host path, which always has run().
116
+ await raceDeadline(abortable(run(sessionId), signal), timeoutMs, sessionId);
110
117
  return;
111
118
  }
112
119
  const deadline = Date.now() + timeoutMs;
@@ -120,6 +127,26 @@ async function waitDrainIdle(runtime, sessionId, signal, timeoutMs = FOREGROUND_
120
127
  await new Promise((r) => setTimeout(r, POLL_MS));
121
128
  }
122
129
  }
130
+ /**
131
+ * Reject once `timeoutMs` elapses, naming the session so the failure reads
132
+ * like the polling branch's. The wrapped promise keeps running — the caller
133
+ * cancels the child right after, which is what actually drains it.
134
+ */
135
+ function raceDeadline(promise, timeoutMs, sessionId) {
136
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
137
+ return promise;
138
+ let timer;
139
+ const timedOut = new Promise((_, reject) => {
140
+ timer = setTimeout(() => reject(new Error(`subagent timed out waiting for session ${sessionId}`)), timeoutMs);
141
+ });
142
+ return Promise.race([
143
+ promise.finally(() => {
144
+ if (timer !== undefined)
145
+ clearTimeout(timer);
146
+ }),
147
+ timedOut,
148
+ ]);
149
+ }
123
150
  /** Reject as soon as `signal` aborts; the wrapped promise keeps running. */
124
151
  function abortable(promise, signal) {
125
152
  if (!signal)
@@ -193,29 +220,88 @@ function countActiveChildren(runtime, parentSessionId) {
193
220
  }
194
221
  return n;
195
222
  }
223
+ /**
224
+ * Undo a half-made spawn. `session.create` / `session.fork` already made the
225
+ * child durable and registered its link, so any failure between there and a
226
+ * successful `session.prompt` would otherwise leave an empty session in the
227
+ * sidebar and a permanent entry in the catalog / quota counts.
228
+ */
229
+ async function discardUnstartedChild(runtime, childSessionId) {
230
+ runtime.subagents.detach(childSessionId);
231
+ runtime.agentTeams.removeNode(childSessionId);
232
+ runtime.agentTeamTasks.forgetByChild(childSessionId);
233
+ runtime.sessionCwds.delete(childSessionId);
234
+ try {
235
+ await dispatchFaceMethod(runtime, "session.cancel", `tool-sa-discard-${childSessionId}`, { sessionId: childSessionId, cascade: true });
236
+ }
237
+ catch {
238
+ /* best effort: the log is dropped either way */
239
+ }
240
+ try {
241
+ runtime.store.delete?.(childSessionId);
242
+ }
243
+ catch {
244
+ /* store without delete / already gone */
245
+ }
246
+ await Promise.resolve(runtime.invalidateAgent?.(childSessionId)).catch(() => undefined);
247
+ }
248
+ /**
249
+ * Reject a caller-supplied `task_id` another child already owns. Checked
250
+ * *before* the child exists: rebinding the card would misdirect that child's
251
+ * later idle notice (`completeByChild` keys on childSessionId).
252
+ */
253
+ function taskIdConflict(runtime, taskIdHint) {
254
+ if (!taskIdHint)
255
+ return undefined;
256
+ const boundChild = runtime.agentTeamTasks.get(taskIdHint)?.childSessionId;
257
+ return boundChild
258
+ ? `subagent: task_id ${taskIdHint} is already bound to child ${boundChild}; use a different task_id`
259
+ : undefined;
260
+ }
196
261
  /**
197
262
  * Optional LLM target for a freshly created child.
198
- * `model` alone inherits the parent's current provider; `provider` alone is rejected.
263
+ *
264
+ * Precedence: the call's own args → the fleet-wide `agent-loop.subagentModel`
265
+ * setting → the parent's own route. `model` alone inherits the parent's current
266
+ * provider; `provider` alone is rejected.
199
267
  */
200
268
  async function applySubagentModelOverride(runtime, args) {
201
269
  const providerRaw = args.provider?.trim() ?? "";
202
270
  const modelRaw = args.model?.trim() ?? "";
203
271
  const effortRaw = args.reasoningEffort?.trim() ?? "";
204
- if (!providerRaw && !modelRaw && !effortRaw)
272
+ // Refuse loudly rather than silently: an explicit model on an external
273
+ // child would otherwise read as honored when the subprocess ignores it.
274
+ if (args.skipExternal && (providerRaw || modelRaw || effortRaw)) {
275
+ return {
276
+ ok: false,
277
+ message: "subagent: model / provider / reasoning_effort do not apply to an external runtime " +
278
+ "(acp / app-server / claude-code) — the child CLI picks its own model. " +
279
+ "Drop the argument, or use runtime=in-process.",
280
+ };
281
+ }
282
+ // No per-call pin: the setting owns the route, and an empty setting leaves
283
+ // the child on the parent's selection that `session.create` copied.
284
+ const fleet = providerRaw || modelRaw || effortRaw
285
+ ? undefined
286
+ : resolveSubagentModelSetting(runtime);
287
+ if (!providerRaw && !modelRaw && !effortRaw && !fleet)
205
288
  return { ok: true };
206
- if (!modelRaw) {
289
+ const model = modelRaw || fleet?.model || "";
290
+ const effort = effortRaw || fleet?.reasoningEffort || "";
291
+ if (!model) {
207
292
  return {
208
293
  ok: false,
209
294
  message: "subagent: model is required when overriding provider or reasoning_effort",
210
295
  };
211
296
  }
212
297
  const provider = providerRaw
298
+ || fleet?.provider
213
299
  || resolveSessionModelSelection(runtime, args.parentSessionId).provider;
214
300
  const selected = await selectSessionModel(runtime, {
215
301
  sessionId: args.childSessionId,
216
302
  provider,
217
- model: modelRaw,
218
- ...(effortRaw ? { reasoningEffort: effortRaw } : {}),
303
+ model,
304
+ ...(effort ? { reasoningEffort: effort } : {}),
219
305
  });
220
306
  if (!selected.ok) {
221
307
  return {
@@ -226,17 +312,31 @@ async function applySubagentModelOverride(runtime, args) {
226
312
  return { ok: true };
227
313
  }
228
314
  function createSubagentTool(options) {
229
- const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
230
- const maxActiveChildren = options.maxActiveChildren ?? DEFAULT_MAX_ACTIVE_CHILDREN;
315
+ /**
316
+ * Ceilings for one call. Resolved live (not frozen at bind time) so the
317
+ * numbers `analytics` / `list_agents` report are the numbers spawn
318
+ * enforces — a Settings change takes effect on the next call instead of
319
+ * at the next agent rebuild. The bind options are a test-only override.
320
+ */
321
+ const caps = () => {
322
+ const quota = resolveSubagentQuota(options.runtime, options.parentSessionId);
323
+ return {
324
+ maxDepth: options.maxDepth ?? quota.maxDepth,
325
+ maxActive: options.maxActiveChildren ?? quota.maxActive,
326
+ active: quota.active,
327
+ };
328
+ };
231
329
  return {
232
330
  name: "subagent",
233
- description: "Delegate a self-contained task to a teammate subagent (separate session/context). " +
331
+ description: "Delegate a self-contained task to a teammate subagent (separate session, same workspace as this session unless worktree:true). " +
234
332
  "Use for focused independent work — research, a scoped implementation, analysis, or read-only review — " +
235
333
  "so it does not consume this conversation's context. " +
236
- "By default the child cannot see this chat — give a complete standalone prompt. " +
334
+ "By default the child cannot see this chat — give a complete standalone prompt (paths, goals, constraints, persona). " +
335
+ "Do not tell the child to read AGENTS.md to discover who it is; Face prepends parent/child session ids, mode, role, and cwd. " +
237
336
  "Set inherit_context true to seed the child with this session's completed turns only " +
238
337
  "(the current in-flight turn is excluded). " +
239
- "By default waits for the result; set run_in_background true to get a durable child id and continue later via send_message. " +
338
+ "By default waits for the result (one-shot, no later human messages on that child). " +
339
+ "Set run_in_background true for a continuable child (chat companion / long task) and continue via followup_task / send_message. " +
240
340
  "Optional role (worker|researcher|reviewer|lead) prefixes a role reminder; " +
241
341
  "optional output_schema appends an OUTPUT CONTRACT and validates the final JSON; " +
242
342
  "optional task_id / task_name register the work on the Agent Teams task board. " +
@@ -256,7 +356,10 @@ function createSubagentTool(options) {
256
356
  },
257
357
  prompt: {
258
358
  type: "string",
259
- description: "Task for the subagent. When inherit_context is false (default), include paths, goals, and constraints — it does not see this conversation. When inherit_context is true, build on the seeded completed turns.",
359
+ description: "Task for the subagent. Put the actual job here (paths, goals, constraints, any persona). " +
360
+ "When inherit_context is false (default), the child does not see this conversation. " +
361
+ "When inherit_context is true, build on the seeded completed turns. " +
362
+ "Face already injects parent/child session ids — do not send the child to AGENTS.md to learn its role.",
260
363
  },
261
364
  inherit_context: {
262
365
  type: "boolean",
@@ -361,20 +464,35 @@ function createSubagentTool(options) {
361
464
  }
362
465
  if (runtimeKind !== "in-process") {
363
466
  const background = a.run_in_background === true;
467
+ // One cwd for every external branch. `resolveSessionCwd` already
468
+ // falls back to the workspace root, so the child runs where the
469
+ // parent runs instead of always at the root (which silently broke
470
+ // any session that had moved into another workspace).
471
+ const externalCwd = resolveSessionCwd(options.runtime, options.parentSessionId);
364
472
  if (background && !supportsExternalContinuable(runtimeKind)) {
365
473
  return {
366
474
  content: "subagent: run_in_background is not supported for claude-code (print); use acp or app-server",
367
475
  isError: true,
368
476
  };
369
477
  }
478
+ if (a.worktree === true) {
479
+ // Worktree handling lives in the in-process branch below; silently
480
+ // dropping the flag would have the child edit the parent's own
481
+ // checkout while the caller believed it was isolated.
482
+ return {
483
+ content: "subagent: worktree is only supported for runtime=in-process " +
484
+ "(an external runtime owns its own working directory).",
485
+ isError: true,
486
+ };
487
+ }
370
488
  const depth = subagentDepth(options.runtime, options.parentSessionId);
489
+ const { maxDepth, maxActive: maxActiveChildren, active } = caps();
371
490
  if (depth >= maxDepth) {
372
491
  return {
373
492
  content: `subagent: max depth ${maxDepth} reached (current depth ${depth})`,
374
493
  isError: true,
375
494
  };
376
495
  }
377
- const active = countActiveChildren(options.runtime, options.parentSessionId);
378
496
  if (active >= maxActiveChildren) {
379
497
  return {
380
498
  content: `subagent: max active children ${maxActiveChildren} reached (active ${active})`,
@@ -407,11 +525,22 @@ function createSubagentTool(options) {
407
525
  ...(typeof a.reasoning_effort === "string"
408
526
  ? { reasoningEffort: a.reasoning_effort }
409
527
  : {}),
528
+ // An ACP / app-server / claude-code child runs on ITS OWN CLI
529
+ // config; a Face-side pin cannot reach it. Recording one anyway
530
+ // made `/status` and `list_agents` report a route the subprocess
531
+ // never used, so the record is skipped rather than faked.
532
+ skipExternal: true,
410
533
  });
411
534
  if (!modelPin.ok) {
535
+ await discardUnstartedChild(options.runtime, childId);
412
536
  return { content: modelPin.message, isError: true };
413
537
  }
414
538
  const taskTitle = String(a.task_name ?? "").trim() || label;
539
+ const taskIdConflictMsg = taskIdConflict(options.runtime, String(a.task_id ?? "").trim() || undefined);
540
+ if (taskIdConflictMsg) {
541
+ await discardUnstartedChild(options.runtime, childId);
542
+ return { content: taskIdConflictMsg, isError: true };
543
+ }
415
544
  const task = options.runtime.agentTeamTasks.open({
416
545
  parentSessionId: options.parentSessionId,
417
546
  title: taskTitle,
@@ -430,7 +559,7 @@ function createSubagentTool(options) {
430
559
  runtime: options.runtime,
431
560
  faceSessionId: childId,
432
561
  kind: runtimeKind,
433
- cwd: resolveSessionCwd(options.runtime, options.parentSessionId) ?? options.runtime.workspaceRoot,
562
+ cwd: externalCwd,
434
563
  prompt,
435
564
  background: true,
436
565
  ...(signal ? { signal } : {}),
@@ -454,7 +583,7 @@ function createSubagentTool(options) {
454
583
  }
455
584
  const result = await runExternalAgentTurn({
456
585
  kind: runtimeKind,
457
- cwd: options.runtime.workspaceRoot,
586
+ cwd: externalCwd,
458
587
  prompt,
459
588
  ...(signal ? { signal } : {}),
460
589
  ...(options.externalEnv ? { env: options.externalEnv } : {}),
@@ -477,13 +606,13 @@ function createSubagentTool(options) {
477
606
  }
478
607
  }
479
608
  const depth = subagentDepth(options.runtime, options.parentSessionId);
609
+ const { maxDepth, maxActive: maxActiveChildren, active } = caps();
480
610
  if (depth >= maxDepth) {
481
611
  return {
482
612
  content: `subagent: max depth ${maxDepth} reached (current depth ${depth})`,
483
613
  isError: true,
484
614
  };
485
615
  }
486
- const active = countActiveChildren(options.runtime, options.parentSessionId);
487
616
  if (active >= maxActiveChildren) {
488
617
  return {
489
618
  content: `subagent: max active children ${maxActiveChildren} reached (active ${active})`,
@@ -497,6 +626,14 @@ function createSubagentTool(options) {
497
626
  (background ? "subagent" : "subagent-task");
498
627
  const linkMode = background ? "continuable" : "one-shot";
499
628
  const parentCwd = resolveSessionCwd(options.runtime, options.parentSessionId);
629
+ const taskTitle = String(a.task_name ?? "").trim() || label;
630
+ const taskIdHint = String(a.task_id ?? "").trim() || undefined;
631
+ // Before anything is allocated: reusing a task_id another child owns
632
+ // must not leave a worktree or a session behind.
633
+ const taskIdError = taskIdConflict(options.runtime, taskIdHint);
634
+ if (taskIdError) {
635
+ return { content: taskIdError, isError: true };
636
+ }
500
637
  let isolated = null;
501
638
  let managedLease = null;
502
639
  let worktreeSkip = "";
@@ -528,15 +665,13 @@ function createSubagentTool(options) {
528
665
  }
529
666
  }
530
667
  }
531
- let childPrompt = applySpawnRoleReminder(prompt, spawnRole);
668
+ let taskBody = prompt;
532
669
  if (outputSchema) {
533
- childPrompt = appendOutputContract(childPrompt, outputSchema);
670
+ taskBody = appendOutputContract(taskBody, outputSchema);
534
671
  }
535
- childPrompt = isolated
536
- ? `${childPrompt}\n\n${worktreeContextNote(isolated)}`
537
- : childPrompt;
538
- const taskTitle = String(a.task_name ?? "").trim() || label;
539
- const taskIdHint = String(a.task_id ?? "").trim() || undefined;
672
+ taskBody = isolated
673
+ ? `${taskBody}\n\n${worktreeContextNote(isolated)}`
674
+ : taskBody;
540
675
  const openTask = (childSessionId) => {
541
676
  const task = options.runtime.agentTeamTasks.open({
542
677
  parentSessionId: options.parentSessionId,
@@ -591,14 +726,41 @@ function createSubagentTool(options) {
591
726
  finalizeSubagentWorktree(isolated);
592
727
  }
593
728
  };
729
+ /**
730
+ * Codex `spawn_guard.rs` (RAII): a half-made spawn is torn down on
731
+ * EVERY exit path, including an unexpected throw. Explicit
732
+ * `discardUnstartedChild` calls cover the known failure branches; this
733
+ * flag + finally is the backstop for anything they miss.
734
+ */
735
+ let started = false;
736
+ const discardIfUnstarted = async () => {
737
+ if (started || !childId)
738
+ return;
739
+ started = true;
740
+ dropIsolated();
741
+ await discardUnstartedChild(options.runtime, childId);
742
+ };
594
743
  let childId;
595
- let seedEventCount = 0;
744
+ /** Fork seed size — bounds the root-evidence slice below. */
745
+ let seedCut = 0;
596
746
  if (inherit) {
597
- const forked = await dispatchFaceMethod(options.runtime, "session.fork", `tool-sa-fork-${Date.now()}`, {
598
- sessionId: options.parentSessionId,
599
- linkMode,
600
- label,
601
- });
747
+ // A transport reject (not an RpcResult error) here would strand the
748
+ // worktree lease allocated above; catch keeps the cleanup total.
749
+ let forked;
750
+ try {
751
+ forked = await dispatchFaceMethod(options.runtime, "session.fork", `tool-sa-fork-${Date.now()}`, {
752
+ sessionId: options.parentSessionId,
753
+ linkMode,
754
+ label,
755
+ });
756
+ }
757
+ catch {
758
+ dropIsolated();
759
+ return {
760
+ content: "subagent seed failed: fork request errored",
761
+ isError: true,
762
+ };
763
+ }
602
764
  if (!forked.result.ok) {
603
765
  // No completed turn yet — fall back to a fresh child (DSH fork omits seed).
604
766
  if (forked.result.error.code === "fork-unavailable") {
@@ -626,7 +788,10 @@ function createSubagentTool(options) {
626
788
  }
627
789
  else {
628
790
  childId = String(forked.result.value.sessionId);
629
- seedEventCount = Number(forked.result.value.eventCount ?? 0);
791
+ // Durable seed boundary: every answer read of this child must
792
+ // slice from here, or the parent's last reply reads as its answer.
793
+ seedCut = Number(forked.result.value.eventCount ?? 0);
794
+ options.runtime.subagents.setSeedEventCount(childId, seedCut);
630
795
  }
631
796
  }
632
797
  else {
@@ -654,21 +819,62 @@ function createSubagentTool(options) {
654
819
  : {}),
655
820
  });
656
821
  if (!modelPin.ok) {
657
- dropIsolated();
822
+ await discardIfUnstarted();
658
823
  return { content: modelPin.message, isError: true };
659
824
  }
660
825
  if (isolated) {
661
826
  options.runtime.sessionCwds.set(childId, isolated.path);
662
- await options.runtime.invalidateAgent?.(childId);
827
+ // Worktree bind failure must not leak the child: swallow and let the
828
+ // prompt below decide the spawn's fate (or the catch backstop).
829
+ await Promise.resolve(options.runtime.invalidateAgent?.(childId)).catch(() => undefined);
663
830
  }
831
+ const childPrompt = applySubagentSpawnPreamble({
832
+ prompt: taskBody,
833
+ parentSessionId: options.parentSessionId,
834
+ childSessionId: childId,
835
+ mode: linkMode,
836
+ label,
837
+ ...(spawnRole ? { role: spawnRole } : {}),
838
+ inheritContext: inherit,
839
+ cwd: isolated?.path ?? parentCwd,
840
+ isolatedWorktree: isolated !== null,
841
+ // The human's own asks: a child that only sees the parent's paraphrase
842
+ // drifts from what was actually authorized (Codex
843
+ // `control/user_authorization.rs`). A forked child already carries
844
+ // these in its seeded transcript, so only add them beyond the cut.
845
+ ...(() => {
846
+ const block = rootUserAuthorizationBlock({
847
+ parentEvents: readSessionEvents(options.runtime.store, options.parentSessionId),
848
+ ...(seedCut > 0 ? { sinceEventCount: seedCut } : {}),
849
+ });
850
+ return block ? { userAuthorization: block } : {};
851
+ })(),
852
+ });
664
853
  const task = openTask(childId);
665
854
  const worktreeLine = reclaimIsolated;
666
- const prompted = await dispatchFaceMethod(options.runtime, "session.prompt", `tool-sa-p-${childId}`, {
667
- sessionId: childId,
668
- mode: "queue",
669
- content: [{ type: "text", text: childPrompt }],
670
- });
855
+ // A transport-level reject (not an RpcResult error) must not leak the
856
+ // child: the catch backstop tears the half-made spawn down.
857
+ let prompted;
858
+ try {
859
+ prompted = await dispatchFaceMethod(options.runtime, "session.prompt", `tool-sa-p-${childId}`, {
860
+ sessionId: childId,
861
+ mode: "queue",
862
+ content: [{ type: "text", text: childPrompt }],
863
+ });
864
+ }
865
+ catch (err) {
866
+ await discardIfUnstarted();
867
+ const extra = worktreeLine(false);
868
+ const message = err instanceof Error ? err.message : String(err);
869
+ return {
870
+ content: [`subagent prompt failed: ${message}`, extra]
871
+ .filter(Boolean)
872
+ .join("\n"),
873
+ isError: true,
874
+ };
875
+ }
671
876
  if (!prompted.result.ok) {
877
+ await discardIfUnstarted();
672
878
  const extra = worktreeLine(false);
673
879
  return {
674
880
  content: [`subagent prompt failed: ${prompted.result.error.message}`, extra]
@@ -677,6 +883,8 @@ function createSubagentTool(options) {
677
883
  isError: true,
678
884
  };
679
885
  }
886
+ // Spawn accepted — the child is no longer a half-made shell.
887
+ started = true;
680
888
  if (background) {
681
889
  return {
682
890
  content: [
@@ -715,12 +923,19 @@ function createSubagentTool(options) {
715
923
  }
716
924
  const all = readSessionEvents(options.runtime.store, childId);
717
925
  // Never return seeded parent assistant text as the child's result.
718
- let owned = seedEventCount > 0 ? all.slice(seedEventCount) : all;
926
+ const link = options.runtime.subagents.getByChild(childId);
927
+ const readOwned = () => subagentOwnedEvents(link, readSessionEvents(options.runtime.store, childId));
928
+ let owned = subagentOwnedEvents(link, all);
719
929
  let text = lastAssistantBodyText(owned);
720
930
  let schemaValid;
721
931
  let schemaErrors;
722
- if (outputSchema && text) {
723
- let checked = validateOutputAgainstSchema(text, outputSchema);
932
+ if (outputSchema) {
933
+ // An empty body cannot satisfy the contract either: a silent skip
934
+ // would hand the parent `ok: true` with an unvalidated (missing)
935
+ // answer, so treat "no text" as one more schema failure.
936
+ let checked = text
937
+ ? validateOutputAgainstSchema(text, outputSchema)
938
+ : { valid: false, errors: ["child produced no assistant text"] };
724
939
  if (!checked.valid) {
725
940
  const retry = await dispatchFaceMethod(options.runtime, "session.prompt", `tool-sa-schema-${childId}`, {
726
941
  sessionId: childId,
@@ -739,18 +954,20 @@ function createSubagentTool(options) {
739
954
  catch {
740
955
  /* keep first answer */
741
956
  }
742
- owned = seedEventCount > 0
743
- ? readSessionEvents(options.runtime.store, childId).slice(seedEventCount)
744
- : readSessionEvents(options.runtime.store, childId);
957
+ owned = readOwned();
745
958
  text = lastAssistantBodyText(owned) || text;
746
- checked = validateOutputAgainstSchema(text, outputSchema);
959
+ checked = text
960
+ ? validateOutputAgainstSchema(text, outputSchema)
961
+ : { valid: false, errors: ["child produced no assistant text"] };
747
962
  }
748
963
  }
749
964
  schemaValid = checked.valid;
750
965
  schemaErrors = checked.errors;
751
966
  }
752
967
  options.runtime.agentTeamTasks.complete(task.id, {
753
- ok: true,
968
+ // An unvalidated answer is not a successful delegation: the task
969
+ // board must not read `completed` when the contract was skipped.
970
+ ok: schemaValid !== false,
754
971
  ...(text ? { preview: text } : {}),
755
972
  ...(schemaValid !== undefined ? { schemaValid } : {}),
756
973
  ...(schemaErrors && schemaErrors.length > 0
@@ -786,7 +1003,7 @@ function createSubagentTool(options) {
786
1003
  function createListAgentsTool(options) {
787
1004
  return {
788
1005
  name: "list_agents",
789
- description: "List direct child subagents of this session (id, label, mode, running, inbox queue/steer). " +
1006
+ description: "List direct child subagents of this session (id, label, mode, running, model, inbox queue/steer). " +
790
1007
  "Prefaces with depth/active quota. Prefer `analytics` for a fuller quota snapshot.",
791
1008
  parameters: { type: "object", properties: {} },
792
1009
  isConcurrencySafe: () => true,
@@ -819,7 +1036,8 @@ function createListAgentsTool(options) {
819
1036
  const inbox = id !== "?"
820
1037
  ? childInboxCounts(options.runtime, id)
821
1038
  : { queued: 0, steering: 0 };
822
- return `${id}\t${label}\t${mode}\t${activity}\tq=${inbox.queued}\tsteer=${inbox.steering}`;
1039
+ const route = e.model ? `\t${e.model}` : "";
1040
+ return `${id}\t${label}\t${mode}\t${activity}${route}\tq=${inbox.queued}\tsteer=${inbox.steering}`;
823
1041
  });
824
1042
  return { content: [header, ...lines].join("\n") };
825
1043
  },
@@ -873,7 +1091,8 @@ function createSendMessageTool(options) {
873
1091
  isError: true,
874
1092
  };
875
1093
  }
876
- options.runtime.agentTeamTasks.markResumed(childId);
1094
+ // Task-board resume is owned by `subagent.prompt` (the SoT for every
1095
+ // delivery route); calling it again here double-bumped the revision.
877
1096
  return { content: `sent to ${childId} (${delivery})` };
878
1097
  },
879
1098
  };
@@ -1032,7 +1251,6 @@ function createTeamGraphTool(options) {
1032
1251
  results.push(`${peer.id}\tfail\t${prompted.result.error.message}`);
1033
1252
  continue;
1034
1253
  }
1035
- options.runtime.agentTeamTasks.markResumed(peer.id);
1036
1254
  results.push(`${peer.id}\tok\t${delivery}`);
1037
1255
  }
1038
1256
  return {
@@ -1110,7 +1328,6 @@ function createFollowupTaskTool(options) {
1110
1328
  isError: true,
1111
1329
  };
1112
1330
  }
1113
- options.runtime.agentTeamTasks.markResumed(childId);
1114
1331
  return { content: `followup_task sent to ${childId} (steer)` };
1115
1332
  },
1116
1333
  };
@@ -1217,10 +1434,11 @@ function createWaitAgentTool(options) {
1217
1434
  : `wait_agent completed (${timeoutMs}ms budget)`,
1218
1435
  ];
1219
1436
  for (const childId of targets) {
1437
+ const link = options.runtime.subagents.getByChild(childId);
1220
1438
  const running = isChildSessionActive(options.runtime, childId);
1221
1439
  const text = running
1222
1440
  ? undefined
1223
- : lastAssistantBodyText(readSessionEvents(options.runtime.store, childId));
1441
+ : lastAssistantBodyText(subagentOwnedEvents(link, readSessionEvents(options.runtime.store, childId)));
1224
1442
  const inbox = childInboxCounts(options.runtime, childId);
1225
1443
  lines.push([
1226
1444
  childId,
@@ -1245,7 +1463,8 @@ function createAnalyticsTool(options) {
1245
1463
  return {
1246
1464
  name: "analytics",
1247
1465
  description: "Local subagent quota and queue snapshot for this session (not cloud telemetry). " +
1248
- "Shows depth/active caps, free slots, and each child's running state plus inbox queue/steer counts.",
1466
+ "Shows depth/active caps, free slots, and each child's running state, model route, " +
1467
+ "plus inbox queue/steer counts.",
1249
1468
  parameters: { type: "object", properties: {} },
1250
1469
  isConcurrencySafe: () => true,
1251
1470
  async execute() {
@@ -1278,7 +1497,14 @@ function createAnalyticsTool(options) {
1278
1497
  ? "running"
1279
1498
  : "idle";
1280
1499
  const inbox = childInboxCounts(options.runtime, link.childSessionId);
1281
- lines.push(` ${link.childSessionId}\t${link.label ?? "subagent"}\t${link.mode}\t${activity}\tq=${inbox.queued}\tsteer=${inbox.steering}`);
1500
+ const externalKind = options.runtime.externalAgents.kind(link.childSessionId);
1501
+ const route = externalKind
1502
+ ? `external:${externalKind}`
1503
+ : (() => {
1504
+ const m = resolveSessionModelSelection(options.runtime, link.childSessionId);
1505
+ return `${m.provider}/${m.model}`;
1506
+ })();
1507
+ lines.push(` ${link.childSessionId}\t${link.label ?? "subagent"}\t${link.mode}\t${activity}\t${route}\tq=${inbox.queued}\tsteer=${inbox.steering}`);
1282
1508
  }
1283
1509
  }
1284
1510
  return { content: lines.join("\n") };