@ymy88/pi-subagents 0.0.0-stage → 0.24.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 (196) hide show
  1. package/CHANGELOG.md +904 -0
  2. package/CONTRIBUTING.md +72 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1170 -2
  5. package/SECURITY.md +95 -0
  6. package/dist/abortable.d.ts +12 -0
  7. package/dist/abortable.js +42 -0
  8. package/dist/agent-color.d.ts +35 -0
  9. package/dist/agent-color.js +123 -0
  10. package/dist/agent-file-toggle.d.ts +133 -0
  11. package/dist/agent-file-toggle.js +287 -0
  12. package/dist/agent-manager.d.ts +552 -0
  13. package/dist/agent-manager.js +1466 -0
  14. package/dist/agent-record-entry.d.ts +96 -0
  15. package/dist/agent-record-entry.js +121 -0
  16. package/dist/agent-runner.d.ts +310 -0
  17. package/dist/agent-runner.js +1195 -0
  18. package/dist/agent-source-badge.d.ts +50 -0
  19. package/dist/agent-source-badge.js +74 -0
  20. package/dist/agent-types.d.ts +119 -0
  21. package/dist/agent-types.js +286 -0
  22. package/dist/child-context.d.ts +2 -0
  23. package/dist/child-context.js +12 -0
  24. package/dist/completion-outbox.d.ts +63 -0
  25. package/dist/completion-outbox.js +104 -0
  26. package/dist/context.d.ts +12 -0
  27. package/dist/context.js +56 -0
  28. package/dist/cross-extension-rpc.d.ts +72 -0
  29. package/dist/cross-extension-rpc.js +153 -0
  30. package/dist/custom-agents.d.ts +77 -0
  31. package/dist/custom-agents.js +396 -0
  32. package/dist/default-agents.d.ts +7 -0
  33. package/dist/default-agents.js +121 -0
  34. package/dist/enabled-models.d.ts +49 -0
  35. package/dist/enabled-models.js +145 -0
  36. package/dist/env.d.ts +6 -0
  37. package/dist/env.js +28 -0
  38. package/dist/group-join.d.ts +32 -0
  39. package/dist/group-join.js +116 -0
  40. package/dist/index.d.ts +50 -0
  41. package/dist/index.js +4111 -0
  42. package/dist/invocation-config.d.ts +108 -0
  43. package/dist/invocation-config.js +83 -0
  44. package/dist/memory.d.ts +53 -0
  45. package/dist/memory.js +165 -0
  46. package/dist/mention-clone.d.ts +79 -0
  47. package/dist/mention-clone.js +155 -0
  48. package/dist/mention.d.ts +81 -0
  49. package/dist/mention.js +131 -0
  50. package/dist/model-resolver.d.ts +36 -0
  51. package/dist/model-resolver.js +95 -0
  52. package/dist/model-scope.d.ts +49 -0
  53. package/dist/model-scope.js +48 -0
  54. package/dist/nested-tools.d.ts +57 -0
  55. package/dist/nested-tools.js +303 -0
  56. package/dist/output-file.d.ts +43 -0
  57. package/dist/output-file.js +153 -0
  58. package/dist/package-resources.d.ts +240 -0
  59. package/dist/package-resources.js +519 -0
  60. package/dist/prompts.d.ts +76 -0
  61. package/dist/prompts.js +121 -0
  62. package/dist/schedule-store.d.ts +38 -0
  63. package/dist/schedule-store.js +155 -0
  64. package/dist/schedule.d.ts +109 -0
  65. package/dist/schedule.js +359 -0
  66. package/dist/settings.d.ts +398 -0
  67. package/dist/settings.js +282 -0
  68. package/dist/skill-loader.d.ts +24 -0
  69. package/dist/skill-loader.js +93 -0
  70. package/dist/status-note.d.ts +61 -0
  71. package/dist/status-note.js +85 -0
  72. package/dist/structured-output.d.ts +61 -0
  73. package/dist/structured-output.js +112 -0
  74. package/dist/types.d.ts +391 -0
  75. package/dist/types.js +5 -0
  76. package/dist/ui/agent-mention.d.ts +82 -0
  77. package/dist/ui/agent-mention.js +187 -0
  78. package/dist/ui/agent-widget.d.ts +219 -0
  79. package/dist/ui/agent-widget.js +585 -0
  80. package/dist/ui/conversation-viewer.d.ts +177 -0
  81. package/dist/ui/conversation-viewer.js +905 -0
  82. package/dist/ui/fleet-list.d.ts +211 -0
  83. package/dist/ui/fleet-list.js +489 -0
  84. package/dist/ui/schedule-menu.d.ts +16 -0
  85. package/dist/ui/schedule-menu.js +94 -0
  86. package/dist/ui/select-item.d.ts +27 -0
  87. package/dist/ui/select-item.js +34 -0
  88. package/dist/ui/viewer-keys.d.ts +22 -0
  89. package/dist/ui/viewer-keys.js +18 -0
  90. package/dist/ui/workflow-card.d.ts +175 -0
  91. package/dist/ui/workflow-card.js +332 -0
  92. package/dist/ui/workflow-dialog.d.ts +305 -0
  93. package/dist/ui/workflow-dialog.js +843 -0
  94. package/dist/ui/workflow-menu.d.ts +60 -0
  95. package/dist/ui/workflow-menu.js +147 -0
  96. package/dist/usage.d.ts +135 -0
  97. package/dist/usage.js +120 -0
  98. package/dist/workflow/collisions.d.ts +95 -0
  99. package/dist/workflow/collisions.js +88 -0
  100. package/dist/workflow/entry.d.ts +32 -0
  101. package/dist/workflow/entry.js +29 -0
  102. package/dist/workflow/host.d.ts +62 -0
  103. package/dist/workflow/host.js +419 -0
  104. package/dist/workflow/journal.d.ts +97 -0
  105. package/dist/workflow/journal.js +120 -0
  106. package/dist/workflow/json-schema.d.ts +52 -0
  107. package/dist/workflow/json-schema.js +112 -0
  108. package/dist/workflow/meta.d.ts +67 -0
  109. package/dist/workflow/meta.js +317 -0
  110. package/dist/workflow/progress.d.ts +224 -0
  111. package/dist/workflow/progress.js +361 -0
  112. package/dist/workflow/runtime.d.ts +334 -0
  113. package/dist/workflow/runtime.js +830 -0
  114. package/dist/workflow/saved.d.ts +102 -0
  115. package/dist/workflow/saved.js +247 -0
  116. package/dist/workflow/task.d.ts +136 -0
  117. package/dist/workflow/task.js +207 -0
  118. package/dist/workflow/tool-description.d.ts +38 -0
  119. package/dist/workflow/tool-description.js +199 -0
  120. package/dist/workflow/worker-source.d.ts +47 -0
  121. package/dist/workflow/worker-source.js +778 -0
  122. package/dist/worktree.d.ts +52 -0
  123. package/dist/worktree.js +164 -0
  124. package/dist/xml.d.ts +10 -0
  125. package/dist/xml.js +12 -0
  126. package/docs/packages.md +211 -0
  127. package/docs/rpc.md +192 -0
  128. package/docs/workflows.md +447 -0
  129. package/examples/agent-tool-description.md +42 -0
  130. package/examples/workflows/compose.js +51 -0
  131. package/examples/workflows/fan-out-audit.js +47 -0
  132. package/examples/workflows/gated-fix.js +60 -0
  133. package/examples/workflows/lib/count-child.js +27 -0
  134. package/examples/workflows/review-panel.js +63 -0
  135. package/examples/workflows/structured-findings.js +78 -0
  136. package/package.json +74 -4
  137. package/src/abortable.ts +43 -0
  138. package/src/agent-color.ts +161 -0
  139. package/src/agent-file-toggle.ts +297 -0
  140. package/src/agent-manager.ts +1735 -0
  141. package/src/agent-record-entry.ts +153 -0
  142. package/src/agent-runner.ts +1468 -0
  143. package/src/agent-source-badge.ts +75 -0
  144. package/src/agent-types.ts +346 -0
  145. package/src/child-context.ts +15 -0
  146. package/src/completion-outbox.ts +116 -0
  147. package/src/context.ts +58 -0
  148. package/src/cross-extension-rpc.ts +221 -0
  149. package/src/custom-agents.ts +452 -0
  150. package/src/default-agents.ts +125 -0
  151. package/src/enabled-models.ts +180 -0
  152. package/src/env.ts +33 -0
  153. package/src/group-join.ts +141 -0
  154. package/src/index.ts +4433 -0
  155. package/src/invocation-config.ts +155 -0
  156. package/src/memory.ts +179 -0
  157. package/src/mention-clone.ts +195 -0
  158. package/src/mention.ts +141 -0
  159. package/src/model-resolver.ts +118 -0
  160. package/src/model-scope.ts +70 -0
  161. package/src/nested-tools.ts +426 -0
  162. package/src/output-file.ts +164 -0
  163. package/src/package-resources.ts +652 -0
  164. package/src/prompts.ts +171 -0
  165. package/src/schedule-store.ts +153 -0
  166. package/src/schedule.ts +386 -0
  167. package/src/settings.ts +650 -0
  168. package/src/skill-loader.ts +102 -0
  169. package/src/status-note.ts +90 -0
  170. package/src/structured-output.ts +130 -0
  171. package/src/types.ts +404 -0
  172. package/src/ui/agent-mention.ts +216 -0
  173. package/src/ui/agent-widget.ts +661 -0
  174. package/src/ui/conversation-viewer.ts +928 -0
  175. package/src/ui/fleet-list.ts +559 -0
  176. package/src/ui/schedule-menu.ts +105 -0
  177. package/src/ui/select-item.ts +45 -0
  178. package/src/ui/viewer-keys.ts +43 -0
  179. package/src/ui/workflow-card.ts +470 -0
  180. package/src/ui/workflow-dialog.ts +1115 -0
  181. package/src/ui/workflow-menu.ts +193 -0
  182. package/src/usage.ts +167 -0
  183. package/src/workflow/collisions.ts +123 -0
  184. package/src/workflow/entry.ts +47 -0
  185. package/src/workflow/host.ts +467 -0
  186. package/src/workflow/journal.ts +164 -0
  187. package/src/workflow/json-schema.ts +129 -0
  188. package/src/workflow/meta.ts +325 -0
  189. package/src/workflow/progress.ts +551 -0
  190. package/src/workflow/runtime.ts +1219 -0
  191. package/src/workflow/saved.ts +259 -0
  192. package/src/workflow/task.ts +302 -0
  193. package/src/workflow/tool-description.ts +200 -0
  194. package/src/workflow/worker-source.ts +781 -0
  195. package/src/worktree.ts +205 -0
  196. package/src/xml.ts +13 -0
@@ -0,0 +1,29 @@
1
+ /**
2
+ * entry.ts — what a finished workflow leaves behind in the session transcript.
3
+ *
4
+ * A workflow started from `--subagents-workflow-file` has no tool call to hang
5
+ * its result card on, so it appends a custom session entry instead. That entry
6
+ * has to survive a reload, which is why this is a plain-JSON snapshot rather
7
+ * than the live {@link WorkflowTask}: the task holds an `AbortController`, the
8
+ * script source and the run's control handle, none of which belongs in a
9
+ * session file.
10
+ *
11
+ * Deliberately free of any renderer import. The card this data renders through
12
+ * lives in `ui/workflow-card.ts` (`renderWorkflowEntryCard`), so the shape a
13
+ * session file stores does not depend on the code that draws it.
14
+ */
15
+ /** `customType` of the session entry a flag-launched workflow renders through. */
16
+ export const WORKFLOW_ENTRY_TYPE = "subagents:workflow";
17
+ /** Snapshot a settled task for {@link WORKFLOW_ENTRY_TYPE}. */
18
+ export function workflowEntryData(task) {
19
+ return {
20
+ name: task.workflowName ?? task.id,
21
+ status: task.status,
22
+ startTime: task.startTime,
23
+ endTime: task.endTime,
24
+ progress: task.workflowProgress,
25
+ agentCount: task.agentCount,
26
+ totalTokens: task.totalTokens,
27
+ ...(task.meta !== undefined ? { meta: task.meta } : {}),
28
+ };
29
+ }
@@ -0,0 +1,62 @@
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
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import type { AgentManager } from "../agent-manager.js";
36
+ import type { WorkflowHost } from "./runtime.js";
37
+ /**
38
+ * Wall-clock bound on a `gate` command. Generous — a gate is routinely a test
39
+ * suite — but not unbounded: `pi.exec` reports a timeout as `killed`, and a
40
+ * gate that hangs forever would wedge the agent slot it is holding.
41
+ */
42
+ export declare const DEFAULT_GATE_TIMEOUT_MS: number;
43
+ export interface WorkflowHostOptions {
44
+ pi: ExtensionAPI;
45
+ ctx: ExtensionContext;
46
+ manager: AgentManager;
47
+ /** The run's abort signal, so killing the workflow kills its children. */
48
+ signal?: AbortSignal;
49
+ /** Groups child transcripts under the parent session. */
50
+ rootSessionId?: string;
51
+ /**
52
+ * The run id every child is stamped with.
53
+ *
54
+ * What makes them the workflow's rather than the session's: stamped children
55
+ * are filtered out of the fleet list, the widget, the `/agents` menus and
56
+ * `@handle` resolution, and they take no `maxConcurrent` slot. The run
57
+ * reports for them, and it has its own concurrency cap.
58
+ */
59
+ workflowId?: string;
60
+ gateTimeoutMs?: number;
61
+ }
62
+ export declare function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost;
@@ -0,0 +1,419 @@
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
+ import { existsSync } from "node:fs";
35
+ import { reopenTarget, worktreeLost } from "../agent-record-entry.js";
36
+ import { getAgentConfig, resolveSpawnType } from "../agent-types.js";
37
+ import { resolveModel } from "../model-resolver.js";
38
+ import { checkModelScope } from "../model-scope.js";
39
+ import { getLifetimeTotal } from "../usage.js";
40
+ import { resolveWorkflowSource } from "./saved.js";
41
+ /**
42
+ * Wall-clock bound on a `gate` command. Generous — a gate is routinely a test
43
+ * suite — but not unbounded: `pi.exec` reports a timeout as `killed`, and a
44
+ * gate that hangs forever would wedge the agent slot it is holding.
45
+ */
46
+ export const DEFAULT_GATE_TIMEOUT_MS = 10 * 60_000;
47
+ /**
48
+ * Where the child worked, when that directory still exists.
49
+ *
50
+ * The guard is not defensive padding. `cleanupWorktree` commits the child's
51
+ * changes to a branch and *removes* the copy before `spawnAndWait` resolves, so
52
+ * an isolated child's worktree is normally already gone by the time a result is
53
+ * built. That is exactly why a gate cannot wait until here — it runs from
54
+ * `onBeforeWorktreeCleanup` instead — and why this reports nothing rather than
55
+ * a path that no longer exists: handing a stale path to a command would fail
56
+ * every gated worktree agent with a spawn error instead of a test result.
57
+ */
58
+ function childCwd(record) {
59
+ // `path`, not `workPath`: a workflow spawn never passes a cwd, so the manager
60
+ // runs the child at the copied repo's root.
61
+ const path = record.worktree?.path;
62
+ return path !== undefined && existsSync(path) ? path : undefined;
63
+ }
64
+ /**
65
+ * Whether the child itself succeeded — the same condition {@link toSpawnResult}
66
+ * turns into `ok`, read from the live record so the pre-cleanup hook can tell a
67
+ * finished child from a failed one before the result exists.
68
+ */
69
+ function succeeded(record) {
70
+ return record?.status === "completed" || record?.status === "steered";
71
+ }
72
+ /** Translate a settled record into what the script sees. */
73
+ /**
74
+ * The effective-configuration half of a record, in the runtime's pi-free shape.
75
+ *
76
+ * `AgentRecord.invocation` is the one authoritative place for it (#168); this
77
+ * only renames the fields across the boundary. Returns undefined until the
78
+ * child's session has reported a model, which is when any of it is knowable.
79
+ */
80
+ function resolvedInfo(record) {
81
+ const invocation = record?.invocation;
82
+ if (invocation?.modelName === undefined)
83
+ return undefined;
84
+ return {
85
+ modelName: invocation.modelName,
86
+ modelId: invocation.modelId,
87
+ thinking: invocation.thinking,
88
+ requestedThinking: invocation.requestedThinking,
89
+ requestedModel: invocation.requestedModel,
90
+ };
91
+ }
92
+ function toSpawnResult(record) {
93
+ const tokens = getLifetimeTotal(record.lifetimeUsage);
94
+ // Reported separately from `tokens`, which is the lifetime total. The script's
95
+ // `budget` counts *output* tokens, as Claude Code's does — billing the input
96
+ // and cache reads a fan-out re-sends would over-report it by an order of
97
+ // magnitude and make the documented guards useless.
98
+ const outputTokens = record.lifetimeUsage?.output ?? 0;
99
+ const cwd = childCwd(record);
100
+ const common = {
101
+ ...(tokens > 0 ? { tokens } : {}),
102
+ ...(outputTokens > 0 ? { outputTokens } : {}),
103
+ ...(record.toolUses > 0 ? { toolCalls: record.toolUses } : {}),
104
+ ...(cwd !== undefined ? { cwd } : {}),
105
+ };
106
+ if (succeeded(record)) {
107
+ return {
108
+ ...common,
109
+ ok: true,
110
+ // The schema'd payload when there is one: `result` is prose, and for a
111
+ // worktree child it has had the branch note appended, so it would not
112
+ // parse. A child asked for a schema that produced none never reaches
113
+ // here — `runAgent` reports that through `failure`.
114
+ text: record.structuredJson ?? record.result ?? "",
115
+ ...(record.structuredRetried ? { structuredRetried: true } : {}),
116
+ };
117
+ }
118
+ // "stopped" is someone reaching in and stopping this child — /agents, the
119
+ // fleet list, a workflow abort. That is the same thing the workflows dialog's
120
+ // skip action means, so it renders as skipped rather than failed.
121
+ if (record.status === "stopped") {
122
+ return { ...common, ok: false, skipped: true, error: record.error ?? "Stopped." };
123
+ }
124
+ return { ...common, ok: false, error: record.error ?? `Agent ${record.status}.` };
125
+ }
126
+ /** Shell used to run a `gate` command, mirroring how a user would type it. */
127
+ const GATE_SHELL = process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"];
128
+ export function createWorkflowHost(deps) {
129
+ const { pi, ctx, manager } = deps;
130
+ /** Runtime agent id → the manager record it spawned. Never pruned mid-run. */
131
+ const records = new Map();
132
+ /**
133
+ * Manager record id → what reopening that child needs once the manager has
134
+ * evicted it, ten minutes after it finished. A long review or a merge queue
135
+ * outlasts that easily, and `resume` must still reach the child (#385).
136
+ * Lives exactly as long as the run, like `records`, so memory stays bounded
137
+ * by the run itself; the manager keeps no tombstone for an owned child.
138
+ */
139
+ const settled = new Map();
140
+ const remember = (record) => {
141
+ settled.set(record.id, {
142
+ type: record.type,
143
+ description: record.description,
144
+ sessionFile: record.sessionFile,
145
+ worktreeLost: worktreeLost(record),
146
+ });
147
+ };
148
+ /**
149
+ * scopeModels warnings already toasted, so a fan-out that pins one
150
+ * out-of-scope agent file raises one notification rather than one per child.
151
+ * Kept for the whole run: the same message is the same warning at agent 200
152
+ * as it was at agent 1.
153
+ */
154
+ const warnedScopeMessages = new Set();
155
+ /**
156
+ * Run a gate command in `cwd`. The only place a gate is executed — `runGate`
157
+ * and the pre-cleanup hook both come through here — so "gate passed" is
158
+ * decided from one behaviour, whichever route the command took.
159
+ */
160
+ async function executeGate(command, cwd) {
161
+ const result = await pi.exec(GATE_SHELL[0], [GATE_SHELL[1], command], {
162
+ cwd,
163
+ timeout: deps.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
164
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
165
+ });
166
+ const output = [result.stdout, result.stderr]
167
+ .map(stream => stream.trim())
168
+ .filter(Boolean)
169
+ .join("\n");
170
+ // `pi.exec` reports a timeout as `killed` with exit code 0, so the code
171
+ // alone would read a killed gate as a passing one.
172
+ if (result.killed) {
173
+ return { ok: false, output: output || `Gate command timed out: ${command}` };
174
+ }
175
+ return { ok: result.code === 0, output };
176
+ }
177
+ /**
178
+ * Resume a child the manager has evicted: reopen its saved session under the
179
+ * same id, still owned by this run, as `Agent({ resume })` does for a
180
+ * top-level agent. The continuation runs under the agent type's current
181
+ * definition, like every reopen — the script's model, schema and isolation
182
+ * for the first run are not replayed.
183
+ */
184
+ async function reopenAgent(id, prompt, onResolved) {
185
+ const kept = settled.get(id);
186
+ if (kept === undefined)
187
+ return { ok: false, error: `Agent ${id} cannot be resumed — it never finished a run to resume from.` };
188
+ const reopen = reopenTarget(kept.type, kept.sessionFile, kept.worktreeLost);
189
+ if (!reopen.ok)
190
+ return { ok: false, error: `Agent ${id} cannot be resumed — ${reopen.reason}.` };
191
+ const config = getAgentConfig(reopen.type);
192
+ try {
193
+ const { record } = await manager.spawnAndWait(pi, ctx, reopen.type, prompt, {
194
+ description: kept.description,
195
+ resumeSessionFile: reopen.sessionFile,
196
+ reclaim: { id },
197
+ ...(config?.isolated !== undefined ? { isolated: config.isolated } : {}),
198
+ ...(deps.workflowId !== undefined ? { workflowId: deps.workflowId } : {}),
199
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
200
+ ...(deps.rootSessionId !== undefined ? { rootSessionId: deps.rootSessionId } : {}),
201
+ }, () => onResolved?.({ recordId: id }));
202
+ remember(record);
203
+ const info = resolvedInfo(record);
204
+ if (info !== undefined)
205
+ onResolved?.(info);
206
+ return toSpawnResult(record);
207
+ }
208
+ catch (error) {
209
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
210
+ }
211
+ }
212
+ return {
213
+ async spawnAgent(request) {
214
+ const dispatch = resolveSpawnType(request.agentType);
215
+ if (!dispatch.ok)
216
+ return { ok: false, error: dispatch.message };
217
+ // Same precedence as the Agent tool: the caller's model wins, the agent
218
+ // definition's is next, and the parent's is the floor. A model the script
219
+ // named and we cannot resolve is an error; one the definition named falls
220
+ // back to the parent silently, because the script never asked for it.
221
+ let model = ctx.model;
222
+ const config = getAgentConfig(dispatch.type);
223
+ const modelInput = request.model ?? config?.model;
224
+ if (modelInput !== undefined) {
225
+ const resolved = resolveModel(modelInput, ctx.modelRegistry);
226
+ if (typeof resolved === "string") {
227
+ if (request.model !== undefined)
228
+ return { ok: false, error: resolved };
229
+ }
230
+ else {
231
+ model = resolved;
232
+ }
233
+ }
234
+ // Same scopeModels policy as the Agent tool and the nested delegation
235
+ // tools: a script's `agent({ model })` is a runtime LLM choice, and the
236
+ // script is written by the model, so it must not reach a model the user's
237
+ // enabledModels list excludes. `callerSupplied` keys off `request.model`
238
+ // and NOT `modelInput` — the latter has already absorbed the agent file's
239
+ // own `model:`, which is user-authored config and so earns the
240
+ // warn-and-proceed branch rather than a refusal.
241
+ const scopeVerdict = checkModelScope({
242
+ model,
243
+ cwd: ctx.cwd,
244
+ modelRegistry: ctx.modelRegistry,
245
+ callerSupplied: request.model !== undefined,
246
+ agentLabel: config?.displayName ?? dispatch.type,
247
+ modelInput,
248
+ });
249
+ // This agent's failure, not the run's — the same shape a bad agent type
250
+ // takes above. The script sees `null` and its siblings carry on, which is
251
+ // the difference between one refused model and a discarded fan-out.
252
+ if (scopeVerdict.kind === "error")
253
+ return { ok: false, error: scopeVerdict.message };
254
+ if (scopeVerdict.kind === "warn" && !warnedScopeMessages.has(scopeVerdict.message)) {
255
+ warnedScopeMessages.add(scopeVerdict.message);
256
+ ctx.ui.notify(scopeVerdict.message, "warning");
257
+ }
258
+ /**
259
+ * The gate's verdict, set only if the hook below actually ran the command.
260
+ * Its presence is what stops the runtime running the gate a second time,
261
+ * so it is set on the failure route too — a gate we tried and could not
262
+ * complete is a failed gate, never an un-run one that then re-runs
263
+ * against the wrong tree.
264
+ */
265
+ let gate;
266
+ let spawnedId;
267
+ /**
268
+ * Hand the child's EFFECTIVE configuration back to the run.
269
+ *
270
+ * `AgentRecord.invocation` is the one authoritative place for it (#168),
271
+ * filled by the manager the moment the child's session exists — which is
272
+ * before this fires, so it is populated by the time we read it. Reading it
273
+ * rather than re-deriving "the session, else the request" is the whole
274
+ * point of that field existing.
275
+ */
276
+ let sessionReady = false;
277
+ const reportResolved = () => {
278
+ // Called from BOTH the session hook and the spawn hook, because their
279
+ // order is not guaranteed: the manager fires `onSpawned` after
280
+ // `runAgent` returns, but `runAgent` decides when its own
281
+ // `onSessionCreated` fires — synchronously, for a stub. Each fires once,
282
+ // so the first call finds a half missing and returns, and the second is
283
+ // the one that reports.
284
+ if (!sessionReady || spawnedId === undefined)
285
+ return;
286
+ const info = resolvedInfo(manager.getRecord(spawnedId));
287
+ if (info !== undefined)
288
+ request.onResolved?.(info);
289
+ };
290
+ const command = request.gate;
291
+ /**
292
+ * Verify the child's work while its worktree still exists.
293
+ *
294
+ * The manager destroys that copy inside the child's own settle, so this
295
+ * is the last (and only) moment at which `npm test` can mean "the code
296
+ * this child just wrote" rather than "whatever is in the main tree".
297
+ */
298
+ const onBeforeWorktreeCleanup = command === undefined
299
+ ? undefined
300
+ : async (worktreePath) => {
301
+ // A failed child's gate is never consulted — the runtime reports
302
+ // the child's own failure — so running it would be pure cost.
303
+ if (spawnedId === undefined || !succeeded(manager.getRecord(spawnedId)))
304
+ return;
305
+ try {
306
+ gate = await executeGate(command, worktreePath);
307
+ }
308
+ catch (error) {
309
+ gate = { ok: false, output: error instanceof Error ? error.message : String(error) };
310
+ }
311
+ };
312
+ try {
313
+ const { record } = await manager.spawnAndWait(pi, ctx, dispatch.type, request.prompt, {
314
+ description: request.label,
315
+ // Same as the Agent tool: `isolated` comes off the agent definition.
316
+ // Left out, a hermetic agent's script-spawned children loaded every
317
+ // host extension and picked up its MCP and web tools.
318
+ ...(config?.isolated !== undefined ? { isolated: config.isolated } : {}),
319
+ // The stamp is what keeps this child out of the session's
320
+ // `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
321
+ // bounds how many of its agents run at once, and counting them
322
+ // twice would let one fan-out starve everything else the user is
323
+ // doing. No `bypassQueue` needed: an agent outside the pool is
324
+ // never queued behind it.
325
+ ...(deps.workflowId !== undefined ? { workflowId: deps.workflowId } : {}),
326
+ ...(model !== undefined ? { model } : {}),
327
+ // Validated worker-side against the same list pi accepts, so the
328
+ // cast asserts what the boundary has already checked. Left unset,
329
+ // the agent definition's `thinking` (then the parent's) still wins —
330
+ // same precedence as `model` above.
331
+ ...(request.effort !== undefined ? { thinkingLevel: request.effort } : {}),
332
+ // Seeded with the REQUEST, not the outcome. The manager overwrites
333
+ // the effective half at session creation; without a seed there is
334
+ // nothing for it to compare against, so a level pi clamped would be
335
+ // indistinguishable from one that was honoured.
336
+ //
337
+ // Only the level. #182's other half — a caller parameter an agent
338
+ // file outranked — cannot arise here: this path resolves
339
+ // `request.model ?? config?.model`, so the script always wins and
340
+ // therefore always got what it asked for. Seeding a `requestedModel`
341
+ // would describe a precedence this path does not have.
342
+ invocation: {
343
+ ...(request.effort !== undefined ? { thinking: request.effort } : {}),
344
+ },
345
+ // Fires once the child's session exists, which is where the model
346
+ // and the clamped thinking level first become knowable.
347
+ onSessionCreated: () => { sessionReady = true; reportResolved(); },
348
+ ...(request.schema !== undefined ? { structuredOutput: request.schema } : {}),
349
+ ...(request.isolation !== undefined ? { isolation: request.isolation } : {}),
350
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
351
+ ...(deps.rootSessionId !== undefined ? { rootSessionId: deps.rootSessionId } : {}),
352
+ ...(onBeforeWorktreeCleanup !== undefined ? { onBeforeWorktreeCleanup } : {}),
353
+ }, id => {
354
+ spawnedId = id;
355
+ records.set(request.agentId, id);
356
+ // Ahead of `reportResolved`, and not folded into it: that one waits
357
+ // for the child's session so it can name the effective model, while
358
+ // the record id is known here and is what the inspector opens a
359
+ // conversation on. A child that fails before its session resolves
360
+ // would otherwise never be openable at all.
361
+ request.onResolved?.({ recordId: id });
362
+ reportResolved();
363
+ });
364
+ remember(record);
365
+ return { ...toSpawnResult(record), ...(gate !== undefined ? { gate } : {}) };
366
+ }
367
+ catch (error) {
368
+ // Strict worktree isolation rejects out of `awaitStartup` — the child
369
+ // never ran. That is this agent's failure, not the run's: the script
370
+ // sees `null` and its siblings carry on.
371
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
372
+ }
373
+ },
374
+ abortAgent(agentId) {
375
+ const id = records.get(agentId);
376
+ // Nothing to abort before the manager has issued an id — the child is
377
+ // still in startup, and the run's own signal reaches it there.
378
+ if (id !== undefined)
379
+ manager.abort(id);
380
+ },
381
+ async resumeAgent(agentId, prompt, onResolved) {
382
+ const id = records.get(agentId);
383
+ if (id === undefined) {
384
+ return { ok: false, error: `Cannot resume "${agentId}" — it never started.` };
385
+ }
386
+ const record = await manager.resume(id, prompt, deps.signal);
387
+ // No live session: the manager evicted the child, or it never got as far
388
+ // as having one. `reopenAgent` refuses the second with the reason — and a
389
+ // record still in memory without a session is refused by the manager,
390
+ // which never reopens an id that is still live.
391
+ if (record === undefined)
392
+ return reopenAgent(id, prompt, onResolved);
393
+ // The resumed row is built from scratch, so it has to be told the same
394
+ // thing the first one was — the child's session already exists, so this is
395
+ // simply read back rather than waited for. The id goes first for the same
396
+ // reason it does on the spawn path: it is knowable even when the rest is
397
+ // not.
398
+ onResolved?.({ recordId: id });
399
+ const info = resolvedInfo(record);
400
+ if (info !== undefined)
401
+ onResolved?.(info);
402
+ return toSpawnResult(record);
403
+ },
404
+ /**
405
+ * Resolve a nested `workflow()` reference. The runtime decides whether what
406
+ * comes back is a workflow; this only finds it.
407
+ */
408
+ loadWorkflow(ref) {
409
+ return resolveWorkflowSource(ref, ctx.cwd);
410
+ },
411
+ // Reached only for a gate the spawn did not already run — a child with no
412
+ // worktree of its own, or a host wired without the pre-cleanup hook.
413
+ async runGate(command, gate) {
414
+ // The child's worktree when it had one and it survived; otherwise the
415
+ // session's own directory, which is where a non-isolated child worked.
416
+ return await executeGate(command, gate.cwd ?? ctx.cwd);
417
+ },
418
+ };
419
+ }
@@ -0,0 +1,97 @@
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
+ /** One settled agent call, as replayed. */
47
+ export interface WorkflowJournalEntry {
48
+ /** Position in the run — the same counter that names `wf-agent-N`. */
49
+ index: number;
50
+ /** Hash of the call's payload; a mismatch ends the replayable prefix. */
51
+ key: string;
52
+ /** Whether the agent succeeded. A failure ends the prefix on replay. */
53
+ ok: boolean;
54
+ /** The agent's answer, when it had one. */
55
+ text?: string;
56
+ /**
57
+ * Whether the call continued an earlier child (`agent({ resume })`).
58
+ *
59
+ * A replayed agent leaves no session behind in the run that replays it — the
60
+ * conversation belongs to the run that actually spawned it, and the host's
61
+ * id map is per-run — so a later `resume` would have nothing to continue.
62
+ * Recording it lets the next run decline to replay at all rather than fail
63
+ * partway through, which is why the flag is on the journal and not derived.
64
+ */
65
+ resumed?: true;
66
+ }
67
+ /**
68
+ * The fields that decide what an agent does.
69
+ *
70
+ * Deliberately not the whole payload: `phaseIndex` and `phaseTitle` move the
71
+ * row around in the progress tree without changing a single token the agent
72
+ * sees, so re-grouping phases should not throw away an hour of results.
73
+ */
74
+ export interface JournalKeyInput {
75
+ prompt: string;
76
+ label?: string;
77
+ model?: string;
78
+ agentType?: string;
79
+ effort?: string;
80
+ isolation?: string;
81
+ gate?: string;
82
+ resume?: string;
83
+ /** Serialized `agent({ schema })`, when the call asked for one. */
84
+ schema?: string;
85
+ }
86
+ /** Stable hash of a call's payload. Field order is fixed here, not by the caller. */
87
+ export declare function journalKey(input: JournalKeyInput): string;
88
+ /**
89
+ * Read a journal file into position order.
90
+ *
91
+ * Never throws: a missing, truncated or hand-mangled journal means "nothing to
92
+ * replay", which costs tokens. Refusing to run would cost the whole run.
93
+ * A partial last line is normal — the file is appended to while agents settle.
94
+ */
95
+ export declare function readJournal(path: string): WorkflowJournalEntry[];
96
+ /** Append one settled call. Failure to write is not failure to run. */
97
+ export declare function appendJournal(path: string, entry: WorkflowJournalEntry): void;