@herbertgao/pi-subagents 0.17.1 → 0.18.1

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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -10
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +10 -4
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -40,8 +40,14 @@ import {
40
40
  } from "./nested-tools.js"
41
41
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js"
42
42
  import { preloadSkills } from "./skill-loader.js"
43
+ import {
44
+ createStructuredCapture,
45
+ createStructuredOutputTool,
46
+ structuredRetryPrompt,
47
+ } from "./structured-output.js"
43
48
  import type { SubagentType, ThinkingLevel } from "./types.js"
44
49
  import type { LifetimeUsage } from "./usage.js"
50
+ import type { CompiledSchema } from "./workflow/json-schema.js"
45
51
 
46
52
  /**
47
53
  * Tool names registered by THIS extension. Single source of truth so the
@@ -51,6 +57,7 @@ import type { LifetimeUsage } from "./usage.js"
51
57
  */
52
58
  export const SUBAGENT_TOOL_NAMES = {
53
59
  AGENT: "Agent",
60
+ WORKFLOW: "SubagentWorkflow",
54
61
  GET_RESULT: "get_subagent_result",
55
62
  STEER: "steer_subagent",
56
63
  } as const
@@ -253,8 +260,15 @@ export function installExtensionToolScope(
253
260
  disallowedSet: Set<string> | undefined
254
261
  extNames: Set<string>
255
262
  narrowing: Map<string, Set<string>>
256
- /** Opt-in nested-delegation tool names to keep active despite the EXCLUDED strip. */
257
- nestedToolNames: Set<string>
263
+ /**
264
+ * Injected `customTools` to keep active regardless of the built-in list.
265
+ *
266
+ * Two kinds arrive here and they are blocked for different reasons: opt-in
267
+ * nested-delegation tools share EXCLUDED_TOOL_NAMES' names, and
268
+ * StructuredOutput is simply not a built-in, so neither survives a `keep`
269
+ * seeded from `toolNames`.
270
+ */
271
+ readmitToolNames: Set<string>
258
272
  },
259
273
  ): void {
260
274
  const {
@@ -263,7 +277,7 @@ export function installExtensionToolScope(
263
277
  disallowedSet,
264
278
  extNames,
265
279
  narrowing,
266
- nestedToolNames,
280
+ readmitToolNames,
267
281
  } = ctx
268
282
 
269
283
  // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
@@ -286,12 +300,11 @@ export function installExtensionToolScope(
286
300
  }
287
301
  }
288
302
  for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name)
289
- // Opt-in nested delegation tools share EXCLUDED_TOOL_NAMES' names but are
290
- // legitimately active for this agent re-admit them so the renarrow keeps
291
- // them in the active set and beforeToolCall doesn't block them.
292
- for (const name of nestedToolNames) {
293
- if (!disallowedSet?.has(name)) keep.add(name)
294
- }
303
+ // Injected tools are legitimately active for this agent — re-admit them so
304
+ // the renarrow keeps them in the active set and beforeToolCall doesn't
305
+ // block them. Already vetted against `disallowed_tools` by the caller,
306
+ // which is the only place that knows which kind may be taken back.
307
+ for (const name of readmitToolNames) keep.add(name)
295
308
  return keep
296
309
  }
297
310
 
@@ -350,6 +363,15 @@ export function setDefaultMaxTurns(n: number | undefined): void {
350
363
  defaultMaxTurns = normalizeMaxTurns(n)
351
364
  }
352
365
 
366
+ /**
367
+ * The turn limit a run of `type` will actually enforce: an explicit value if the
368
+ * caller supplied one, else the agent's own `max_turns`, else the project
369
+ * default. `undefined` = unlimited.
370
+ *
371
+ * Exported because the widget's turn counter (`↻3≤20`) has to predict this
372
+ * before the run starts, and a second copy of the expression would drift from
373
+ * the one below that enforces it.
374
+ */
353
375
  export function resolveEffectiveMaxTurns(
354
376
  type: string,
355
377
  explicit?: number,
@@ -359,6 +381,24 @@ export function resolveEffectiveMaxTurns(
359
381
  )
360
382
  }
361
383
 
384
+ /**
385
+ * Project default for `persist_session`, from the `rememberAgents` setting.
386
+ * On by default: a persisted session is what lets `@handle` reopen an agent's
387
+ * conversation after its record has been evicted, which is the whole point of
388
+ * addressing an agent by a name that outlives one run. Per-agent frontmatter
389
+ * still overrides it in both directions.
390
+ */
391
+ let rememberAgents = true
392
+
393
+ /** Whether subagent sessions are persisted by default. */
394
+ export function getRememberAgents(): boolean {
395
+ return rememberAgents
396
+ }
397
+ /** Set whether subagent sessions are persisted by default. */
398
+ export function setRememberAgents(b: boolean): void {
399
+ rememberAgents = b
400
+ }
401
+
362
402
  /** Additional turns allowed after the soft limit steer message. */
363
403
  let graceTurns = 5
364
404
 
@@ -422,9 +462,38 @@ export interface RunOptions {
422
462
  isolated?: boolean
423
463
  inheritContext?: boolean
424
464
  thinkingLevel?: ThinkingLevel
465
+ /**
466
+ * Reopen this pi session file rather than starting an empty conversation.
467
+ * `createAgentSession` seeds itself from whatever its SessionManager holds,
468
+ * so pointing it at an existing file rehydrates that agent's history and the
469
+ * prompt continues it. Everything else — tools, model, system prompt, turn
470
+ * caps — is still resolved from the agent type, so the continuation runs
471
+ * under the type's *current* definition, not the one the original run used.
472
+ */
473
+ resumeSessionFile?: string
474
+ /**
475
+ * True when another agent spawned this one. Only top-level agents get a
476
+ * handle, so only they can be reopened by name — which is the whole reason
477
+ * `rememberAgents` persists a session at all. A nested run's transcript would
478
+ * be unreachable by anything, so it stays in memory unless its own
479
+ * frontmatter asks otherwise.
480
+ */
481
+ nested?: boolean
482
+ /**
483
+ * True when a workflow run spawned this agent. Its final text is the value
484
+ * `agent()` resolves to rather than a report a person reads, and the prompt
485
+ * says so — but only when `structuredOutput` is unset, since that child
486
+ * already has a `StructuredOutput` tool to answer through and two competing
487
+ * "this is how you return your answer" instructions is worse than one.
488
+ */
489
+ workflow?: boolean
425
490
  /** Override working directory (e.g. for worktree isolation). */
426
491
  cwd?: string
427
- /** Original checkout path when cwd is an isolated worktree copy. */
492
+ /**
493
+ * Directory the worktree copy was created from. Set only when `cwd` points
494
+ * into a worktree — the prompt then tells the agent to stay in the copy
495
+ * instead of following the inherited parent prompt back to the main tree.
496
+ */
428
497
  worktreeBase?: string
429
498
  /**
430
499
  * Where .pi config is discovered (project extensions, skills, pi settings,
@@ -451,6 +520,11 @@ export interface RunOptions {
451
520
  * Called once per assistant message_end with that message's usage delta.
452
521
  * Lets callers maintain a lifetime accumulator that survives compaction
453
522
  * (which replaces session.state.messages and resets stats-derived sums).
523
+ *
524
+ * `cost` is pi's own `usage.cost.total` for that message — priced from the
525
+ * model's rates, so it is 0 (not missing) for a model pi has no pricing for.
526
+ * We never price anything ourselves; every dollar figure this extension shows
527
+ * or reports traces back to this field.
454
528
  */
455
529
  onAssistantUsage?: (usage: LifetimeUsage) => void
456
530
  /**
@@ -461,6 +535,14 @@ export interface RunOptions {
461
535
  reason: "manual" | "threshold" | "overflow"
462
536
  tokensBefore: number
463
537
  }) => void
538
+ /**
539
+ * Make this child report through a `StructuredOutput` tool built from this
540
+ * schema, and put the validated payload on {@link RunResult.structuredJson}.
541
+ *
542
+ * Already compiled by the caller, so a schema this runtime cannot validate
543
+ * fails at the call that wrote it rather than inside the child.
544
+ */
545
+ structuredOutput?: CompiledSchema
464
546
  /** Runtime bridge for opt-in child-safe nested delegation. */
465
547
  nestedRuntime?: {
466
548
  manager: NestedAgentManager
@@ -487,6 +569,18 @@ export interface RunResult {
487
569
  * stop that produced text (a legitimate truncated answer).
488
570
  */
489
571
  failure?: string
572
+ /**
573
+ * The validated `StructuredOutput` payload as canonical JSON, when the caller
574
+ * asked for a schema and the child produced one.
575
+ *
576
+ * Deliberately not folded into {@link responseText}: `record.result` picks up
577
+ * a worktree branch note on the way out, which would leave the caller with
578
+ * unparseable JSON, and merging the two would make "produced structured
579
+ * output" indistinguishable from "happened to answer in JSON".
580
+ */
581
+ structuredJson?: string
582
+ /** Whether the extra structured-output prompt had to be sent. */
583
+ structuredRetried?: boolean
490
584
  }
491
585
 
492
586
  /**
@@ -610,7 +704,9 @@ export async function runAgent(
610
704
  const parentSystemPrompt = ctx.getSystemPrompt()
611
705
 
612
706
  // Build prompt extras (memory, skill preloading)
613
- const extras: PromptExtras = { worktreeBase: options.worktreeBase }
707
+ const extras: PromptExtras = {}
708
+ if (options.worktreeBase) extras.worktreeBase = options.worktreeBase
709
+ if (options.workflow && !options.structuredOutput) extras.workflowChild = true
614
710
 
615
711
  // Resolve extensions/skills: isolated overrides to false
616
712
  const extensions = options.isolated ? false : config.extensions
@@ -884,6 +980,36 @@ export async function runAgent(
884
980
  : []
885
981
  const nestedToolNames = new Set(nestedTools.map((tool) => tool.name))
886
982
 
983
+ // The `agent({ schema })` contract: this child reports its answer by calling
984
+ // StructuredOutput, and `structuredJson` below is what the caller reads. The
985
+ // schema was already compiled by whoever asked for it, so a bad one failed
986
+ // before any of this ran.
987
+ const structuredCapture = options.structuredOutput
988
+ ? createStructuredCapture()
989
+ : undefined
990
+ const structuredTools =
991
+ options.structuredOutput && structuredCapture
992
+ ? [
993
+ createStructuredOutputTool(
994
+ options.structuredOutput,
995
+ structuredCapture,
996
+ ),
997
+ ]
998
+ : []
999
+ const structuredToolNames = new Set(structuredTools.map((tool) => tool.name))
1000
+ // Re-admitted together at every gate below. Kept as one set so a new injected
1001
+ // tool cannot be added to some of the three gates and forgotten at the rest.
1002
+ //
1003
+ // `disallowed_tools` is applied HERE rather than at the gates, because the two
1004
+ // kinds answer to it differently: a nested delegation tool is an opt-in the
1005
+ // agent's own frontmatter can take back, while StructuredOutput exists only
1006
+ // because this call asked for a schema — removing it would make the request
1007
+ // unsatisfiable by construction rather than merely restricted.
1008
+ const readmitToolNames = new Set([
1009
+ ...[...nestedToolNames].filter((name) => !disallowedSet?.has(name)),
1010
+ ...structuredToolNames,
1011
+ ])
1012
+
887
1013
  // ─── Tool scoping ───────────────────────────────────────────────────────
888
1014
  //
889
1015
  // Some extensions register their tools ASYNCHRONOUSLY, long after the
@@ -923,6 +1049,11 @@ export async function runAgent(
923
1049
  (t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t),
924
1050
  ),
925
1051
  ...[...nestedToolNames].filter((t) => !disallowedSet?.has(t)),
1052
+ // Not filtered through `disallowedSet`, unlike the nested tools above:
1053
+ // the caller asked for a schema, and removing the only tool that can
1054
+ // satisfy it would make the request unsatisfiable by construction rather
1055
+ // than merely restricted.
1056
+ ...structuredToolNames,
926
1057
  ]
927
1058
  } else {
928
1059
  // Deny the orchestration tools EXCEPT the nested ones this agent opted into —
@@ -936,7 +1067,10 @@ export async function runAgent(
936
1067
  }
937
1068
  if (disallowedSet) {
938
1069
  // disallowed_tools wins even over an opt-in nested tool of the same name.
939
- for (const name of disallowedSet) denyTools.add(name)
1070
+ // Not over StructuredOutput, though — see the allowlist branch above.
1071
+ for (const name of disallowedSet) {
1072
+ if (!structuredToolNames.has(name)) denyTools.add(name)
1073
+ }
940
1074
  }
941
1075
  sessionExcludeTools = [...denyTools]
942
1076
  }
@@ -948,26 +1082,43 @@ export async function runAgent(
948
1082
  )
949
1083
  const defaultSessionDir =
950
1084
  process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.()
951
- const sessionManager = agentConfig?.persistSession
952
- ? SessionManager.create(
953
- effectiveCwd,
1085
+ // Frontmatter wins when it says anything; otherwise the project default,
1086
+ // which `rememberAgents` supplies for top-level agents only. Same precedence
1087
+ // as `outputTranscript`.
1088
+ const persistSession =
1089
+ agentConfig?.persistSession ?? (options.nested ? false : rememberAgents)
1090
+ const sessionManager = options.resumeSessionFile
1091
+ ? // Reopening an existing conversation: the file already carries its own
1092
+ // header (cwd, parent) and history, so none of the create-time options
1093
+ // apply. `sessionDir` still matters for a later /new or /branch off it.
1094
+ SessionManager.open(
1095
+ options.resumeSessionFile,
954
1096
  configuredSessionDir ?? defaultSessionDir,
955
- {
956
- parentSession: ctx.sessionManager.getSessionFile(),
957
- },
958
1097
  )
959
- : SessionManager.inMemory(effectiveCwd)
1098
+ : persistSession
1099
+ ? SessionManager.create(
1100
+ effectiveCwd,
1101
+ configuredSessionDir ?? defaultSessionDir,
1102
+ {
1103
+ // Optional metadata — it only nests the subagent under its spawner in
1104
+ // `/resume`. Until `rememberAgents` this ran solely for the rare
1105
+ // `persist_session: true` agent; now it runs for every spawn, so a
1106
+ // context without a session manager (a bare programmatic ctx) must
1107
+ // still persist rather than take the whole spawn down.
1108
+ parentSession: ctx.sessionManager?.getSessionFile?.(),
1109
+ },
1110
+ )
1111
+ : SessionManager.inMemory(effectiveCwd)
960
1112
 
961
1113
  // Pi 0.80.8 replaced createAgentSession's modelRegistry option with
962
1114
  // modelRuntime, but ExtensionContext still exposes only the registry facade.
963
1115
  // Pass both so the full supported Pi range retains the parent's providers.
964
- type SessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>
1116
+ // SAFETY: Pi's registry facade exposes its runtime at runtime on versions
1117
+ // that support modelRuntime; the optional shape preserves older Pi support.
965
1118
  const parentModelRuntime = (
966
- ctx.modelRegistry as unknown as {
967
- runtime?: unknown
968
- }
1119
+ ctx.modelRegistry as unknown as { runtime?: unknown }
969
1120
  ).runtime
970
- const sessionOpts: SessionOptions & {
1121
+ const sessionOpts: Parameters<typeof createAgentSession>[0] & {
971
1122
  modelRegistry: ExtensionContext["modelRegistry"]
972
1123
  modelRuntime?: unknown
973
1124
  } = {
@@ -976,12 +1127,16 @@ export async function runAgent(
976
1127
  sessionManager,
977
1128
  settingsManager,
978
1129
  modelRegistry: ctx.modelRegistry,
979
- ...(parentModelRuntime != null && {
1130
+ // `as never` is what keeps this assignable across the supported Pi range:
1131
+ // pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
1132
+ // above, while newer Pi types it as `ModelRuntime` — a shape an opaque
1133
+ // `unknown` read off the private facade field can never satisfy.
1134
+ ...(parentModelRuntime !== undefined && {
980
1135
  modelRuntime: parentModelRuntime as never,
981
1136
  }),
982
1137
  model,
983
1138
  tools: sessionTools,
984
- customTools: nestedTools,
1139
+ customTools: [...nestedTools, ...structuredTools],
985
1140
  resourceLoader: loader,
986
1141
  }
987
1142
  if (sessionExcludeTools) {
@@ -1028,7 +1183,7 @@ export async function runAgent(
1028
1183
  disallowedSet,
1029
1184
  extNames,
1030
1185
  narrowing,
1031
- nestedToolNames,
1186
+ readmitToolNames,
1032
1187
  })
1033
1188
  }
1034
1189
 
@@ -1110,8 +1265,24 @@ export async function runAgent(
1110
1265
  // Boundary for the history fallback: only assistant text produced from here
1111
1266
  // on counts as this run's output (a fresh session, so usually 0).
1112
1267
  const startLen = session.messages.length
1268
+ let structuredRetried = false
1113
1269
  try {
1114
1270
  await session.prompt(effectivePrompt)
1271
+
1272
+ // One more prompt when a schema was asked for and nothing usable came back
1273
+ // — the model answered in prose, or only ever called the tool invalidly.
1274
+ // Inside this `try`, so the turn tracking, the text collector and above all
1275
+ // the abort forwarding are still live: torn down first, a retry would be
1276
+ // unkillable.
1277
+ if (
1278
+ structuredCapture !== undefined &&
1279
+ structuredCapture.json === undefined &&
1280
+ !aborted &&
1281
+ options.signal?.aborted !== true
1282
+ ) {
1283
+ structuredRetried = true
1284
+ await session.prompt(structuredRetryPrompt(structuredCapture))
1285
+ }
1115
1286
  } finally {
1116
1287
  unsubTurns()
1117
1288
  collector.unsubscribe()
@@ -1120,12 +1291,25 @@ export async function runAgent(
1120
1291
 
1121
1292
  const responseText =
1122
1293
  collector.getText().trim() || getLastAssistantText(session, startLen)
1294
+ // A child asked for structured output that never gave any has failed, however
1295
+ // articulate its prose was. Reported through `failure` so it travels the same
1296
+ // path as a provider error rather than arriving as a successful empty answer.
1297
+ const structuredFailure =
1298
+ structuredCapture !== undefined && structuredCapture.json === undefined
1299
+ ? structuredCapture.lastError !== undefined
1300
+ ? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
1301
+ : "The agent did not report its answer through StructuredOutput."
1302
+ : undefined
1123
1303
  return {
1124
1304
  responseText,
1125
1305
  session,
1126
1306
  aborted,
1127
1307
  steered: softLimitReached,
1128
- failure: finalTurnError(session, startLen),
1308
+ failure: finalTurnError(session, startLen) ?? structuredFailure,
1309
+ ...(structuredCapture?.json !== undefined
1310
+ ? { structuredJson: structuredCapture.json }
1311
+ : {}),
1312
+ ...(structuredRetried ? { structuredRetried } : {}),
1129
1313
  }
1130
1314
  }
1131
1315
 
@@ -1137,8 +1321,6 @@ export async function resumeAgent(
1137
1321
  prompt: string,
1138
1322
  options: {
1139
1323
  onToolActivity?: (activity: ToolActivity) => void
1140
- /** Called at the end of each resumed agentic turn with the 1-based count. */
1141
- onTurnEnd?: (turnCount: number) => void
1142
1324
  onAssistantUsage?: (usage: LifetimeUsage) => void
1143
1325
  onCompaction?: (info: {
1144
1326
  reason: "manual" | "threshold" | "overflow"
@@ -1153,18 +1335,10 @@ export async function resumeAgent(
1153
1335
  const startLen = session.messages.length
1154
1336
  const collector = collectResponseText(session)
1155
1337
  const cleanupAbort = forwardAbortSignal(session, options.signal)
1156
- let turnCount = 0
1157
1338
 
1158
1339
  const unsubEvents =
1159
- options.onToolActivity ||
1160
- options.onTurnEnd ||
1161
- options.onAssistantUsage ||
1162
- options.onCompaction
1340
+ options.onToolActivity || options.onAssistantUsage || options.onCompaction
1163
1341
  ? session.subscribe((event: AgentSessionEvent) => {
1164
- if (event.type === "turn_end") {
1165
- turnCount++
1166
- options.onTurnEnd?.(turnCount)
1167
- }
1168
1342
  if (event.type === "tool_execution_start")
1169
1343
  options.onToolActivity?.({
1170
1344
  type: "start",
@@ -7,10 +7,16 @@
7
7
  * Reply envelope follows pi-mono convention:
8
8
  * success → { success: true, data?: T }
9
9
  * error → { success: false, error: string }
10
+ *
11
+ * @see docs/rpc.md — the caller-facing integration reference: spawn options
12
+ * (including the fields spawnTopLevel strips), every error string, the
13
+ * completion-notification race, and what protocol version 2 does not promise.
10
14
  */
11
15
 
16
+ import { isTopLevelAgent } from "./agent-manager.js"
12
17
  import { type ModelRegistry, resolveModel } from "./model-resolver.js"
13
18
  import { checkModelScope } from "./model-scope.js"
19
+ import type { AgentRecord } from "./types.js"
14
20
 
15
21
  /** Minimal event bus interface needed by the RPC handlers. */
16
22
  export interface EventBus {
@@ -35,7 +41,22 @@ export interface SpawnCapable {
35
41
  prompt: string,
36
42
  options: any,
37
43
  ): string
44
+ /** Resolves once the spawned agent is running; rejects on a startup failure. */
45
+ awaitStartup(id: string): Promise<void>
38
46
  abort(id: string): boolean
47
+ /**
48
+ * The record behind an id, for the stop handler's ownership check. Narrowed
49
+ * to the two fields `isTopLevelAgent` reads, so the RPC layer keeps its
50
+ * deliberately shallow view of the manager.
51
+ */
52
+ getRecord(
53
+ id: string,
54
+ ): Pick<AgentRecord, "parentAgentId" | "workflowId"> | undefined
55
+ /**
56
+ * Mark a settled agent's result as read by the caller, suppressing the
57
+ * completion notification — what `get_subagent_result` does when it returns
58
+ * one. False when there is no such agent, or it has not settled yet.
59
+ */
39
60
  consumeResult(id: string): boolean
40
61
  }
41
62
 
@@ -94,7 +115,7 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
94
115
  type: string
95
116
  prompt: string
96
117
  options?: any
97
- }>(events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
118
+ }>(events, "subagents:rpc:spawn", async ({ type, prompt, options }) => {
98
119
  const ctx = getCtx()
99
120
  if (!ctx) throw new Error("No active session")
100
121
 
@@ -105,13 +126,17 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
105
126
  // agent's auth lookup doesn't crash with "No API key found for
106
127
  // undefined".
107
128
  let normalizedOptions = options ?? {}
129
+ // `!= null` on purpose: a JSON-forwarding caller can serialize an unset
130
+ // field as null, and the runner reads `options.model ?? default`, so null
131
+ // means "inherit" — not an override to resolve or scope-check.
108
132
  const override = normalizedOptions.model
109
- // null means inherit, matching the runner's `model ?? default` behavior.
110
133
  if (override != null) {
111
134
  const { modelRegistry, cwd } = ctx as {
112
135
  modelRegistry?: ModelRegistry
113
136
  cwd?: string
114
137
  }
138
+ // Names the override the same way in both messages below; an object
139
+ // override would otherwise interpolate as "[object Object]".
115
140
  const label =
116
141
  typeof override === "string"
117
142
  ? override
@@ -121,15 +146,25 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
121
146
  `Model override "${label}" provided but ctx.modelRegistry is unavailable`,
122
147
  )
123
148
  }
124
-
125
149
  let model = override
126
150
  if (typeof override === "string") {
127
151
  const resolved = resolveModel(override, modelRegistry)
128
- if (typeof resolved === "string") throw new Error(resolved)
152
+ if (typeof resolved === "string") {
153
+ // resolveModel returns a human-readable error string when the
154
+ // input doesn't match any available model. Surface it instead of
155
+ // silently falling back so the caller sees the auth/typo issue.
156
+ throw new Error(resolved)
157
+ }
129
158
  model = resolved
130
159
  normalizedOptions = { ...normalizedOptions, model: resolved }
131
160
  }
132
161
 
162
+ // A model on the RPC payload is an orchestrator-level choice, exactly
163
+ // like Agent({ model }) — so it gets the Agent tool's hard error, never
164
+ // the frontmatter warn (#240). The check reads the RESOLVED model:
165
+ // resolveModel is fuzzy, so a bare "sonnet" can land on a provider the
166
+ // caller never named. Frontmatter-pinned and parent-inherited models are
167
+ // resolved later, in agent-runner, and keep warn-and-proceed.
133
168
  const verdict = checkModelScope({
134
169
  model,
135
170
  cwd: cwd ?? process.cwd(),
@@ -141,25 +176,49 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
141
176
  if (verdict.kind === "error") throw new Error(verdict.message)
142
177
  }
143
178
 
144
- return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) }
179
+ const id = manager.spawn(pi, ctx, type, prompt, normalizedOptions)
180
+ // With isolation: "worktree" the agent starts asynchronously — wait for
181
+ // it, so a strict-isolation failure is still an error envelope rather
182
+ // than an id for an agent that never ran.
183
+ await manager.awaitStartup(id)
184
+ return { id }
145
185
  })
146
186
 
147
187
  const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
148
188
  events,
149
189
  "subagents:rpc:stop",
150
190
  ({ agentId }) => {
151
- if (!manager.abort(agentId)) throw new Error("Agent not found")
191
+ const record = manager.getRecord(agentId)
192
+ if (!record) throw new Error("Agent not found")
193
+ // Only the session's own agents are this RPC's to stop. A nested child or
194
+ // a workflow's agent is owned by something that is *waiting on it*, and
195
+ // aborting it out from under that owner turns another extension's stop
196
+ // into a failed step here. Defence in depth rather than a live hole: no
197
+ // RPC hands out agent ids, so a caller has no ordinary way to name one it
198
+ // does not own — but the guard is cheap and the id may leak some other
199
+ // way. Same refuse-what-we-should-not-touch stance as `consume` below.
200
+ if (!isTopLevelAgent(record))
201
+ throw new Error("Agent is owned by another agent or workflow")
202
+ // Not "not found" — the lookup above already proved it exists. `abort`
203
+ // returns false only for a record that is neither running nor queued,
204
+ // which is an agent that has already finished.
205
+ if (!manager.abort(agentId)) throw new Error("Agent is not running")
152
206
  },
153
207
  )
154
208
 
155
- const unsubConsume = handleRpc<{
156
- requestId: string
157
- agentId: string
158
- }>(events, "subagents:rpc:consume", ({ agentId }) => {
159
- if (!manager.consumeResult(agentId)) {
160
- throw new Error("Agent not found or still running")
161
- }
162
- })
209
+ // A caller that has already shown the model an agent's result — pi-tasks'
210
+ // TaskOutput is the one in practice — says so here, so the completion
211
+ // notification for that same result is not delivered on top of it and does
212
+ // not cost the parent a turn. Deliberately outside the ping version
213
+ // handshake: an extension built against protocol v2 simply never calls it.
214
+ const unsubConsume = handleRpc<{ requestId: string; agentId: string }>(
215
+ events,
216
+ "subagents:rpc:consume",
217
+ ({ agentId }) => {
218
+ if (!manager.consumeResult(agentId))
219
+ throw new Error("Agent not found or still running")
220
+ },
221
+ )
163
222
 
164
223
  return { unsubPing, unsubSpawn, unsubStop, unsubConsume }
165
224
  }