@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
@@ -0,0 +1,463 @@
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 {
37
+ ExtensionAPI,
38
+ ExtensionContext,
39
+ } from "@earendil-works/pi-coding-agent"
40
+ import type { AgentManager } from "../agent-manager.js"
41
+ import { getAgentConfig, resolveSpawnType } from "../agent-types.js"
42
+ import { resolveModel } from "../model-resolver.js"
43
+ import { checkModelScope } from "../model-scope.js"
44
+ import type { AgentRecord, ThinkingLevel } from "../types.js"
45
+ import { getLifetimeTotal } from "../usage.js"
46
+ import type {
47
+ WorkflowGateResult,
48
+ WorkflowHost,
49
+ WorkflowSpawnResult,
50
+ } from "./runtime.js"
51
+ import { resolveWorkflowSource } from "./saved.js"
52
+
53
+ /**
54
+ * Wall-clock bound on a `gate` command. Generous — a gate is routinely a test
55
+ * suite — but not unbounded: `pi.exec` reports a timeout as `killed`, and a
56
+ * gate that hangs forever would wedge the agent slot it is holding.
57
+ */
58
+ export const DEFAULT_GATE_TIMEOUT_MS = 10 * 60_000
59
+
60
+ export interface WorkflowHostOptions {
61
+ pi: ExtensionAPI
62
+ ctx: ExtensionContext
63
+ manager: AgentManager
64
+ /** The run's abort signal, so killing the workflow kills its children. */
65
+ signal?: AbortSignal
66
+ /** Groups child transcripts under the parent session. */
67
+ rootSessionId?: string
68
+ /**
69
+ * The run id every child is stamped with.
70
+ *
71
+ * What makes them the workflow's rather than the session's: stamped children
72
+ * are filtered out of the fleet list, the widget, the `/agents` menus and
73
+ * `@handle` resolution, and they take no `maxConcurrent` slot. The run
74
+ * reports for them, and it has its own concurrency cap.
75
+ */
76
+ workflowId?: string
77
+ gateTimeoutMs?: number
78
+ }
79
+
80
+ /**
81
+ * Where the child worked, when that directory still exists.
82
+ *
83
+ * The guard is not defensive padding. `cleanupWorktree` commits the child's
84
+ * changes to a branch and *removes* the copy before `spawnAndWait` resolves, so
85
+ * an isolated child's worktree is normally already gone by the time a result is
86
+ * built. That is exactly why a gate cannot wait until here — it runs from
87
+ * `onBeforeWorktreeCleanup` instead — and why this reports nothing rather than
88
+ * a path that no longer exists: handing a stale path to a command would fail
89
+ * every gated worktree agent with a spawn error instead of a test result.
90
+ */
91
+ function childCwd(record: AgentRecord): string | undefined {
92
+ // `path`, not `workPath`: a workflow spawn never passes a cwd, so the manager
93
+ // runs the child at the copied repo's root.
94
+ const path = record.worktree?.path
95
+ return path !== undefined && existsSync(path) ? path : undefined
96
+ }
97
+
98
+ /**
99
+ * Whether the child itself succeeded — the same condition {@link toSpawnResult}
100
+ * turns into `ok`, read from the live record so the pre-cleanup hook can tell a
101
+ * finished child from a failed one before the result exists.
102
+ */
103
+ function succeeded(record: AgentRecord | undefined): boolean {
104
+ return record?.status === "completed" || record?.status === "steered"
105
+ }
106
+
107
+ /** Translate a settled record into what the script sees. */
108
+ /**
109
+ * The effective-configuration half of a record, in the runtime's pi-free shape.
110
+ *
111
+ * `AgentRecord.invocation` is the one authoritative place for it (#168); this
112
+ * only renames the fields across the boundary. Returns undefined until the
113
+ * child's session has reported a model, which is when any of it is knowable.
114
+ */
115
+ function resolvedInfo(record: AgentRecord | undefined) {
116
+ const invocation = record?.invocation
117
+ if (invocation?.modelName === undefined) return undefined
118
+ return {
119
+ modelName: invocation.modelName,
120
+ modelId: invocation.modelId,
121
+ thinking: invocation.thinking,
122
+ requestedThinking: invocation.requestedThinking,
123
+ requestedModel: invocation.requestedModel,
124
+ }
125
+ }
126
+
127
+ function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
128
+ const tokens = getLifetimeTotal(record.lifetimeUsage)
129
+ // Reported separately from `tokens`, which is the lifetime total. The script's
130
+ // `budget` counts *output* tokens, as Claude Code's does — billing the input
131
+ // and cache reads a fan-out re-sends would over-report it by an order of
132
+ // magnitude and make the documented guards useless.
133
+ const outputTokens = record.lifetimeUsage?.output ?? 0
134
+ const cwd = childCwd(record)
135
+ const common = {
136
+ ...(tokens > 0 ? { tokens } : {}),
137
+ ...(outputTokens > 0 ? { outputTokens } : {}),
138
+ ...(record.toolUses > 0 ? { toolCalls: record.toolUses } : {}),
139
+ ...(cwd !== undefined ? { cwd } : {}),
140
+ }
141
+
142
+ if (succeeded(record)) {
143
+ return {
144
+ ...common,
145
+ ok: true,
146
+ // The schema'd payload when there is one: `result` is prose, and for a
147
+ // worktree child it has had the branch note appended, so it would not
148
+ // parse. A child asked for a schema that produced none never reaches
149
+ // here — `runAgent` reports that through `failure`.
150
+ text: record.structuredJson ?? record.result ?? "",
151
+ ...(record.structuredRetried ? { structuredRetried: true } : {}),
152
+ }
153
+ }
154
+ // "stopped" is someone reaching in and stopping this child — /agents, the
155
+ // fleet list, a workflow abort. That is the same thing the workflows dialog's
156
+ // skip action means, so it renders as skipped rather than failed.
157
+ if (record.status === "stopped") {
158
+ return {
159
+ ...common,
160
+ ok: false,
161
+ skipped: true,
162
+ error: record.error ?? "Stopped.",
163
+ }
164
+ }
165
+ return {
166
+ ...common,
167
+ ok: false,
168
+ error: record.error ?? `Agent ${record.status}.`,
169
+ }
170
+ }
171
+
172
+ /** Shell used to run a `gate` command, mirroring how a user would type it. */
173
+ const GATE_SHELL: readonly [string, string] =
174
+ process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"]
175
+
176
+ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
177
+ const { pi, ctx, manager } = deps
178
+ /** Runtime agent id → the manager record it spawned. Never pruned mid-run. */
179
+ const records = new Map<string, string>()
180
+ /**
181
+ * scopeModels warnings already toasted, so a fan-out that pins one
182
+ * out-of-scope agent file raises one notification rather than one per child.
183
+ * Kept for the whole run: the same message is the same warning at agent 200
184
+ * as it was at agent 1.
185
+ */
186
+ const warnedScopeMessages = new Set<string>()
187
+
188
+ /**
189
+ * Run a gate command in `cwd`. The only place a gate is executed — `runGate`
190
+ * and the pre-cleanup hook both come through here — so "gate passed" is
191
+ * decided from one behaviour, whichever route the command took.
192
+ */
193
+ async function executeGate(
194
+ command: string,
195
+ cwd: string,
196
+ ): Promise<WorkflowGateResult> {
197
+ const result = await pi.exec(GATE_SHELL[0], [GATE_SHELL[1], command], {
198
+ cwd,
199
+ timeout: deps.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
200
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
201
+ })
202
+ const output = [result.stdout, result.stderr]
203
+ .map((stream) => stream.trim())
204
+ .filter(Boolean)
205
+ .join("\n")
206
+ // `pi.exec` reports a timeout as `killed` with exit code 0, so the code
207
+ // alone would read a killed gate as a passing one.
208
+ if (result.killed) {
209
+ return {
210
+ ok: false,
211
+ output: output || `Gate command timed out: ${command}`,
212
+ }
213
+ }
214
+ return { ok: result.code === 0, output }
215
+ }
216
+
217
+ return {
218
+ async spawnAgent(request) {
219
+ const dispatch = resolveSpawnType(request.agentType)
220
+ if (!dispatch.ok) return { ok: false, error: dispatch.message }
221
+
222
+ // Same precedence as the Agent tool: the caller's model wins, the agent
223
+ // definition's is next, and the parent's is the floor. A model the script
224
+ // named and we cannot resolve is an error; one the definition named falls
225
+ // back to the parent silently, because the script never asked for it.
226
+ let model = ctx.model
227
+ const config = getAgentConfig(dispatch.type)
228
+ const modelInput = request.model ?? config?.model
229
+ if (modelInput !== undefined) {
230
+ const resolved = resolveModel(modelInput, ctx.modelRegistry)
231
+ if (typeof resolved === "string") {
232
+ if (request.model !== undefined) return { ok: false, error: resolved }
233
+ } else {
234
+ model = resolved
235
+ }
236
+ }
237
+
238
+ // Same scopeModels policy as the Agent tool and the nested delegation
239
+ // tools: a script's `agent({ model })` is a runtime LLM choice, and the
240
+ // script is written by the model, so it must not reach a model the user's
241
+ // enabledModels list excludes. `callerSupplied` keys off `request.model`
242
+ // and NOT `modelInput` — the latter has already absorbed the agent file's
243
+ // own `model:`, which is user-authored config and so earns the
244
+ // warn-and-proceed branch rather than a refusal.
245
+ const scopeVerdict = checkModelScope({
246
+ model,
247
+ cwd: ctx.cwd,
248
+ modelRegistry: ctx.modelRegistry,
249
+ callerSupplied: request.model !== undefined,
250
+ agentLabel: config?.displayName ?? dispatch.type,
251
+ modelInput,
252
+ })
253
+ // This agent's failure, not the run's — the same shape a bad agent type
254
+ // takes above. The script sees `null` and its siblings carry on, which is
255
+ // the difference between one refused model and a discarded fan-out.
256
+ if (scopeVerdict.kind === "error")
257
+ return { ok: false, error: scopeVerdict.message }
258
+ if (
259
+ scopeVerdict.kind === "warn" &&
260
+ !warnedScopeMessages.has(scopeVerdict.message)
261
+ ) {
262
+ warnedScopeMessages.add(scopeVerdict.message)
263
+ ctx.ui.notify(scopeVerdict.message, "warning")
264
+ }
265
+
266
+ /**
267
+ * The gate's verdict, set only if the hook below actually ran the command.
268
+ * Its presence is what stops the runtime running the gate a second time,
269
+ * so it is set on the failure route too — a gate we tried and could not
270
+ * complete is a failed gate, never an un-run one that then re-runs
271
+ * against the wrong tree.
272
+ */
273
+ let gate: WorkflowGateResult | undefined
274
+ let spawnedId: string | undefined
275
+
276
+ /**
277
+ * Hand the child's EFFECTIVE configuration back to the run.
278
+ *
279
+ * `AgentRecord.invocation` is the one authoritative place for it (#168),
280
+ * filled by the manager the moment the child's session exists — which is
281
+ * before this fires, so it is populated by the time we read it. Reading it
282
+ * rather than re-deriving "the session, else the request" is the whole
283
+ * point of that field existing.
284
+ */
285
+ let sessionReady = false
286
+ const reportResolved = () => {
287
+ // Called from BOTH the session hook and the spawn hook, because their
288
+ // order is not guaranteed: the manager fires `onSpawned` after
289
+ // `runAgent` returns, but `runAgent` decides when its own
290
+ // `onSessionCreated` fires — synchronously, for a stub. Each fires once,
291
+ // so the first call finds a half missing and returns, and the second is
292
+ // the one that reports.
293
+ if (!sessionReady || spawnedId === undefined) return
294
+ const info = resolvedInfo(manager.getRecord(spawnedId))
295
+ if (info !== undefined) request.onResolved?.(info)
296
+ }
297
+ const command = request.gate
298
+ /**
299
+ * Verify the child's work while its worktree still exists.
300
+ *
301
+ * The manager destroys that copy inside the child's own settle, so this
302
+ * is the last (and only) moment at which `npm test` can mean "the code
303
+ * this child just wrote" rather than "whatever is in the main tree".
304
+ */
305
+ const onBeforeWorktreeCleanup =
306
+ command === undefined
307
+ ? undefined
308
+ : async (worktreePath: string): Promise<void> => {
309
+ // A failed child's gate is never consulted — the runtime reports
310
+ // the child's own failure — so running it would be pure cost.
311
+ if (
312
+ spawnedId === undefined ||
313
+ !succeeded(manager.getRecord(spawnedId))
314
+ )
315
+ return
316
+ try {
317
+ gate = await executeGate(command, worktreePath)
318
+ } catch (error) {
319
+ gate = {
320
+ ok: false,
321
+ output:
322
+ error instanceof Error ? error.message : String(error),
323
+ }
324
+ }
325
+ }
326
+
327
+ try {
328
+ const { record } = await manager.spawnAndWait(
329
+ pi,
330
+ ctx,
331
+ dispatch.type,
332
+ request.prompt,
333
+ {
334
+ description: request.label,
335
+ // The stamp is what keeps this child out of the session's
336
+ // `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
337
+ // bounds how many of its agents run at once, and counting them
338
+ // twice would let one fan-out starve everything else the user is
339
+ // doing. No `bypassQueue` needed: an agent outside the pool is
340
+ // never queued behind it.
341
+ ...(deps.workflowId !== undefined
342
+ ? { workflowId: deps.workflowId }
343
+ : {}),
344
+ ...(model !== undefined ? { model } : {}),
345
+ // Validated worker-side against the same list pi accepts, so the
346
+ // cast asserts what the boundary has already checked. Left unset,
347
+ // the agent definition's `thinking` (then the parent's) still wins —
348
+ // same precedence as `model` above.
349
+ ...(request.effort !== undefined
350
+ ? { thinkingLevel: request.effort as ThinkingLevel }
351
+ : {}),
352
+ // Seeded with the REQUEST, not the outcome. The manager overwrites
353
+ // the effective half at session creation; without a seed there is
354
+ // nothing for it to compare against, so a level pi clamped would be
355
+ // indistinguishable from one that was honoured.
356
+ //
357
+ // Only the level. #182's other half — a caller parameter an agent
358
+ // file outranked — cannot arise here: this path resolves
359
+ // `request.model ?? config?.model`, so the script always wins and
360
+ // therefore always got what it asked for. Seeding a `requestedModel`
361
+ // would describe a precedence this path does not have.
362
+ invocation: {
363
+ ...(request.effort !== undefined
364
+ ? { thinking: request.effort as ThinkingLevel }
365
+ : {}),
366
+ },
367
+ // Fires once the child's session exists, which is where the model
368
+ // and the clamped thinking level first become knowable.
369
+ onSessionCreated: () => {
370
+ sessionReady = true
371
+ reportResolved()
372
+ },
373
+ ...(request.schema !== undefined
374
+ ? { structuredOutput: request.schema }
375
+ : {}),
376
+ ...(request.isolation !== undefined
377
+ ? { isolation: request.isolation }
378
+ : {}),
379
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
380
+ ...(deps.rootSessionId !== undefined
381
+ ? { rootSessionId: deps.rootSessionId }
382
+ : {}),
383
+ ...(onBeforeWorktreeCleanup !== undefined
384
+ ? { onBeforeWorktreeCleanup }
385
+ : {}),
386
+ },
387
+ (id) => {
388
+ spawnedId = id
389
+ records.set(request.agentId, id)
390
+ // Ahead of `reportResolved`, and not folded into it: that one waits
391
+ // for the child's session so it can name the effective model, while
392
+ // the record id is known here and is what the inspector opens a
393
+ // conversation on. A child that fails before its session resolves
394
+ // would otherwise never be openable at all.
395
+ request.onResolved?.({ recordId: id })
396
+ reportResolved()
397
+ },
398
+ )
399
+ return {
400
+ ...toSpawnResult(record),
401
+ ...(gate !== undefined ? { gate } : {}),
402
+ }
403
+ } catch (error) {
404
+ // Strict worktree isolation rejects out of `awaitStartup` — the child
405
+ // never ran. That is this agent's failure, not the run's: the script
406
+ // sees `null` and its siblings carry on.
407
+ return {
408
+ ok: false,
409
+ error: error instanceof Error ? error.message : String(error),
410
+ }
411
+ }
412
+ },
413
+
414
+ abortAgent(agentId) {
415
+ const id = records.get(agentId)
416
+ // Nothing to abort before the manager has issued an id — the child is
417
+ // still in startup, and the run's own signal reaches it there.
418
+ if (id !== undefined) manager.abort(id)
419
+ },
420
+
421
+ async resumeAgent(agentId, prompt, onResolved) {
422
+ const id = records.get(agentId)
423
+ if (id === undefined) {
424
+ return {
425
+ ok: false,
426
+ error: `Cannot resume "${agentId}" — it never started.`,
427
+ }
428
+ }
429
+ const record = await manager.resume(id, prompt, deps.signal)
430
+ if (record === undefined) {
431
+ return {
432
+ ok: false,
433
+ error: `Agent ${id} has no session left to resume — records are dropped ten minutes after they finish.`,
434
+ }
435
+ }
436
+ // The resumed row is built from scratch, so it has to be told the same
437
+ // thing the first one was — the child's session already exists, so this is
438
+ // simply read back rather than waited for. The id goes first for the same
439
+ // reason it does on the spawn path: it is knowable even when the rest is
440
+ // not.
441
+ onResolved?.({ recordId: id })
442
+ const info = resolvedInfo(record)
443
+ if (info !== undefined) onResolved?.(info)
444
+ return toSpawnResult(record)
445
+ },
446
+
447
+ /**
448
+ * Resolve a nested `workflow()` reference. The runtime decides whether what
449
+ * comes back is a workflow; this only finds it.
450
+ */
451
+ loadWorkflow(ref) {
452
+ return resolveWorkflowSource(ref, ctx.cwd)
453
+ },
454
+
455
+ // Reached only for a gate the spawn did not already run — a child with no
456
+ // worktree of its own, or a host wired without the pre-cleanup hook.
457
+ async runGate(command, gate) {
458
+ // The child's worktree when it had one and it survived; otherwise the
459
+ // session's own directory, which is where a non-isolated child worked.
460
+ return await executeGate(command, gate.cwd ?? ctx.cwd)
461
+ },
462
+ }
463
+ }
@@ -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
+ }