@herbertgao/pi-subagents 0.17.1 → 0.18.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +6 -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 +11 -9
  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
package/src/schedule.ts CHANGED
@@ -281,8 +281,15 @@ export class SubagentScheduler {
281
281
  isolated: job.isolated,
282
282
  thinkingLevel: job.thinking,
283
283
  isolation: job.isolation,
284
+ // A scheduled run has no tool call to build this, so without it the
285
+ // conversation viewer shows nothing about how the job was configured.
286
+ // The model is left out on purpose: agent-manager fills in the effective
287
+ // one when the session reports it, and naming the pre-session pick here
288
+ // would only be right until then.
284
289
  invocation: {
285
290
  thinking: job.thinking,
291
+ // Normalized like the Agent tool's own snapshot: `0` means unlimited,
292
+ // and rendering it as "max turns: 0" would read as a limit of none.
286
293
  maxTurns: normalizeMaxTurns(job.max_turns),
287
294
  isolated: job.isolated,
288
295
  runInBackground: true,
@@ -301,7 +308,6 @@ export class SubagentScheduler {
301
308
 
302
309
  this.emit({ type: "fired", jobId: id, agentId, name: job.name })
303
310
 
304
- const record = manager.getRecord(agentId)
305
311
  const finalize = (status: "success" | "error") => {
306
312
  const next = this.getNextRun(id)
307
313
  const current = store.get(id)
@@ -316,21 +322,20 @@ export class SubagentScheduler {
316
322
  // AgentManager's promise resolves either way (its .catch returns ""), so we
317
323
  // can't infer success/failure from the promise — read record.status instead.
318
324
  // Terminal states: completed/steered = success; error/aborted/stopped = error.
319
- if (record?.promise) {
320
- record.promise
321
- .then(() => {
322
- const r = manager.getRecord(agentId)
323
- const failed =
324
- r?.status === "error" ||
325
- r?.status === "aborted" ||
326
- r?.status === "stopped"
327
- finalize(failed ? "error" : "success")
328
- })
329
- .catch(() => finalize("error"))
330
- } else {
331
- // Spawn returned without a promise (defensive — bypassQueue path always sets one).
332
- finalize("success")
333
- }
325
+ // awaitStartup first: with isolation: "worktree" the run promise only exists
326
+ // once the repo copy is made, and a failed copy rejects here.
327
+ manager
328
+ .awaitStartup(agentId)
329
+ .then(() => manager.getRecord(agentId)?.promise)
330
+ .then(() => {
331
+ const r = manager.getRecord(agentId)
332
+ const failed =
333
+ r?.status === "error" ||
334
+ r?.status === "aborted" ||
335
+ r?.status === "stopped"
336
+ finalize(failed ? "error" : "success")
337
+ })
338
+ .catch(() => finalize("error"))
334
339
  }
335
340
 
336
341
  private emit(event: ScheduleChangeEvent): void {
package/src/settings.ts CHANGED
@@ -6,11 +6,36 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
6
6
  import { dirname, join } from "node:path"
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent"
8
8
  import { NO_FALLBACK } from "./agent-types.js"
9
- import type { JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js"
9
+ import type {
10
+ AgentMentionMode,
11
+ JoinMode,
12
+ ViewerMarkdownMode,
13
+ WidgetMode,
14
+ } from "./types.js"
10
15
 
11
16
  export interface SubagentsSettings {
12
17
  maxConcurrent?: number
13
- /** Max concurrent blocking agents. 0 = unlimited (default). */
18
+ /**
19
+ * Max concurrent FOREGROUND (blocking) agents — `0` = unlimited, the default,
20
+ * which preserves the behaviour that has always applied: nothing bounded
21
+ * foreground work, and pi dispatches a message's tool calls through
22
+ * `Promise.all`, so an unqualified fan-out of blocking `Agent` calls runs all
23
+ * at once. Set it to bound that (#253 — on local models, parallel agents
24
+ * thrash the prompt cache).
25
+ *
26
+ * Deliberately independent of `maxConcurrent` rather than folded into it: a
27
+ * foreground agent blocks the parent anyway, so charging it to the background
28
+ * pool would let a saturated pool starve the main session of work it could
29
+ * have done itself.
30
+ *
31
+ * Bounds only spawns a caller is blocking on inline. Nested children are
32
+ * exempt — their parent is blocked awaiting them, so queueing a child behind
33
+ * its own parent would deadlock — and so are detached spawns from
34
+ * cross-extension RPC or `@handle` mentions, which block nobody and are
35
+ * documented to start immediately. Foreground `resume` is also outside the
36
+ * pool: it reuses an existing session and never reaches the spawn path, so
37
+ * several blocking resumes in one message can still exceed the limit.
38
+ */
14
39
  maxConcurrentForeground?: number
15
40
  /**
16
41
  * 0 = unlimited — the extension's single source of truth for that convention:
@@ -70,8 +95,14 @@ export interface SubagentsSettings {
70
95
  scopeModels?: boolean
71
96
  /**
72
97
  * When true, an unreadable or unparseable agent `.md` aborts extension load
73
- * instead of being skipped with a warning. This applies only during startup;
74
- * later per-call reloads remain tolerant. Defaults to false.
98
+ * instead of being skipped with a warning — pi exits, naming the file.
99
+ *
100
+ * Startup only, by design. Mid-session reloads (one per `Agent` call) keep
101
+ * warning: a bad edit at 3pm should not kill the session on the next
102
+ * unrelated spawn, where the failure would look disconnected from its cause.
103
+ * For a checked-in `.pi/agents/`, failing at startup is the point — the
104
+ * alternative is running a *different* agent than the file names.
105
+ * Defaults to false.
75
106
  */
76
107
  strictAgentFiles?: boolean
77
108
  /**
@@ -98,6 +129,34 @@ export interface SubagentsSettings {
98
129
  * the list never registers and the global key handler never captures input.
99
130
  */
100
131
  fleetView?: boolean
132
+ /**
133
+ * Whether `@handle message` typed at the prompt is routed to that subagent
134
+ * instead of the main model, and whether `@` offers running agents alongside
135
+ * pi's file completion. Defaults to `model`. Applied live.
136
+ *
137
+ * - `model`: mentioning an agent that is not running asks the main model to
138
+ * spawn it with the `Agent` tool, Claude Code's behaviour. Costs a turn,
139
+ * and the model writes the agent's prompt rather than your text being it.
140
+ * - `direct`: that agent is started here instead, with the typed message as
141
+ * its prompt and no main-model turn spent.
142
+ * - `off`: the input hook falls straight through and the stacked
143
+ * autocomplete provider delegates everything back to pi's built-in one.
144
+ *
145
+ * Messaging a running agent and resuming a finished one are direct in both
146
+ * `model` and `direct`. The legacy booleans are still accepted: `true` reads
147
+ * as `model`, `false` as `off`.
148
+ */
149
+ agentMentions?: AgentMentionMode
150
+ /**
151
+ * Whether subagents persist their pi session by default, so `@handle` can
152
+ * reopen an agent's conversation long after its in-memory record is gone.
153
+ * Defaults to `true`. Per-agent `persist_session:` frontmatter overrides it
154
+ * in both directions. Turning it off restores the previous behaviour, where
155
+ * a handle stops resolving roughly ten minutes after the agent finishes and
156
+ * mentioning it starts a fresh run instead. Persisted sessions also appear
157
+ * nested under the spawning session in pi's `/resume`.
158
+ */
159
+ rememberAgents?: boolean
101
160
  /**
102
161
  * Display mode for the persistent above-editor agent widget:
103
162
  * - `all`: show every agent (foreground + background).
@@ -143,6 +202,27 @@ export interface SubagentsSettings {
143
202
  * scheduler and the unvalidated cross-extension RPC path.
144
203
  */
145
204
  worktreeIsolation?: boolean
205
+ /**
206
+ * Master switch for scripted workflows. Defaults to `true`.
207
+ *
208
+ * Off is not a soft hide: the `SubagentWorkflow` tool is never registered, so
209
+ * the model is not told it exists and cannot call it, the `/agents`
210
+ * Workflows entry is hidden, and `--subagents-workflow-file` is refused.
211
+ *
212
+ * Absent is not the same as `true`. Unset means *auto*: on, but yielding to
213
+ * another extension that already offers a workflow tool, because two
214
+ * orchestrators in one tool spec is a worse default than none — the model
215
+ * has to guess which to call, and pays for both descriptions to find out.
216
+ * Setting it explicitly pins the answer in both directions: `true` keeps
217
+ * ours whatever else is loaded, `false` is off regardless. See
218
+ * `resolveWorkflowCollisions` in index.ts.
219
+ *
220
+ * Read once at extension init, before registration, so flipping it in
221
+ * `/agents → Settings` takes effect on the next pi session — the same
222
+ * contract `schedulingEnabled` has, and for the same reason: a tool spec is
223
+ * fixed once pi has it.
224
+ */
225
+ workflowsEnabled?: boolean
146
226
  /**
147
227
  * Hard ceiling on nested subagent delegation, counted from the main session:
148
228
  * main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
@@ -202,9 +282,30 @@ export interface SubagentsSettings {
202
282
  * what the parent session counts.
203
283
  */
204
284
  showCost?: boolean
205
- /** Show effective model and thinking level in running widget rows. */
285
+
286
+ /**
287
+ * Whether the widget's running rows name the model driving each agent and the
288
+ * thinking level it is running at.
289
+ *
290
+ * Off by default, unlike the tool result and the conversation viewer, which
291
+ * show the pair unconditionally: those have a line to themselves, while the
292
+ * widget row already carries the description, turns, tool uses, tokens and
293
+ * elapsed time, and every character it gains is one the description loses on a
294
+ * narrow terminal.
295
+ */
206
296
  showModel?: boolean
207
- /** Markdown scope in the conversation viewer. Defaults to assistant. */
297
+ /**
298
+ * How much of the conversation viewer's transcript renders as Markdown.
299
+ * Defaults to `assistant`. Applied live — the viewer's `m` key cycles this
300
+ * same setting, so a choice made in the overlay persists like one made in
301
+ * `/agents → Settings`.
302
+ *
303
+ * Scoped rather than all-or-nothing because the two kinds of content have
304
+ * different contracts: assistant text is authored as Markdown, while a tool
305
+ * result is whatever bytes the tool produced. Rendering the latter as
306
+ * Markdown is lossy in ways that look like the tool misbehaved — see
307
+ * `ViewerMarkdownMode` for the specific rewrites — so `all` is opt-in.
308
+ */
208
309
  viewerMarkdown?: ViewerMarkdownMode
209
310
  }
210
311
 
@@ -224,9 +325,12 @@ export interface SettingsAppliers {
224
325
  setDisableDefaultAgents: (b: boolean) => void
225
326
  setToolDescriptionMode: (mode: ToolDescriptionMode) => void
226
327
  setFleetView: (b: boolean) => void
328
+ setAgentMentions: (mode: AgentMentionMode) => void
329
+ setRememberAgents: (b: boolean) => void
227
330
  setWidgetMode: (mode: WidgetMode) => void
228
331
  setOutputTranscript: (b: boolean) => void
229
332
  setWorktreeIsolation: (b: boolean) => void
333
+ setWorkflowsEnabled: (b: boolean) => void
230
334
  setMaxSubagentDepth: (n: number) => void
231
335
  setFallbackSubagent: (v: string | undefined) => void
232
336
  setReportUsage: (b: boolean) => void
@@ -252,6 +356,8 @@ const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>([
252
356
  ])
253
357
  const VALID_VIEWER_MARKDOWN_MODES: ReadonlySet<string> =
254
358
  new Set<ViewerMarkdownMode>(["off", "assistant", "all"])
359
+ const VALID_AGENT_MENTION_MODES: ReadonlySet<string> =
360
+ new Set<AgentMentionMode>(["model", "direct", "off"])
255
361
 
256
362
  // Sanity ceilings — prevent hand-edited configs from asking for values that
257
363
  // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
@@ -273,6 +379,8 @@ function sanitize(raw: unknown): SubagentsSettings {
273
379
  ) {
274
380
  out.maxConcurrent = r.maxConcurrent as number
275
381
  }
382
+ // Floor 0, not 1 like maxConcurrent above: 0 is the documented "unlimited"
383
+ // value and the default, so dropping it would silently be unrepresentable.
276
384
  if (
277
385
  Number.isInteger(r.maxConcurrentForeground) &&
278
386
  (r.maxConcurrentForeground as number) >= 0 &&
@@ -331,6 +439,19 @@ function sanitize(raw: unknown): SubagentsSettings {
331
439
  if (typeof r.fleetView === "boolean") {
332
440
  out.fleetView = r.fleetView
333
441
  }
442
+ // Was a boolean before the `model` mode existed. A hand-written or
443
+ // previously-written `true` means "on", which is now the default `model`.
444
+ if (typeof r.agentMentions === "boolean") {
445
+ out.agentMentions = r.agentMentions ? "model" : "off"
446
+ } else if (
447
+ typeof r.agentMentions === "string" &&
448
+ VALID_AGENT_MENTION_MODES.has(r.agentMentions)
449
+ ) {
450
+ out.agentMentions = r.agentMentions as AgentMentionMode
451
+ }
452
+ if (typeof r.rememberAgents === "boolean") {
453
+ out.rememberAgents = r.rememberAgents
454
+ }
334
455
  if (
335
456
  typeof r.widgetMode === "string" &&
336
457
  VALID_WIDGET_MODES.has(r.widgetMode)
@@ -358,6 +479,9 @@ function sanitize(raw: unknown): SubagentsSettings {
358
479
  ) {
359
480
  out.viewerMarkdown = r.viewerMarkdown as ViewerMarkdownMode
360
481
  }
482
+ if (typeof r.workflowsEnabled === "boolean") {
483
+ out.workflowsEnabled = r.workflowsEnabled
484
+ }
361
485
  if (r.fallbackSubagent === false) {
362
486
  // The only non-string spelling worth accepting: a boolean would otherwise be
363
487
  // dropped, silently leaving the PERMISSIVE default in place. Every string is
@@ -434,8 +558,9 @@ export function applySettings(
434
558
  ): void {
435
559
  if (typeof s.maxConcurrent === "number")
436
560
  appliers.setMaxConcurrent(s.maxConcurrent)
437
- if (typeof s.maxConcurrentForeground === "number")
561
+ if (typeof s.maxConcurrentForeground === "number") {
438
562
  appliers.setMaxConcurrentForeground(s.maxConcurrentForeground)
563
+ }
439
564
  if (typeof s.defaultMaxTurns === "number")
440
565
  appliers.setDefaultMaxTurns(s.defaultMaxTurns)
441
566
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns)
@@ -456,6 +581,9 @@ export function applySettings(
456
581
  if (s.toolDescriptionMode)
457
582
  appliers.setToolDescriptionMode(s.toolDescriptionMode)
458
583
  if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView)
584
+ if (s.agentMentions) appliers.setAgentMentions(s.agentMentions)
585
+ if (typeof s.rememberAgents === "boolean")
586
+ appliers.setRememberAgents(s.rememberAgents)
459
587
  if (s.widgetMode) appliers.setWidgetMode(s.widgetMode)
460
588
  if (typeof s.outputTranscript === "boolean")
461
589
  appliers.setOutputTranscript(s.outputTranscript)
@@ -465,6 +593,8 @@ export function applySettings(
465
593
  if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost)
466
594
  if (typeof s.showModel === "boolean") appliers.setShowModel(s.showModel)
467
595
  if (s.viewerMarkdown) appliers.setViewerMarkdown(s.viewerMarkdown)
596
+ if (typeof s.workflowsEnabled === "boolean")
597
+ appliers.setWorkflowsEnabled(s.workflowsEnabled)
468
598
  }
469
599
 
470
600
  /**
@@ -0,0 +1,136 @@
1
+ /**
2
+ * structured-output.ts — the synthetic tool behind `agent(prompt, { schema })`.
3
+ *
4
+ * A workflow script that passes a `schema` wants an *object* back, not prose it
5
+ * has to parse. Claude Code does this by giving the child a `StructuredOutput`
6
+ * tool whose input schema is the caller's schema, so the provider fills the
7
+ * fields, and returning the validated payload as the agent's result.
8
+ *
9
+ * We do the same, with one gap named up front: Claude Code *forces* the call,
10
+ * and we cannot. `toolChoice` exists in pi-ai's provider layer but is not
11
+ * plumbed through `AgentSession`, so an extension has no way to require a
12
+ * particular tool. What we have instead is three softer pressures —
13
+ *
14
+ * 1. `constrainedSampling`, so providers that support it hold the payload to
15
+ * the schema at sampling time;
16
+ * 2. the tool's description, snippet and guideline, which say the answer must
17
+ * come through this call;
18
+ * 3. validation here, answering a bad payload with `isError` so the model
19
+ * sees what was wrong and calls again inside the same run.
20
+ *
21
+ * — and, when all three fail, one more prompt from `runAgent`. See
22
+ * {@link structuredRetryPrompt}.
23
+ *
24
+ * The name matches Claude Code's exactly, so a ported prompt that mentions
25
+ * `StructuredOutput` is still telling the truth.
26
+ */
27
+
28
+ import {
29
+ defineTool,
30
+ type ToolDefinition,
31
+ } from "@earendil-works/pi-coding-agent"
32
+ import type { CompiledSchema } from "./workflow/json-schema.js"
33
+
34
+ /**
35
+ * Deliberately NOT added to `SUBAGENT_TOOL_NAMES`: that list becomes
36
+ * `EXCLUDED_TOOL_NAMES`, which is exactly the denial this tool has to avoid.
37
+ * Nor to `BUILTIN_TOOL_NAMES` — it is ours to inject, never a name a user may
38
+ * ask for in an agent's `tools:` frontmatter.
39
+ */
40
+ export const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput"
41
+
42
+ /** What the child produced, filled in as the tool is called. */
43
+ export interface StructuredCapture {
44
+ /** The last payload that validated, canonicalised. Absent until one does. */
45
+ json?: string
46
+ /** Why the most recent attempt was rejected, for the retry prompt. */
47
+ lastError?: string
48
+ /** Whether the tool was called at all — "never tried" reads differently. */
49
+ called: boolean
50
+ }
51
+
52
+ export function createStructuredCapture(): StructuredCapture {
53
+ return { called: false }
54
+ }
55
+
56
+ /**
57
+ * Build the tool for one child.
58
+ *
59
+ * `capture` is the box the caller reads afterwards. It is passed in rather than
60
+ * returned so `runAgent` owns its lifetime and can consult it on every exit
61
+ * path, including the ones where the tool was never reached.
62
+ */
63
+ export function createStructuredOutputTool(
64
+ compiled: CompiledSchema,
65
+ capture: StructuredCapture,
66
+ ): ToolDefinition {
67
+ return defineTool({
68
+ name: STRUCTURED_OUTPUT_TOOL_NAME,
69
+ label: "Structured Output",
70
+ description:
71
+ "Report your final answer. Call this exactly once, with the complete result, and put everything the " +
72
+ "caller needs inside the arguments — text written outside this call is discarded. If a call is " +
73
+ "rejected for not matching the schema, fix the reported fields and call it again.",
74
+ promptSnippet: "Report your final answer as structured data",
75
+ promptGuidelines: [
76
+ "Your final answer MUST be reported by calling StructuredOutput. Prose outside that call is discarded.",
77
+ ],
78
+ // The caller's schema *is* the tool's input schema, verbatim — that is what
79
+ // makes the provider fill the fields. pi types this as TypeBox's `TSchema`,
80
+ // which v1 defines as an open interface, so a plain JSON Schema satisfies
81
+ // it without a cast at runtime or a conversion at author time.
82
+ parameters: compiled.schema as never,
83
+ // "prefer", not "require": a provider that cannot constrain sampling should
84
+ // fall through to validation-and-retry rather than fail the call outright.
85
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
86
+ // Models occasionally send the whole payload as one JSON string instead of
87
+ // an object. Recovering that costs nothing and saves a whole retry.
88
+ prepareArguments: (args: unknown) => {
89
+ if (typeof args !== "string") return args as never
90
+ try {
91
+ return JSON.parse(args) as never
92
+ } catch {
93
+ return args as never
94
+ }
95
+ },
96
+ execute: async (_toolCallId, params) => {
97
+ capture.called = true
98
+ const verdict = compiled.check(params)
99
+ if (verdict !== true) {
100
+ capture.lastError = verdict
101
+ // `isError` puts the reason in front of the model as a tool result, so
102
+ // it can correct itself inside this same run. This is where most
103
+ // mismatches are resolved; the prompt-level retry is the backstop.
104
+ return {
105
+ content: [
106
+ {
107
+ type: "text",
108
+ text: `StructuredOutput did not match the required schema:\n${verdict}\nCall it again with a corrected value.`,
109
+ },
110
+ ],
111
+ isError: true,
112
+ details: {},
113
+ }
114
+ }
115
+ // Last valid call wins: a model that calls twice meant the second one.
116
+ capture.json = JSON.stringify(params)
117
+ capture.lastError = undefined
118
+ return { content: [{ type: "text", text: "Recorded." }], details: {} }
119
+ },
120
+ }) as ToolDefinition
121
+ }
122
+
123
+ /**
124
+ * The one extra prompt sent when a run ended with nothing captured.
125
+ *
126
+ * Distinguishes "never called it" from "called it wrongly" — the two need
127
+ * different corrections, and telling a model it got the shape wrong when it
128
+ * never answered at all sends it looking for a mistake it did not make.
129
+ */
130
+ export function structuredRetryPrompt(capture: StructuredCapture): string {
131
+ const reason =
132
+ capture.called && capture.lastError !== undefined
133
+ ? `Your last ${STRUCTURED_OUTPUT_TOOL_NAME} call did not match the required schema: ${capture.lastError}`
134
+ : `You did not call ${STRUCTURED_OUTPUT_TOOL_NAME}, so your answer was not recorded.`
135
+ return `${reason}\n\nCall ${STRUCTURED_OUTPUT_TOOL_NAME} now with your complete final answer. Do not reply with prose.`
136
+ }
package/src/types.ts CHANGED
@@ -105,12 +105,75 @@ export type JoinMode = "async" | "group" | "smart"
105
105
  */
106
106
  export type WidgetMode = "all" | "background" | "off"
107
107
 
108
- /** How much of the conversation viewer renders as Markdown. */
108
+ /**
109
+ * How much of the conversation viewer's transcript is rendered as Markdown.
110
+ * - `off`: every line wraps as literal text, as it did before the mode existed.
111
+ * - `assistant`: assistant text renders as Markdown; tool results stay verbatim
112
+ * and dim. The default, because assistant text *is* Markdown by contract
113
+ * while a tool result is arbitrary bytes — a Markdown pass over a log or a
114
+ * diff eats `#` from shell comments, swallows a `---` line into a setext
115
+ * heading, re-fences indented output and redraws `| a | b |` as a table.
116
+ * (Ordered-list renumbering is the one such rewrite actively suppressed —
117
+ * see `MARKDOWN_OPTIONS` — because it silently changes data, not layout.)
118
+ * - `all`: tool results render as Markdown too, for tools that genuinely emit
119
+ * it (#210's `ctx_execute`), accepting the rewrites above on ones that don't.
120
+ */
109
121
  export type ViewerMarkdownMode = "off" | "assistant" | "all"
110
122
 
123
+ /**
124
+ * How `@handle message` starts an agent that is not already running.
125
+ * - `model`: inject Claude Code's `agent_mention` reminder and let the main
126
+ * model spawn it with the `Agent` tool, which is what Claude Code does.
127
+ * - `direct`: spawn it here, immediately, with the typed message as its prompt
128
+ * and no main-model turn spent.
129
+ * - `off`: `@` means only "attach a file" again.
130
+ *
131
+ * Messaging a running agent and resuming a finished one are direct in every
132
+ * mode — Claude Code only differs from us on the *new* invocation.
133
+ */
134
+ export type AgentMentionMode = "model" | "direct" | "off"
135
+
136
+ /**
137
+ * What survives a record's eviction so `@handle` keeps working. The live record
138
+ * is discarded after ~10 minutes, but the pi session it wrote is still on disk,
139
+ * and this is the little that is needed to find and describe it again.
140
+ */
141
+ export interface AgentTombstone {
142
+ handle: string
143
+ alias?: string
144
+ id: string
145
+ type: SubagentType
146
+ description: string
147
+ /** Always set — a record with no session file is never tombstoned. */
148
+ sessionFile: string
149
+ completedAt: number
150
+ }
151
+
152
+ /**
153
+ * What `@handle` resolved to: an agent still in memory, or the remains of one
154
+ * whose conversation can be reopened from disk.
155
+ */
156
+ export type MentionResolution =
157
+ | { kind: "live"; record: AgentRecord }
158
+ | { kind: "tombstone"; entry: AgentTombstone }
159
+
111
160
  export interface AgentRecord {
112
161
  id: string
113
162
  type: SubagentType
163
+ /**
164
+ * Typeable name for the `@handle message` prompt mention, derived from the
165
+ * agent type and numbered when siblings collide (`explore`, `explore-2`).
166
+ * Top-level agents only — nested children are hidden from every top-level
167
+ * surface, so nothing can address them.
168
+ */
169
+ handle?: string
170
+ /**
171
+ * A second, memorable handle from the spawner's `name` (`@auth-audit`), drawn
172
+ * from the same namespace as `handle` so the two can never collide. Purely
173
+ * additive: `handle` is assigned regardless, so a named agent stays reachable
174
+ * by its type and `@explore` never comes to mean "start another one".
175
+ */
176
+ alias?: string
114
177
  description: string
115
178
  status:
116
179
  | "queued"
@@ -128,9 +191,21 @@ export interface AgentRecord {
128
191
  session?: AgentSession
129
192
  abortController?: AbortController
130
193
  promise?: Promise<string>
131
- /** Whether a caller is awaiting this agent inline. */
194
+ /**
195
+ * A caller is awaiting this agent inline (`spawnAndWait`) — what
196
+ * `maxConcurrentForeground` bounds. Distinct from `isBackground === false`,
197
+ * which says only that the agent has an inline result surface: a detached
198
+ * cross-extension RPC spawn is foreground by that measure and yet blocks
199
+ * nobody, so it takes no slot.
200
+ */
132
201
  blocking?: boolean
133
- /** Resolves when a queued blocking agent starts or is removed from the queue. */
202
+ /**
203
+ * Present only while the record is "queued": resolves when it leaves the
204
+ * queue, started or aborted. `spawnAndWait` waits on this because a queued
205
+ * record has no `promise` yet. Always resolves, never rejects — a rejection
206
+ * would escape into the caller's tool `execute` and take down pi's whole
207
+ * Promise.all tool batch.
208
+ */
134
209
  startGate?: Promise<void>
135
210
  groupId?: string
136
211
  joinMode?: JoinMode
@@ -141,11 +216,23 @@ export interface AgentRecord {
141
216
  /** Worktree info if the agent is running in an isolated worktree. */
142
217
  worktree?: { path: string; branch: string; baseSha: string; workPath: string }
143
218
  /** Worktree cleanup result after agent completion. */
144
- worktreeResult?: { hasChanges: boolean; branch?: string }
219
+ worktreeResult?: {
220
+ hasChanges: boolean
221
+ branch?: string
222
+ path?: string
223
+ error?: string
224
+ }
145
225
  /** The tool_use_id from the original Agent tool call. */
146
226
  toolCallId?: string
147
227
  /** Path to the streaming output transcript file. */
148
228
  outputFile?: string
229
+ /**
230
+ * The agent's pi session file, when it was persisted (`persist_session`, or
231
+ * the `rememberAgents` default). Captured so a mention can reopen the
232
+ * conversation after the record itself has been evicted; undefined for an
233
+ * in-memory session, which leaves nothing to reopen.
234
+ */
235
+ sessionFile?: string
149
236
  /** Cleanup function for the output file stream subscription. */
150
237
  outputCleanup?: () => void
151
238
  /**
@@ -171,8 +258,27 @@ export interface AgentRecord {
171
258
  invocation?: AgentInvocation
172
259
  /** Nesting depth: top-level subagent = 1. */
173
260
  depth?: number
261
+ /**
262
+ * The validated `StructuredOutput` payload, as canonical JSON.
263
+ *
264
+ * Set only when the spawn asked for a schema. Separate from `result` because
265
+ * `result` is prose for a reader — previewed in the widget, written to the
266
+ * transcript, and appended to with the worktree branch note — and JSON that
267
+ * has been appended to no longer parses.
268
+ */
269
+ structuredJson?: string
270
+ /** Whether the child needed the extra structured-output prompt. */
271
+ structuredRetried?: boolean
174
272
  /** Parent agent ID for ownership-scoped nested controls. */
175
273
  parentAgentId?: string
274
+ /**
275
+ * The workflow run that owns this child, when a workflow spawned it.
276
+ *
277
+ * Owned the same way a nested child is owned by its parent: filtered out of
278
+ * every top-level surface, and outside the `maxConcurrent` pool. See
279
+ * `isTopLevelAgent`.
280
+ */
281
+ workflowId?: string
176
282
  /** Effective inherited nesting cap for this branch. */
177
283
  maxSubagentDepth?: number
178
284
  /**
@@ -183,17 +289,29 @@ export interface AgentRecord {
183
289
  rootSessionId?: string
184
290
  }
185
291
 
186
- /** Effective session level, including pi's display-only `off` value. */
292
+ /**
293
+ * What a session reports as its level: pi's `ThinkingLevel` plus the `"off"` a
294
+ * model with thinking disabled reports. Display-only — spawning still takes a
295
+ * `ThinkingLevel`, so this widening cannot leak into an invocation.
296
+ */
187
297
  export type EffectiveThinkingLevel = ThinkingLevel | "off"
188
298
 
189
299
  export interface AgentInvocation {
190
- /** Short model label for tight UI rows. */
300
+ /** Short display name for tight rows, e.g. "haiku 4.5". Always set once known. */
191
301
  modelName?: string
192
- /** Canonical provider/model id. */
302
+ /** Canonical `provider/id`, for surfaces with room to disambiguate providers. */
193
303
  modelId?: string
304
+ /** The level actually in effect, once a session exists to report one. */
194
305
  thinking?: EffectiveThinkingLevel
195
- /** Requested values retained only when the run used something else. */
306
+ /**
307
+ * What the caller asked for, kept only when they did not get it — pi clamped
308
+ * the level to the model's capabilities, or an agent file's frontmatter
309
+ * outranked the parameter (#182). The snapshot exists to answer "did the spawn
310
+ * honor my instructions?" (#62), which it cannot do if the request is lost, so
311
+ * neither `requested*` field is overwritten once set.
312
+ */
196
313
  requestedThinking?: EffectiveThinkingLevel
314
+ /** The caller's `model` parameter, as written, when an agent file's pin won. */
197
315
  requestedModel?: string
198
316
  maxTurns?: number
199
317
  isolated?: boolean