@esso0428/pi-subagents 0.17.5 → 0.17.7

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 (263) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/README.md +1 -1
  4. package/dist/abortable.d.ts +13 -0
  5. package/dist/abortable.d.ts.map +1 -0
  6. package/dist/abortable.js +43 -0
  7. package/dist/abortable.js.map +1 -0
  8. package/dist/agent-color.d.ts +36 -0
  9. package/dist/agent-color.d.ts.map +1 -0
  10. package/dist/agent-color.js +124 -0
  11. package/dist/agent-color.js.map +1 -0
  12. package/dist/agent-file-toggle.d.ts +126 -0
  13. package/dist/agent-file-toggle.d.ts.map +1 -0
  14. package/dist/agent-file-toggle.js +259 -0
  15. package/dist/agent-file-toggle.js.map +1 -0
  16. package/dist/agent-history.d.ts +4 -0
  17. package/dist/agent-history.d.ts.map +1 -1
  18. package/dist/agent-history.js +47 -1
  19. package/dist/agent-history.js.map +1 -1
  20. package/dist/agent-manager.d.ts +370 -56
  21. package/dist/agent-manager.d.ts.map +1 -1
  22. package/dist/agent-manager.js +1123 -409
  23. package/dist/agent-manager.js.map +1 -1
  24. package/dist/agent-runner.d.ts +100 -10
  25. package/dist/agent-runner.d.ts.map +1 -1
  26. package/dist/agent-runner.js +166 -21
  27. package/dist/agent-runner.js.map +1 -1
  28. package/dist/agent-types.d.ts +57 -5
  29. package/dist/agent-types.d.ts.map +1 -1
  30. package/dist/agent-types.js +164 -32
  31. package/dist/agent-types.js.map +1 -1
  32. package/dist/child-context.d.ts +3 -0
  33. package/dist/child-context.d.ts.map +1 -0
  34. package/dist/child-context.js +13 -0
  35. package/dist/child-context.js.map +1 -0
  36. package/dist/cross-extension-rpc.d.ts +23 -3
  37. package/dist/cross-extension-rpc.d.ts.map +1 -1
  38. package/dist/cross-extension-rpc.js +79 -17
  39. package/dist/cross-extension-rpc.js.map +1 -1
  40. package/dist/custom-agents.d.ts +38 -1
  41. package/dist/custom-agents.d.ts.map +1 -1
  42. package/dist/custom-agents.js +164 -12
  43. package/dist/custom-agents.js.map +1 -1
  44. package/dist/index.d.ts +34 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1908 -495
  47. package/dist/index.js.map +1 -1
  48. package/dist/invocation-config.d.ts +87 -2
  49. package/dist/invocation-config.d.ts.map +1 -1
  50. package/dist/invocation-config.js +71 -3
  51. package/dist/invocation-config.js.map +1 -1
  52. package/dist/mention-clone.d.ts +88 -0
  53. package/dist/mention-clone.d.ts.map +1 -0
  54. package/dist/mention-clone.js +154 -0
  55. package/dist/mention-clone.js.map +1 -0
  56. package/dist/mention.d.ts +82 -0
  57. package/dist/mention.d.ts.map +1 -0
  58. package/dist/mention.js +132 -0
  59. package/dist/mention.js.map +1 -0
  60. package/dist/model-resolver.d.ts +17 -0
  61. package/dist/model-resolver.d.ts.map +1 -1
  62. package/dist/model-resolver.js +15 -0
  63. package/dist/model-resolver.js.map +1 -1
  64. package/dist/model-scope.d.ts +50 -0
  65. package/dist/model-scope.d.ts.map +1 -0
  66. package/dist/model-scope.js +49 -0
  67. package/dist/model-scope.js.map +1 -0
  68. package/dist/nested-tools.d.ts +57 -0
  69. package/dist/nested-tools.d.ts.map +1 -0
  70. package/dist/nested-tools.js +301 -0
  71. package/dist/nested-tools.js.map +1 -0
  72. package/dist/output-file.d.ts +22 -3
  73. package/dist/output-file.d.ts.map +1 -1
  74. package/dist/output-file.js +58 -7
  75. package/dist/output-file.js.map +1 -1
  76. package/dist/prompts.d.ts +23 -0
  77. package/dist/prompts.d.ts.map +1 -1
  78. package/dist/prompts.js +20 -2
  79. package/dist/prompts.js.map +1 -1
  80. package/dist/schedule.d.ts.map +1 -1
  81. package/dist/schedule.js +36 -15
  82. package/dist/schedule.js.map +1 -1
  83. package/dist/settings.d.ts +228 -2
  84. package/dist/settings.d.ts.map +1 -1
  85. package/dist/settings.js +94 -0
  86. package/dist/settings.js.map +1 -1
  87. package/dist/status-note.d.ts +49 -1
  88. package/dist/status-note.d.ts.map +1 -1
  89. package/dist/status-note.js +62 -1
  90. package/dist/status-note.js.map +1 -1
  91. package/dist/structured-output.d.ts +62 -0
  92. package/dist/structured-output.d.ts.map +1 -0
  93. package/dist/structured-output.js +113 -0
  94. package/dist/structured-output.js.map +1 -0
  95. package/dist/types.d.ts +176 -10
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/ui/agent-mention.d.ts +83 -0
  98. package/dist/ui/agent-mention.d.ts.map +1 -0
  99. package/dist/ui/agent-mention.js +188 -0
  100. package/dist/ui/agent-mention.js.map +1 -0
  101. package/dist/ui/agent-widget.d.ts +96 -75
  102. package/dist/ui/agent-widget.d.ts.map +1 -1
  103. package/dist/ui/agent-widget.js +397 -420
  104. package/dist/ui/agent-widget.js.map +1 -1
  105. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  106. package/dist/ui/conversation-blocks.js +6 -0
  107. package/dist/ui/conversation-blocks.js.map +1 -1
  108. package/dist/ui/conversation-timeline.d.ts +10 -2
  109. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  110. package/dist/ui/conversation-timeline.js +130 -23
  111. package/dist/ui/conversation-timeline.js.map +1 -1
  112. package/dist/ui/conversation-viewer.d.ts +20 -5
  113. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  114. package/dist/ui/conversation-viewer.js +274 -73
  115. package/dist/ui/conversation-viewer.js.map +1 -1
  116. package/dist/ui/fleet-list.d.ts +198 -0
  117. package/dist/ui/fleet-list.d.ts.map +1 -0
  118. package/dist/ui/fleet-list.js +487 -0
  119. package/dist/ui/fleet-list.js.map +1 -0
  120. package/dist/ui/schedule-menu.d.ts.map +1 -1
  121. package/dist/ui/schedule-menu.js +6 -7
  122. package/dist/ui/schedule-menu.js.map +1 -1
  123. package/dist/ui/select-item.d.ts +28 -0
  124. package/dist/ui/select-item.d.ts.map +1 -0
  125. package/dist/ui/select-item.js +35 -0
  126. package/dist/ui/select-item.js.map +1 -0
  127. package/dist/ui/workflow-card.d.ts +176 -0
  128. package/dist/ui/workflow-card.d.ts.map +1 -0
  129. package/dist/ui/workflow-card.js +333 -0
  130. package/dist/ui/workflow-card.js.map +1 -0
  131. package/dist/ui/workflow-dialog.d.ts +306 -0
  132. package/dist/ui/workflow-dialog.d.ts.map +1 -0
  133. package/dist/ui/workflow-dialog.js +844 -0
  134. package/dist/ui/workflow-dialog.js.map +1 -0
  135. package/dist/ui/workflow-menu.d.ts +61 -0
  136. package/dist/ui/workflow-menu.d.ts.map +1 -0
  137. package/dist/ui/workflow-menu.js +148 -0
  138. package/dist/ui/workflow-menu.js.map +1 -0
  139. package/dist/usage.d.ts +86 -1
  140. package/dist/usage.d.ts.map +1 -1
  141. package/dist/usage.js +72 -1
  142. package/dist/usage.js.map +1 -1
  143. package/dist/workflow/collisions.d.ts +96 -0
  144. package/dist/workflow/collisions.d.ts.map +1 -0
  145. package/dist/workflow/collisions.js +89 -0
  146. package/dist/workflow/collisions.js.map +1 -0
  147. package/dist/workflow/entry.d.ts +33 -0
  148. package/dist/workflow/entry.d.ts.map +1 -0
  149. package/dist/workflow/entry.js +30 -0
  150. package/dist/workflow/entry.js.map +1 -0
  151. package/dist/workflow/host.d.ts +63 -0
  152. package/dist/workflow/host.d.ts.map +1 -0
  153. package/dist/workflow/host.js +363 -0
  154. package/dist/workflow/host.js.map +1 -0
  155. package/dist/workflow/journal.d.ts +98 -0
  156. package/dist/workflow/journal.d.ts.map +1 -0
  157. package/dist/workflow/journal.js +121 -0
  158. package/dist/workflow/journal.js.map +1 -0
  159. package/dist/workflow/json-schema.d.ts +52 -0
  160. package/dist/workflow/json-schema.d.ts.map +1 -0
  161. package/dist/workflow/json-schema.js +112 -0
  162. package/dist/workflow/json-schema.js.map +1 -0
  163. package/dist/workflow/meta.d.ts +68 -0
  164. package/dist/workflow/meta.d.ts.map +1 -0
  165. package/dist/workflow/meta.js +318 -0
  166. package/dist/workflow/meta.js.map +1 -0
  167. package/dist/workflow/progress.d.ts +225 -0
  168. package/dist/workflow/progress.d.ts.map +1 -0
  169. package/dist/workflow/progress.js +362 -0
  170. package/dist/workflow/progress.js.map +1 -0
  171. package/dist/workflow/runtime.d.ts +335 -0
  172. package/dist/workflow/runtime.d.ts.map +1 -0
  173. package/dist/workflow/runtime.js +831 -0
  174. package/dist/workflow/runtime.js.map +1 -0
  175. package/dist/workflow/saved.d.ts +91 -0
  176. package/dist/workflow/saved.d.ts.map +1 -0
  177. package/dist/workflow/saved.js +204 -0
  178. package/dist/workflow/saved.js.map +1 -0
  179. package/dist/workflow/task.d.ts +137 -0
  180. package/dist/workflow/task.d.ts.map +1 -0
  181. package/dist/workflow/task.js +208 -0
  182. package/dist/workflow/task.js.map +1 -0
  183. package/dist/workflow/tool-description.d.ts +39 -0
  184. package/dist/workflow/tool-description.d.ts.map +1 -0
  185. package/dist/workflow/tool-description.js +200 -0
  186. package/dist/workflow/tool-description.js.map +1 -0
  187. package/dist/workflow/worker-source.d.ts +48 -0
  188. package/dist/workflow/worker-source.d.ts.map +1 -0
  189. package/dist/workflow/worker-source.js +779 -0
  190. package/dist/workflow/worker-source.js.map +1 -0
  191. package/dist/worktree.d.ts +10 -3
  192. package/dist/worktree.d.ts.map +1 -1
  193. package/dist/worktree.js +58 -54
  194. package/dist/worktree.js.map +1 -1
  195. package/dist/xml.d.ts +11 -0
  196. package/dist/xml.d.ts.map +1 -0
  197. package/dist/xml.js +13 -0
  198. package/dist/xml.js.map +1 -0
  199. package/docs/rpc.md +183 -0
  200. package/docs/superpowers/plans/2026-09-30-conversation-viewer-scrollbar.md +216 -0
  201. package/docs/superpowers/plans/2026-09-30-upstream-event-workflow-partial-history.md +195 -0
  202. package/docs/superpowers/specs/2026-09-30-conversation-viewer-scrollbar-design.md +82 -0
  203. package/docs/superpowers/specs/2026-09-30-upstream-event-workflow-partial-history-design.md +49 -0
  204. package/docs/workflows.md +437 -0
  205. package/examples/agent-tool-description.md +7 -7
  206. package/examples/workflows/compose.js +51 -0
  207. package/examples/workflows/fan-out-audit.js +47 -0
  208. package/examples/workflows/gated-fix.js +60 -0
  209. package/examples/workflows/lib/count-child.js +27 -0
  210. package/examples/workflows/review-panel.js +63 -0
  211. package/examples/workflows/structured-findings.js +78 -0
  212. package/package.json +1 -1
  213. package/src/abortable.ts +43 -0
  214. package/src/agent-color.ts +161 -0
  215. package/src/agent-file-toggle.ts +269 -0
  216. package/src/agent-history.ts +54 -2
  217. package/src/agent-manager.ts +1263 -402
  218. package/src/agent-runner.ts +251 -27
  219. package/src/agent-types.ts +188 -32
  220. package/src/child-context.ts +15 -0
  221. package/src/cross-extension-rpc.ts +96 -20
  222. package/src/custom-agents.ts +170 -13
  223. package/src/index.ts +2024 -537
  224. package/src/invocation-config.ts +118 -3
  225. package/src/mention-clone.ts +196 -0
  226. package/src/mention.ts +141 -0
  227. package/src/model-resolver.ts +18 -0
  228. package/src/model-scope.ts +70 -0
  229. package/src/nested-tools.ts +424 -0
  230. package/src/output-file.ts +61 -6
  231. package/src/prompts.ts +45 -2
  232. package/src/schedule.ts +35 -14
  233. package/src/settings.ts +312 -2
  234. package/src/status-note.ts +66 -1
  235. package/src/structured-output.ts +130 -0
  236. package/src/types.ts +177 -10
  237. package/src/ui/agent-mention.ts +216 -0
  238. package/src/ui/agent-widget.ts +389 -441
  239. package/src/ui/conversation-blocks.ts +6 -0
  240. package/src/ui/conversation-timeline.ts +139 -25
  241. package/src/ui/conversation-viewer.ts +284 -69
  242. package/src/ui/fleet-list.ts +558 -0
  243. package/src/ui/schedule-menu.ts +9 -8
  244. package/src/ui/select-item.ts +45 -0
  245. package/src/ui/workflow-card.ts +470 -0
  246. package/src/ui/workflow-dialog.ts +1115 -0
  247. package/src/ui/workflow-menu.ts +193 -0
  248. package/src/usage.ts +109 -2
  249. package/src/workflow/collisions.ts +123 -0
  250. package/src/workflow/entry.ts +47 -0
  251. package/src/workflow/host.ts +403 -0
  252. package/src/workflow/journal.ts +164 -0
  253. package/src/workflow/json-schema.ts +128 -0
  254. package/src/workflow/meta.ts +325 -0
  255. package/src/workflow/progress.ts +550 -0
  256. package/src/workflow/runtime.ts +1219 -0
  257. package/src/workflow/saved.ts +217 -0
  258. package/src/workflow/task.ts +302 -0
  259. package/src/workflow/tool-description.ts +200 -0
  260. package/src/workflow/worker-source.ts +781 -0
  261. package/src/worktree.ts +69 -55
  262. package/src/xml.ts +13 -0
  263. package/vitest.config.ts +0 -18
@@ -0,0 +1,403 @@
1
+ /**
2
+ * host.ts — binds a workflow run to the real `AgentManager`.
3
+ *
4
+ * `runtime.ts` deliberately knows nothing about this extension: its only seam is
5
+ * the injected {@link WorkflowHost}, which is what keeps the runtime's tests
6
+ * free of sessions, models and git. This file is the other half of that seam —
7
+ * everything the script can reach through `agent()`, `resume` and `gate` ends up
8
+ * here, and nowhere else.
9
+ *
10
+ * Four mappings carry most of the weight:
11
+ *
12
+ * - **ids.** The runtime hands out its own `wf-agent-N` handles before
13
+ * anything spawns, because it needs a stable progress-entry identity. The
14
+ * manager issues a different id when the child actually starts. `records`
15
+ * is the translation, and it is kept for the whole run rather than cleared
16
+ * on completion: `resume` reaches back to a child that has already
17
+ * finished.
18
+ * - **agent type and model.** Resolved through `resolveSpawnType` and
19
+ * `getAgentConfig` — the same dispatch the `Agent` tool uses — so a
20
+ * workflow and a tool call disagree about nothing.
21
+ * - **failure.** A strict worktree-isolation failure throws out of
22
+ * `spawnAndWait`; the script must see that as an agent that failed
23
+ * (`{ok: false}` → `null`), not as an unhandled rejection that takes the
24
+ * run down.
25
+ * - **when a `gate` runs.** For an isolated child it cannot wait until the
26
+ * spawn resolves: the manager commits the worktree to a branch and deletes
27
+ * the copy inside the child's own settle, so by then the only tree left to
28
+ * run `npm test` in is the main one — which would report on code the child
29
+ * never wrote. So the gate runs from `onBeforeWorktreeCleanup`, inside that
30
+ * settle, and the verdict travels back on the spawn result. `runGate` still
31
+ * exists for a child that had no worktree; the runtime uses whichever of
32
+ * the two happened, never both.
33
+ */
34
+
35
+ import { existsSync } from "node:fs";
36
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
37
+ import type { AgentManager } from "../agent-manager.js";
38
+ import { getAgentConfig, resolveSpawnType } from "../agent-types.js";
39
+ import { resolveModel } from "../model-resolver.js";
40
+ import { checkModelScope } from "../model-scope.js";
41
+ import type { AgentRecord, ThinkingLevel } from "../types.js";
42
+ import { getLifetimeTotal } from "../usage.js";
43
+ import type { WorkflowGateResult, WorkflowHost, WorkflowSpawnResult } from "./runtime.js";
44
+ import { resolveWorkflowSource } from "./saved.js";
45
+
46
+ /**
47
+ * Wall-clock bound on a `gate` command. Generous — a gate is routinely a test
48
+ * suite — but not unbounded: `pi.exec` reports a timeout as `killed`, and a
49
+ * gate that hangs forever would wedge the agent slot it is holding.
50
+ */
51
+ export const DEFAULT_GATE_TIMEOUT_MS = 10 * 60_000;
52
+
53
+ export interface WorkflowHostOptions {
54
+ pi: ExtensionAPI;
55
+ ctx: ExtensionContext;
56
+ manager: AgentManager;
57
+ /** The run's abort signal, so killing the workflow kills its children. */
58
+ signal?: AbortSignal;
59
+ /** Groups child transcripts under the parent session. */
60
+ rootSessionId?: string;
61
+ /**
62
+ * The run id every child is stamped with.
63
+ *
64
+ * What makes them the workflow's rather than the session's: stamped children
65
+ * are filtered out of the fleet list, the widget, the `/agents` menus and
66
+ * `@handle` resolution, and they take no `maxConcurrent` slot. The run
67
+ * reports for them, and it has its own concurrency cap.
68
+ */
69
+ workflowId?: string;
70
+ gateTimeoutMs?: number;
71
+ }
72
+
73
+ /**
74
+ * Where the child worked, when that directory still exists.
75
+ *
76
+ * The guard is not defensive padding. `cleanupWorktree` commits the child's
77
+ * changes to a branch and *removes* the copy before `spawnAndWait` resolves, so
78
+ * an isolated child's worktree is normally already gone by the time a result is
79
+ * built. That is exactly why a gate cannot wait until here — it runs from
80
+ * `onBeforeWorktreeCleanup` instead — and why this reports nothing rather than
81
+ * a path that no longer exists: handing a stale path to a command would fail
82
+ * every gated worktree agent with a spawn error instead of a test result.
83
+ */
84
+ function childCwd(record: AgentRecord): string | undefined {
85
+ // `path`, not `workPath`: a workflow spawn never passes a cwd, so the manager
86
+ // runs the child at the copied repo's root.
87
+ const path = record.worktree?.path;
88
+ return path !== undefined && existsSync(path) ? path : undefined;
89
+ }
90
+
91
+ /**
92
+ * Whether the child itself succeeded — the same condition {@link toSpawnResult}
93
+ * turns into `ok`, read from the live record so the pre-cleanup hook can tell a
94
+ * finished child from a failed one before the result exists.
95
+ */
96
+ function succeeded(record: AgentRecord | undefined): boolean {
97
+ return record?.status === "completed" || record?.status === "steered";
98
+ }
99
+
100
+ /** Translate a settled record into what the script sees. */
101
+ /**
102
+ * The effective-configuration half of a record, in the runtime's pi-free shape.
103
+ *
104
+ * `AgentRecord.invocation` is the one authoritative place for it (#168); this
105
+ * only renames the fields across the boundary. Returns undefined until the
106
+ * child's session has reported a model, which is when any of it is knowable.
107
+ */
108
+ function resolvedInfo(record: AgentRecord | undefined) {
109
+ const invocation = record?.invocation;
110
+ if (invocation?.modelName === undefined) return undefined;
111
+ return {
112
+ modelName: invocation.modelName,
113
+ modelId: invocation.modelId,
114
+ thinking: invocation.thinking,
115
+ requestedThinking: invocation.requestedThinking,
116
+ requestedModel: invocation.requestedModel,
117
+ };
118
+ }
119
+
120
+ function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
121
+ const tokens = getLifetimeTotal(record.lifetimeUsage);
122
+ // Reported separately from `tokens`, which is the lifetime total. The script's
123
+ // `budget` counts *output* tokens, as Claude Code's does — billing the input
124
+ // and cache reads a fan-out re-sends would over-report it by an order of
125
+ // magnitude and make the documented guards useless.
126
+ const outputTokens = record.lifetimeUsage?.output ?? 0;
127
+ const cwd = childCwd(record);
128
+ const common = {
129
+ ...(tokens > 0 ? { tokens } : {}),
130
+ ...(outputTokens > 0 ? { outputTokens } : {}),
131
+ ...(record.toolUses > 0 ? { toolCalls: record.toolUses } : {}),
132
+ ...(cwd !== undefined ? { cwd } : {}),
133
+ };
134
+
135
+ if (succeeded(record)) {
136
+ return {
137
+ ...common,
138
+ ok: true,
139
+ // The schema'd payload when there is one: `result` is prose, and for a
140
+ // worktree child it has had the branch note appended, so it would not
141
+ // parse. A child asked for a schema that produced none never reaches
142
+ // here — `runAgent` reports that through `failure`.
143
+ text: record.structuredJson ?? record.result ?? "",
144
+ ...(record.structuredRetried ? { structuredRetried: true } : {}),
145
+ };
146
+ }
147
+ // "stopped" is someone reaching in and stopping this child — /agents, the
148
+ // fleet list, a workflow abort. That is the same thing the workflows dialog's
149
+ // skip action means, so it renders as skipped rather than failed.
150
+ if (record.status === "stopped") {
151
+ return { ...common, ok: false, skipped: true, error: record.error ?? "Stopped." };
152
+ }
153
+ return { ...common, ok: false, error: record.error ?? `Agent ${record.status}.` };
154
+ }
155
+
156
+ /** Shell used to run a `gate` command, mirroring how a user would type it. */
157
+ const GATE_SHELL: readonly [string, string] =
158
+ process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"];
159
+
160
+ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
161
+ const { pi, ctx, manager } = deps;
162
+ /** Runtime agent id → the manager record it spawned. Never pruned mid-run. */
163
+ const records = new Map<string, string>();
164
+ /**
165
+ * scopeModels warnings already toasted, so a fan-out that pins one
166
+ * out-of-scope agent file raises one notification rather than one per child.
167
+ * Kept for the whole run: the same message is the same warning at agent 200
168
+ * as it was at agent 1.
169
+ */
170
+ const warnedScopeMessages = new Set<string>();
171
+
172
+ /**
173
+ * Run a gate command in `cwd`. The only place a gate is executed — `runGate`
174
+ * and the pre-cleanup hook both come through here — so "gate passed" is
175
+ * decided from one behaviour, whichever route the command took.
176
+ */
177
+ async function executeGate(command: string, cwd: string): Promise<WorkflowGateResult> {
178
+ const result = await pi.exec(GATE_SHELL[0], [GATE_SHELL[1], command], {
179
+ cwd,
180
+ timeout: deps.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
181
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
182
+ });
183
+ const output = [result.stdout, result.stderr]
184
+ .map(stream => stream.trim())
185
+ .filter(Boolean)
186
+ .join("\n");
187
+ // `pi.exec` reports a timeout as `killed` with exit code 0, so the code
188
+ // alone would read a killed gate as a passing one.
189
+ if (result.killed) {
190
+ return { ok: false, output: output || `Gate command timed out: ${command}` };
191
+ }
192
+ return { ok: result.code === 0, output };
193
+ }
194
+
195
+ return {
196
+ async spawnAgent(request) {
197
+ const dispatch = resolveSpawnType(request.agentType);
198
+ if (!dispatch.ok) return { ok: false, error: dispatch.message };
199
+
200
+ // Same precedence as the Agent tool: the caller's model wins, the agent
201
+ // definition's is next, and the parent's is the floor. A model the script
202
+ // named and we cannot resolve is an error; one the definition named falls
203
+ // back to the parent silently, because the script never asked for it.
204
+ let model = ctx.model;
205
+ const config = getAgentConfig(dispatch.type);
206
+ const modelInput = request.model ?? config?.model;
207
+ if (modelInput !== undefined) {
208
+ const resolved = resolveModel(modelInput, ctx.modelRegistry);
209
+ if (typeof resolved === "string") {
210
+ if (request.model !== undefined) return { ok: false, error: resolved };
211
+ } else {
212
+ model = resolved;
213
+ }
214
+ }
215
+
216
+ // Same scopeModels policy as the Agent tool and the nested delegation
217
+ // tools: a script's `agent({ model })` is a runtime LLM choice, and the
218
+ // script is written by the model, so it must not reach a model the user's
219
+ // enabledModels list excludes. `callerSupplied` keys off `request.model`
220
+ // and NOT `modelInput` — the latter has already absorbed the agent file's
221
+ // own `model:`, which is user-authored config and so earns the
222
+ // warn-and-proceed branch rather than a refusal.
223
+ const scopeVerdict = checkModelScope({
224
+ model,
225
+ cwd: ctx.cwd,
226
+ modelRegistry: ctx.modelRegistry,
227
+ callerSupplied: request.model !== undefined,
228
+ agentLabel: config?.displayName ?? dispatch.type,
229
+ modelInput,
230
+ });
231
+ // This agent's failure, not the run's — the same shape a bad agent type
232
+ // takes above. The script sees `null` and its siblings carry on, which is
233
+ // the difference between one refused model and a discarded fan-out.
234
+ if (scopeVerdict.kind === "error") return { ok: false, error: scopeVerdict.message };
235
+ if (scopeVerdict.kind === "warn" && !warnedScopeMessages.has(scopeVerdict.message)) {
236
+ warnedScopeMessages.add(scopeVerdict.message);
237
+ ctx.ui.notify(scopeVerdict.message, "warning");
238
+ }
239
+
240
+ /**
241
+ * The gate's verdict, set only if the hook below actually ran the command.
242
+ * Its presence is what stops the runtime running the gate a second time,
243
+ * so it is set on the failure route too — a gate we tried and could not
244
+ * complete is a failed gate, never an un-run one that then re-runs
245
+ * against the wrong tree.
246
+ */
247
+ let gate: WorkflowGateResult | undefined;
248
+ let spawnedId: string | undefined;
249
+
250
+ /**
251
+ * Hand the child's EFFECTIVE configuration back to the run.
252
+ *
253
+ * `AgentRecord.invocation` is the one authoritative place for it (#168),
254
+ * filled by the manager the moment the child's session exists — which is
255
+ * before this fires, so it is populated by the time we read it. Reading it
256
+ * rather than re-deriving "the session, else the request" is the whole
257
+ * point of that field existing.
258
+ */
259
+ let sessionReady = false;
260
+ const reportResolved = () => {
261
+ // Called from BOTH the session hook and the spawn hook, because their
262
+ // order is not guaranteed: the manager fires `onSpawned` after
263
+ // `runAgent` returns, but `runAgent` decides when its own
264
+ // `onSessionCreated` fires — synchronously, for a stub. Each fires once,
265
+ // so the first call finds a half missing and returns, and the second is
266
+ // the one that reports.
267
+ if (!sessionReady || spawnedId === undefined) return;
268
+ const info = resolvedInfo(manager.getRecord(spawnedId));
269
+ if (info !== undefined) request.onResolved?.(info);
270
+ };
271
+ const command = request.gate;
272
+ /**
273
+ * Verify the child's work while its worktree still exists.
274
+ *
275
+ * The manager destroys that copy inside the child's own settle, so this
276
+ * is the last (and only) moment at which `npm test` can mean "the code
277
+ * this child just wrote" rather than "whatever is in the main tree".
278
+ */
279
+ const onBeforeWorktreeCleanup =
280
+ command === undefined
281
+ ? undefined
282
+ : async (worktreePath: string): Promise<void> => {
283
+ // A failed child's gate is never consulted — the runtime reports
284
+ // the child's own failure — so running it would be pure cost.
285
+ if (spawnedId === undefined || !succeeded(manager.getRecord(spawnedId))) return;
286
+ try {
287
+ gate = await executeGate(command, worktreePath);
288
+ } catch (error) {
289
+ gate = { ok: false, output: error instanceof Error ? error.message : String(error) };
290
+ }
291
+ };
292
+
293
+ try {
294
+ const { record } = await manager.spawnAndWait(
295
+ pi,
296
+ ctx,
297
+ dispatch.type,
298
+ request.prompt,
299
+ {
300
+ description: request.label,
301
+ // The stamp is what keeps this child out of the session's
302
+ // `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
303
+ // bounds how many of its agents run at once, and counting them
304
+ // twice would let one fan-out starve everything else the user is
305
+ // doing. No `bypassQueue` needed: an agent outside the pool is
306
+ // never queued behind it.
307
+ ...(deps.workflowId !== undefined ? { workflowId: deps.workflowId } : {}),
308
+ ...(model !== undefined ? { model } : {}),
309
+ // Validated worker-side against the same list pi accepts, so the
310
+ // cast asserts what the boundary has already checked. Left unset,
311
+ // the agent definition's `thinking` (then the parent's) still wins —
312
+ // same precedence as `model` above.
313
+ ...(request.effort !== undefined ? { thinkingLevel: request.effort as ThinkingLevel } : {}),
314
+ // Seeded with the REQUEST, not the outcome. The manager overwrites
315
+ // the effective half at session creation; without a seed there is
316
+ // nothing for it to compare against, so a level pi clamped would be
317
+ // indistinguishable from one that was honoured.
318
+ //
319
+ // Only the level. #182's other half — a caller parameter an agent
320
+ // file outranked — cannot arise here: this path resolves
321
+ // `request.model ?? config?.model`, so the script always wins and
322
+ // therefore always got what it asked for. Seeding a `requestedModel`
323
+ // would describe a precedence this path does not have.
324
+ invocation: {
325
+ ...(request.effort !== undefined ? { thinking: request.effort as ThinkingLevel } : {}),
326
+ },
327
+ // Fires once the child's session exists, which is where the model
328
+ // and the clamped thinking level first become knowable.
329
+ onSessionCreated: () => { sessionReady = true; reportResolved(); },
330
+ ...(request.schema !== undefined ? { structuredOutput: request.schema } : {}),
331
+ ...(request.isolation !== undefined ? { isolation: request.isolation } : {}),
332
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
333
+ ...(deps.rootSessionId !== undefined ? { rootSessionId: deps.rootSessionId } : {}),
334
+ ...(onBeforeWorktreeCleanup !== undefined ? { onBeforeWorktreeCleanup } : {}),
335
+ },
336
+ id => {
337
+ spawnedId = id;
338
+ records.set(request.agentId, id);
339
+ // Ahead of `reportResolved`, and not folded into it: that one waits
340
+ // for the child's session so it can name the effective model, while
341
+ // the record id is known here and is what the inspector opens a
342
+ // conversation on. A child that fails before its session resolves
343
+ // would otherwise never be openable at all.
344
+ request.onResolved?.({ recordId: id });
345
+ reportResolved();
346
+ },
347
+ );
348
+ return { ...toSpawnResult(record), ...(gate !== undefined ? { gate } : {}) };
349
+ } catch (error) {
350
+ // Strict worktree isolation rejects out of `awaitStartup` — the child
351
+ // never ran. That is this agent's failure, not the run's: the script
352
+ // sees `null` and its siblings carry on.
353
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
354
+ }
355
+ },
356
+
357
+ abortAgent(agentId) {
358
+ const id = records.get(agentId);
359
+ // Nothing to abort before the manager has issued an id — the child is
360
+ // still in startup, and the run's own signal reaches it there.
361
+ if (id !== undefined) manager.abort(id);
362
+ },
363
+
364
+ async resumeAgent(agentId, prompt, onResolved) {
365
+ const id = records.get(agentId);
366
+ if (id === undefined) {
367
+ return { ok: false, error: `Cannot resume "${agentId}" — it never started.` };
368
+ }
369
+ const record = await manager.resume(id, prompt, deps.signal);
370
+ if (record === undefined) {
371
+ return {
372
+ ok: false,
373
+ error: `Agent ${id} has no session left to resume — records are dropped ten minutes after they finish.`,
374
+ };
375
+ }
376
+ // The resumed row is built from scratch, so it has to be told the same
377
+ // thing the first one was — the child's session already exists, so this is
378
+ // simply read back rather than waited for. The id goes first for the same
379
+ // reason it does on the spawn path: it is knowable even when the rest is
380
+ // not.
381
+ onResolved?.({ recordId: id });
382
+ const info = resolvedInfo(record);
383
+ if (info !== undefined) onResolved?.(info);
384
+ return toSpawnResult(record);
385
+ },
386
+
387
+ /**
388
+ * Resolve a nested `workflow()` reference. The runtime decides whether what
389
+ * comes back is a workflow; this only finds it.
390
+ */
391
+ loadWorkflow(ref) {
392
+ return resolveWorkflowSource(ref, ctx.cwd);
393
+ },
394
+
395
+ // Reached only for a gate the spawn did not already run — a child with no
396
+ // worktree of its own, or a host wired without the pre-cleanup hook.
397
+ async runGate(command, gate) {
398
+ // The child's worktree when it had one and it survived; otherwise the
399
+ // session's own directory, which is where a non-isolated child worked.
400
+ return await executeGate(command, gate.cwd ?? ctx.cwd);
401
+ },
402
+ };
403
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * journal.ts — the record a workflow run leaves so a later run can skip work.
3
+ *
4
+ * ## What resume actually buys
5
+ *
6
+ * The documented iteration loop is "edit the persisted script and re-run it".
7
+ * Without a journal that re-pays every agent from scratch, which for a 40-agent
8
+ * audit is the entire cost of the run — to change one line of the last stage.
9
+ * With one, the unchanged prefix comes back from disk and only the edit runs.
10
+ *
11
+ * ## Why a *prefix*, and not a lookup table
12
+ *
13
+ * Each entry is keyed by both its position in the run and a hash of everything
14
+ * that decides what that agent does. A replay walks positions in order and
15
+ * stops reusing at the first entry that does not match — every call from there
16
+ * on runs live. Reusing later matches out of order would be reusing a result
17
+ * produced under different upstream conditions: the same prompt at position 12
18
+ * of a *different* run is not the same work, because what fed it changed.
19
+ *
20
+ * A failed agent is journaled as a failure and never replayed as one. Resuming
21
+ * a run that died at agent 5 exists to retry agent 5, so the prefix ends there
22
+ * and 5 onwards run live — the alternative would make a failure permanent.
23
+ *
24
+ * ## Runs that use `agent({ resume })`
25
+ *
26
+ * Those are not replayed at all. A replayed agent is text from a file, not a
27
+ * live child, so there is no conversation in this run for a later `resume` to
28
+ * continue — and the id map that would find one belongs to the run that did
29
+ * the spawning. Rather than replay a prefix that strands the first `resume`
30
+ * call, a journal carrying one declines the whole cache and the run pays in
31
+ * full. Coarse on purpose: the alternative is tracking which label each entry
32
+ * ran under and capping the prefix below the earliest one that gets resumed,
33
+ * which is a second key concept for a case that costs one run.
34
+ *
35
+ * ## Ordering under concurrency
36
+ *
37
+ * Positions are assigned as calls arrive, and with `pipeline` that order
38
+ * depends on which agent finished first. A replay usually reproduces it, since
39
+ * cached calls answer in journal order, but it is not guaranteed. That is why
40
+ * the key is checked as well as the position: a run that interleaves
41
+ * differently loses cache hits, it never returns another agent's answer.
42
+ *
43
+ * The file is JSON Lines, appended as each agent settles, so a run that is
44
+ * killed mid-flight still leaves everything it had finished.
45
+ */
46
+
47
+ import { createHash } from "node:crypto";
48
+ import { appendFileSync, readFileSync } from "node:fs";
49
+
50
+ /** One settled agent call, as replayed. */
51
+ export interface WorkflowJournalEntry {
52
+ /** Position in the run — the same counter that names `wf-agent-N`. */
53
+ index: number;
54
+ /** Hash of the call's payload; a mismatch ends the replayable prefix. */
55
+ key: string;
56
+ /** Whether the agent succeeded. A failure ends the prefix on replay. */
57
+ ok: boolean;
58
+ /** The agent's answer, when it had one. */
59
+ text?: string;
60
+ /**
61
+ * Whether the call continued an earlier child (`agent({ resume })`).
62
+ *
63
+ * A replayed agent leaves no session behind in the run that replays it — the
64
+ * conversation belongs to the run that actually spawned it, and the host's
65
+ * id map is per-run — so a later `resume` would have nothing to continue.
66
+ * Recording it lets the next run decline to replay at all rather than fail
67
+ * partway through, which is why the flag is on the journal and not derived.
68
+ */
69
+ resumed?: true;
70
+ }
71
+
72
+ /**
73
+ * The fields that decide what an agent does.
74
+ *
75
+ * Deliberately not the whole payload: `phaseIndex` and `phaseTitle` move the
76
+ * row around in the progress tree without changing a single token the agent
77
+ * sees, so re-grouping phases should not throw away an hour of results.
78
+ */
79
+ export interface JournalKeyInput {
80
+ prompt: string;
81
+ label?: string;
82
+ model?: string;
83
+ agentType?: string;
84
+ effort?: string;
85
+ isolation?: string;
86
+ gate?: string;
87
+ resume?: string;
88
+ /** Serialized `agent({ schema })`, when the call asked for one. */
89
+ schema?: string;
90
+ }
91
+
92
+ /** Stable hash of a call's payload. Field order is fixed here, not by the caller. */
93
+ export function journalKey(input: JournalKeyInput): string {
94
+ const canonical = JSON.stringify([
95
+ input.prompt,
96
+ input.label ?? null,
97
+ input.model ?? null,
98
+ input.agentType ?? null,
99
+ input.effort ?? null,
100
+ input.isolation ?? null,
101
+ input.gate ?? null,
102
+ input.resume ?? null,
103
+ // Appended only when present, which looks like a hack and is not: adding a
104
+ // ninth slot unconditionally would change the canonical form of every entry
105
+ // and invalidate every journal already on disk. Conditional, a schema-less
106
+ // call keys exactly as it always did, and adding or changing a schema still
107
+ // produces a different key.
108
+ ...(input.schema !== undefined ? [input.schema] : []),
109
+ ]);
110
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
111
+ }
112
+
113
+ /**
114
+ * Read a journal file into position order.
115
+ *
116
+ * Never throws: a missing, truncated or hand-mangled journal means "nothing to
117
+ * replay", which costs tokens. Refusing to run would cost the whole run.
118
+ * A partial last line is normal — the file is appended to while agents settle.
119
+ */
120
+ export function readJournal(path: string): WorkflowJournalEntry[] {
121
+ let raw: string;
122
+ try {
123
+ raw = readFileSync(path, "utf-8");
124
+ } catch {
125
+ return [];
126
+ }
127
+
128
+ const entries: WorkflowJournalEntry[] = [];
129
+ for (const line of raw.split("\n")) {
130
+ if (line.trim() === "") continue;
131
+ try {
132
+ const parsed = JSON.parse(line) as unknown;
133
+ if (!isEntry(parsed)) continue;
134
+ entries.push(parsed);
135
+ } catch {
136
+ // A half-written final line, or someone editing the file. Skipping it
137
+ // keeps what came before, and a shorter prefix is still a useful one.
138
+ }
139
+ }
140
+ entries.sort((a, b) => a.index - b.index);
141
+ return entries;
142
+ }
143
+
144
+ /** Append one settled call. Failure to write is not failure to run. */
145
+ export function appendJournal(path: string, entry: WorkflowJournalEntry): void {
146
+ try {
147
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8");
148
+ } catch {
149
+ // A journal that cannot be written costs a future resume, nothing more.
150
+ }
151
+ }
152
+
153
+ function isEntry(value: unknown): value is WorkflowJournalEntry {
154
+ if (typeof value !== "object" || value === null) return false;
155
+ const entry = value as Record<string, unknown>;
156
+ return (
157
+ Number.isInteger(entry.index) &&
158
+ (entry.index as number) >= 0 &&
159
+ typeof entry.key === "string" &&
160
+ typeof entry.ok === "boolean" &&
161
+ (entry.text === undefined || typeof entry.text === "string") &&
162
+ (entry.resumed === undefined || entry.resumed === true)
163
+ );
164
+ }