@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,230 @@
1
+ /**
2
+ * saved.ts — resolve `SubagentWorkflow({ name })` to a script on disk.
3
+ *
4
+ * A saved workflow is a plain `.js` file whose contents are exactly what
5
+ * `script` would have carried. Nothing is parsed here: `extractMeta` still runs
6
+ * over the source at the call site, so a saved file and an inline one fail the
7
+ * same way on a bad `meta` block.
8
+ *
9
+ * Roots mirror `loadCustomAgents` rather than inventing a fourth convention —
10
+ * project `.pi` is the authority, the shared `.agents` workspace is an extra
11
+ * read location, and the user's agent dir is the fallback:
12
+ *
13
+ * 1. <cwd>/.pi/workflows/<name>.js
14
+ * 2. <cwd>/.agents/workflows/<name>.js
15
+ * 3. getAgentDir()/workflows/<name>.js (default ~/.pi/agent/workflows)
16
+ *
17
+ * Precedence is expressed as first-hit-wins here, not last-write-wins as in the
18
+ * agent loader, because a name resolves to one file — there is no map to
19
+ * overwrite.
20
+ *
21
+ * Symlinks are rejected through `safeReadFile`, and the name is whitelisted
22
+ * before it is ever joined to a path: `name` arrives from a model, and
23
+ * `../../etc/passwd` must not become a readable workflow.
24
+ *
25
+ * ## Not every `.js` in the folder is a workflow
26
+ *
27
+ * These are ordinary directories. `.agents/workflows/` is shared across tools
28
+ * and the user's agent dir is theirs to fill; either may hold a build artifact,
29
+ * a config, or a scratch script. A file is only treated as a workflow if it
30
+ * carries the `export const meta =` declaration every workflow opens with.
31
+ *
32
+ * Nothing is ever executed to decide this — the check is a regex over the
33
+ * source, and even the real parse only evaluates the `meta` object literal in
34
+ * an empty vm. What the filter buys is honesty: a listing that offers `utils.js`
35
+ * as a runnable workflow invites the model to try it, and naming it should say
36
+ * "that is not a workflow" rather than produce a parser error about a block the
37
+ * author never intended to write.
38
+ */
39
+
40
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
41
+ import { isAbsolute, join } from "node:path"
42
+ import { getAgentDir } from "@earendil-works/pi-coding-agent"
43
+ import { isSymlink, isUnsafeName, safeReadFile } from "../memory.js"
44
+ import { hasMetaDeclaration } from "./meta.js"
45
+ import { MAX_SCRIPT_LENGTH } from "./runtime.js"
46
+
47
+ /** Extension a saved workflow file carries. */
48
+ const WORKFLOW_EXTENSION = ".js"
49
+
50
+ /** The roots a `name` is looked up in, highest priority first. */
51
+ export function savedWorkflowRoots(cwd: string): string[] {
52
+ return [
53
+ join(cwd, ".pi", "workflows"),
54
+ join(cwd, ".agents", "workflows"),
55
+ join(getAgentDir(), "workflows"),
56
+ ]
57
+ }
58
+
59
+ export type SavedWorkflow =
60
+ | { ok: true; script: string; path: string }
61
+ | { ok: false; message: string }
62
+
63
+ /**
64
+ * Read the saved workflow called `name`.
65
+ *
66
+ * Failure carries the roots that were searched and, when there are any, the
67
+ * names that do exist — a model that guessed the name can correct itself from
68
+ * the error instead of spending a turn asking.
69
+ */
70
+ export function readSavedWorkflow(name: string, cwd: string): SavedWorkflow {
71
+ const trimmed = name.trim()
72
+ if (isUnsafeName(trimmed)) {
73
+ return {
74
+ ok: false,
75
+ message:
76
+ `"${name}" is not a usable workflow name. Use letters, digits, dots, hyphens and underscores only ` +
77
+ "— a path is what `scriptPath` is for.",
78
+ }
79
+ }
80
+
81
+ const roots = savedWorkflowRoots(cwd)
82
+ for (const root of roots) {
83
+ if (isSymlink(root)) continue // reject a symlinked root entirely, as skill-loader does
84
+ const path = join(root, `${trimmed}${WORKFLOW_EXTENSION}`)
85
+ const script = safeReadFile(path)
86
+ if (script === undefined) continue
87
+ // Found the file, so stop looking — a shadowing name that turns out not to
88
+ // be a workflow is worth reporting, not worth silently reaching past.
89
+ if (!hasMetaDeclaration(script)) {
90
+ return {
91
+ ok: false,
92
+ message:
93
+ `"${path}" is not a workflow script — it has no \`export const meta = { name, description }\` ` +
94
+ "declaration. Nothing was run.",
95
+ }
96
+ }
97
+ return { ok: true, script, path }
98
+ }
99
+
100
+ const known = listSavedWorkflows(cwd)
101
+ return {
102
+ ok: false,
103
+ message:
104
+ `No saved workflow named "${trimmed}". Looked in: ${roots.join(", ")}. ` +
105
+ (known.length > 0
106
+ ? `Available: ${known.join(", ")}.`
107
+ : "Save one as `<name>.js` in one of those directories, or pass `script`/`scriptPath` instead."),
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Resolve a reference — a saved name, or a path — to source.
113
+ *
114
+ * The one place that decides what a reference means, so the tool's `name` /
115
+ * `scriptPath` parameters and a script's nested `workflow()` cannot drift apart
116
+ * on precedence or on what counts as a workflow.
117
+ */
118
+ export function resolveWorkflowSource(
119
+ ref: { name?: string; scriptPath?: string },
120
+ cwd: string,
121
+ ): SavedWorkflow {
122
+ const path = ref.scriptPath?.trim()
123
+ if (path !== undefined && path !== "") {
124
+ const resolved = isAbsolute(path) ? path : join(cwd, path)
125
+ try {
126
+ return {
127
+ ok: true,
128
+ script: readFileSync(resolved, "utf-8"),
129
+ path: resolved,
130
+ }
131
+ } catch (err) {
132
+ return {
133
+ ok: false,
134
+ message: `Could not read workflow script "${resolved}": ${err instanceof Error ? err.message : String(err)}`,
135
+ }
136
+ }
137
+ }
138
+ const name = ref.name?.trim()
139
+ if (name !== undefined && name !== "") return readSavedWorkflow(name, cwd)
140
+ return {
141
+ ok: false,
142
+ message: "A workflow reference needs a `name` or a `scriptPath`.",
143
+ }
144
+ }
145
+
146
+ /** Every saved workflow name, de-duplicated across roots and sorted. */
147
+ export function listSavedWorkflows(cwd: string): string[] {
148
+ const names = new Set<string>()
149
+ for (const root of savedWorkflowRoots(cwd)) {
150
+ if (!existsSync(root) || isSymlink(root)) continue
151
+ let entries: string[]
152
+ try {
153
+ entries = readdirSync(root)
154
+ } catch {
155
+ continue // an unreadable root is not worth failing a lookup over
156
+ }
157
+ for (const entry of entries) {
158
+ if (!entry.endsWith(WORKFLOW_EXTENSION)) continue
159
+ const name = entry.slice(0, -WORKFLOW_EXTENSION.length)
160
+ if (isUnsafeName(name)) continue
161
+ if (isWorkflowFile(join(root, entry))) names.add(name)
162
+ }
163
+ }
164
+ return [...names].sort()
165
+ }
166
+
167
+ /**
168
+ * Whether the file at `path` is a workflow rather than some other script.
169
+ *
170
+ * Size-guarded before reading: the listing runs over a whole directory, and a
171
+ * file too large to be a workflow at all should not be pulled into memory just
172
+ * to check its first line.
173
+ */
174
+ function isWorkflowFile(path: string): boolean {
175
+ try {
176
+ if (statSync(path).size > MAX_SCRIPT_LENGTH) return false
177
+ } catch {
178
+ return false
179
+ }
180
+ const source = safeReadFile(path)
181
+ return source !== undefined && hasMetaDeclaration(source)
182
+ }
183
+
184
+ /**
185
+ * Resolve which source a `SubagentWorkflow` call runs.
186
+ *
187
+ * `scriptPath` wins over `script`, which wins over `name` — Claude Code's
188
+ * order — and at least one is required: a call with none is a mistake worth
189
+ * naming rather than an empty run. Lives beside {@link resolveWorkflowSource}
190
+ * because that is the function it defers to once precedence is settled, so the
191
+ * two cannot disagree about what a reference means.
192
+ */
193
+ export function resolveWorkflowScript(
194
+ params: { script?: string; scriptPath?: string; name?: string },
195
+ cwd: string,
196
+ ):
197
+ | { ok: true; script: string; scriptPath?: string }
198
+ | { ok: false; message: string } {
199
+ const path = params.scriptPath?.trim()
200
+ if (path !== undefined && path !== "") {
201
+ const resolved = resolveWorkflowSource({ scriptPath: path }, cwd)
202
+ return resolved.ok
203
+ ? { ok: true, script: resolved.script, scriptPath: resolved.path }
204
+ : resolved
205
+ }
206
+ const script = params.script
207
+ if (script !== undefined && script.trim() !== "") return { ok: true, script }
208
+
209
+ // A saved workflow is the same source by another route, so it reports its
210
+ // file as `scriptPath`: the "edit the file and re-run" loop then works on a
211
+ // named workflow without the author having to find where it lives. Shared
212
+ // with a script's nested `workflow()`, so one definition decides what a
213
+ // reference means.
214
+ const name = params.name?.trim()
215
+ if (name !== undefined && name !== "") {
216
+ const saved = resolveWorkflowSource({ name }, cwd)
217
+ return saved.ok
218
+ ? { ok: true, script: saved.script, scriptPath: saved.path }
219
+ : saved
220
+ }
221
+
222
+ const known = listSavedWorkflows(cwd)
223
+ return {
224
+ ok: false,
225
+ message:
226
+ "Provide `script` (inline source), `scriptPath` (a file to read), or `name` (a saved workflow). " +
227
+ "`scriptPath` takes precedence, then `script`, then `name`." +
228
+ (known.length > 0 ? ` Saved workflows: ${known.join(", ")}.` : ""),
229
+ }
230
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * task.ts — the background record one workflow run lives in.
3
+ *
4
+ * A `SubagentWorkflow` tool call returns a task id immediately and the run continues
5
+ * without it, so the run's state cannot live in the tool call's closure: the
6
+ * inline card, the completion notification and (later) the `/agents → Workflows` dialog
7
+ * all read it after `execute` has returned. This is that record, shaped after
8
+ * Claude Code's `local_workflow` task so the fields line up with what the
9
+ * renderers already expect.
10
+ *
11
+ * The progress log is append-only and collapses by index (see `progress.ts`),
12
+ * so every derived counter here is recomputed from the log rather than
13
+ * incremented as entries arrive — a re-emitted agent entry replaces its
14
+ * predecessor, and adding its tokens on top would double-count them.
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto"
18
+ import { escapeXml } from "../xml.js"
19
+ import type { WorkflowJournalEntry } from "./journal.js"
20
+ import type { WorkflowMeta } from "./meta.js"
21
+ import {
22
+ collapse,
23
+ elapsedMs,
24
+ stats,
25
+ type WorkflowEntry,
26
+ type WorkflowRunStatus,
27
+ } from "./progress.js"
28
+ import type { WorkflowControl, WorkflowRunResult } from "./runtime.js"
29
+
30
+ /** `wf_` + hex, matching Claude Code's `^wf_[a-z0-9-]{6,}$` run ids. */
31
+ export function workflowRunId(): string {
32
+ return `wf_${randomUUID().replace(/-/g, "").slice(0, 12)}`
33
+ }
34
+
35
+ export interface WorkflowTask {
36
+ /** Discriminator, alongside Claude Code's `local_agent` / `local_bash`. */
37
+ type: "local_workflow"
38
+ id: string
39
+ status: WorkflowRunStatus
40
+ script: string
41
+ /** Where the script can be edited and re-run from. */
42
+ scriptPath?: string
43
+ args?: unknown
44
+ meta?: WorkflowMeta
45
+ workflowName?: string
46
+ /** The `tool_use_id` of the call that started this, when one did. */
47
+ toolCallId?: string
48
+
49
+ /**
50
+ * Pause, skip and retry, once the run is up.
51
+ *
52
+ * Absent before the runtime hands it over and after the run settles — the
53
+ * dialog treats "no control" as "those keys do nothing", which is the same
54
+ * thing it does for a run that has finished.
55
+ */
56
+ control?: WorkflowControl
57
+ /** When the current pause started, so `totalPausedMs` can be closed out. */
58
+ pausedAt?: number
59
+
60
+ /** Where this run records its own settled calls, for a later resume. */
61
+ journalPath?: string
62
+ /** A previous run's journal, when this call asked to resume one. */
63
+ replay?: readonly WorkflowJournalEntry[]
64
+ /** The run id this one resumed, for the result line that says so. */
65
+ resumedFrom?: string
66
+ /** How many agents came back from {@link replay} instead of being spawned. */
67
+ replayedCount: number
68
+
69
+ /** The append-only event log, in emission order. */
70
+ workflowProgress: WorkflowEntry[]
71
+ /** Bumped once per applied batch, so a renderer can tell nothing changed. */
72
+ progressVersion: number
73
+ agentCount: number
74
+ /**
75
+ * Agents that have settled successfully, recomputed with the other counters.
76
+ *
77
+ * Cached rather than derived on read because the fleet list asks five times a
78
+ * second: deriving it there would walk the whole append-only log on every
79
+ * tick, which for a thousand-agent run is real work in the render loop.
80
+ */
81
+ doneCount: number
82
+ totalTokens: number
83
+ totalToolCalls: number
84
+ logs: string[]
85
+
86
+ abortController: AbortController
87
+ startTime: number
88
+ endTime?: number
89
+ /** Excluded from the elapsed clock the header shows. */
90
+ totalPausedMs: number
91
+
92
+ /** The script's return value, once the run produced one. */
93
+ value?: unknown
94
+ error?: string
95
+ }
96
+
97
+ export function createWorkflowTask(init: {
98
+ id: string
99
+ script: string
100
+ scriptPath?: string
101
+ args?: unknown
102
+ meta?: WorkflowMeta
103
+ toolCallId?: string
104
+ startTime?: number
105
+ journalPath?: string
106
+ replay?: readonly WorkflowJournalEntry[]
107
+ resumedFrom?: string
108
+ }): WorkflowTask {
109
+ return {
110
+ type: "local_workflow",
111
+ id: init.id,
112
+ status: "running",
113
+ script: init.script,
114
+ scriptPath: init.scriptPath,
115
+ args: init.args,
116
+ meta: init.meta,
117
+ workflowName: init.meta?.name,
118
+ toolCallId: init.toolCallId,
119
+ journalPath: init.journalPath,
120
+ replay: init.replay,
121
+ resumedFrom: init.resumedFrom,
122
+ replayedCount: 0,
123
+ workflowProgress: [],
124
+ progressVersion: 0,
125
+ agentCount: 0,
126
+ doneCount: 0,
127
+ totalTokens: 0,
128
+ totalToolCalls: 0,
129
+ logs: [],
130
+ abortController: new AbortController(),
131
+ startTime: init.startTime ?? Date.now(),
132
+ totalPausedMs: 0,
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Apply one batch of progress entries.
138
+ *
139
+ * Batched rather than per-entry because that is how the worker emits them, and
140
+ * because every counter below is an O(log) recompute — doing it once per fan-out
141
+ * frame instead of once per agent is the difference that keeps a 200-agent run
142
+ * cheap to render.
143
+ */
144
+ export function updateWorkflowProgressBatch(
145
+ task: WorkflowTask,
146
+ entries: readonly WorkflowEntry[],
147
+ ): void {
148
+ if (entries.length === 0) return
149
+ task.workflowProgress.push(...entries)
150
+ task.progressVersion++
151
+
152
+ const { agents, logs } = collapse(task.workflowProgress)
153
+ task.logs = logs
154
+ // `agentCount` is what the runtime has scheduled, which can lead what the log
155
+ // has seen — never let a recompute walk it backwards.
156
+ task.agentCount = Math.max(task.agentCount, agents.length)
157
+
158
+ let totalTokens = 0
159
+ let totalToolCalls = 0
160
+ let done = 0
161
+ for (const agent of agents) {
162
+ totalTokens += agent.tokens ?? 0
163
+ totalToolCalls += agent.toolCalls ?? 0
164
+ // Counted off the collapsed agents, so a re-emitted row counts once.
165
+ if (agent.state === "done") done++
166
+ }
167
+ task.totalTokens = totalTokens
168
+ task.totalToolCalls = totalToolCalls
169
+ task.doneCount = done
170
+ }
171
+
172
+ /**
173
+ * Hold the run, and stop its clock.
174
+ *
175
+ * The elapsed figure every surface shows subtracts `totalPausedMs`, so a run
176
+ * left paused overnight does not come back reading as a twelve-hour run.
177
+ */
178
+ export function pauseWorkflowTask(
179
+ task: WorkflowTask,
180
+ now = Date.now(),
181
+ ): boolean {
182
+ if (task.status !== "running" || task.control === undefined) return false
183
+ task.control.pause()
184
+ task.status = "paused"
185
+ task.pausedAt = now
186
+ return true
187
+ }
188
+
189
+ /** Let it go again, banking however long it was held. */
190
+ export function resumeWorkflowTask(
191
+ task: WorkflowTask,
192
+ now = Date.now(),
193
+ ): boolean {
194
+ if (task.status !== "paused" || task.control === undefined) return false
195
+ task.control.resume()
196
+ task.status = "running"
197
+ task.totalPausedMs =
198
+ (task.totalPausedMs ?? 0) + Math.max(0, now - (task.pausedAt ?? now))
199
+ task.pausedAt = undefined
200
+ return true
201
+ }
202
+
203
+ /** Settle a task from the run's own result. */
204
+ export function completeWorkflowTask(
205
+ task: WorkflowTask,
206
+ result: WorkflowRunResult,
207
+ ): void {
208
+ // Banked before the status moves off "paused": a run that finished while held
209
+ // still spent that time held, and the elapsed figure has to say so.
210
+ if (task.pausedAt !== undefined) {
211
+ task.totalPausedMs =
212
+ (task.totalPausedMs ?? 0) + Math.max(0, Date.now() - task.pausedAt)
213
+ task.pausedAt = undefined
214
+ }
215
+ // Nothing left to control, and holding the handle would let the dialog offer
216
+ // pause on a run that has already stopped.
217
+ task.control = undefined
218
+ task.status = result.status
219
+ task.meta ??= result.meta
220
+ task.workflowName ??= result.meta.name
221
+ task.agentCount = Math.max(task.agentCount, result.agentCount)
222
+ task.replayedCount = result.replayedCount
223
+ task.value = result.value
224
+ task.error = result.error
225
+ task.endTime = Date.now()
226
+ }
227
+
228
+ /**
229
+ * Settle a task that never produced a result — a script rejected before the
230
+ * worker started (bad `meta`, oversized source, non-JSON `args`).
231
+ */
232
+ export function failWorkflowTask(task: WorkflowTask, error: string): void {
233
+ task.control = undefined
234
+ task.pausedAt = undefined
235
+ task.status = "failed"
236
+ task.error = error
237
+ task.endTime = Date.now()
238
+ }
239
+
240
+ /** The run's outcome as text, for the notification and the LLM-facing result. */
241
+ export function workflowResultText(task: WorkflowTask): string {
242
+ if (task.error !== undefined) return task.error
243
+ if (task.value === undefined) return "No output."
244
+ if (typeof task.value === "string") return task.value
245
+ return JSON.stringify(task.value, null, 2)
246
+ }
247
+
248
+ /**
249
+ * Resolve a `resumeFromRunId` against the runs this session has seen.
250
+ *
251
+ * Same-session only, and deliberately so: the journal lives beside the
252
+ * session's task files, and a run id from another session would silently find
253
+ * nothing to replay — reporting that as "resumed" would be a lie the caller
254
+ * could not see through. An unknown id is an error rather than a cold start,
255
+ * because a caller that asked to resume is expecting not to pay.
256
+ */
257
+ export function resolveResumeTarget(
258
+ runId: string | undefined,
259
+ tasks: ReadonlyMap<string, WorkflowTask>,
260
+ ):
261
+ | undefined
262
+ | { ok: true; runId: string; journalPath: string; scriptPath: string }
263
+ | { ok: false; message: string } {
264
+ const id = runId?.trim()
265
+ if (id === undefined || id === "") return undefined
266
+
267
+ const prior = tasks.get(id)
268
+ if (prior === undefined) {
269
+ const known = [...tasks.keys()]
270
+ return {
271
+ ok: false,
272
+ message:
273
+ `No workflow run "${id}" in this session. ` +
274
+ (known.length > 0
275
+ ? `Runs this session: ${known.join(", ")}.`
276
+ : "Nothing has run yet — call this without `resumeFromRunId`."),
277
+ }
278
+ }
279
+ if (prior.status === "running") {
280
+ return {
281
+ ok: false,
282
+ message: `Workflow "${id}" is still running. Stop it from /agents → Workflows before resuming it.`,
283
+ }
284
+ }
285
+ if (prior.journalPath === undefined) {
286
+ return {
287
+ ok: false,
288
+ message: `Workflow "${id}" has no journal to resume from.`,
289
+ }
290
+ }
291
+ return {
292
+ ok: true,
293
+ runId: id,
294
+ journalPath: prior.journalPath,
295
+ // The persisted copy, which is what `scriptPath` holds when the call had
296
+ // no file of its own.
297
+ scriptPath: prior.scriptPath ?? "",
298
+ }
299
+ }
300
+
301
+ /** `<task-notification>`, in the same shape a finished background agent sends. */
302
+ export function formatWorkflowNotification(
303
+ task: WorkflowTask,
304
+ now = Date.now(),
305
+ ): string {
306
+ const totals = stats(task.workflowProgress, task.agentCount)
307
+ const status =
308
+ task.status === "completed"
309
+ ? "Done"
310
+ : task.status === "killed"
311
+ ? "Stopped"
312
+ : `Error: ${task.error ?? "unknown"}`
313
+ const result = workflowResultText(task)
314
+ return [
315
+ `<task-notification>`,
316
+ `<task-id>${task.id}</task-id>`,
317
+ task.toolCallId
318
+ ? `<tool-use-id>${escapeXml(task.toolCallId)}</tool-use-id>`
319
+ : null,
320
+ task.scriptPath ? `<script>${escapeXml(task.scriptPath)}</script>` : null,
321
+ `<status>${escapeXml(status)}</status>`,
322
+ `<summary>Workflow "${escapeXml(task.workflowName ?? task.id)}" ${task.status} — ${totals.done}/${totals.total} agents${
323
+ task.replayedCount > 0
324
+ ? `, ${task.replayedCount} replayed from ${escapeXml(task.resumedFrom ?? "an earlier run")}`
325
+ : ""
326
+ }</summary>`,
327
+ `<result>${escapeXml(result.length > 4000 ? `${result.slice(0, 4000)}\n...(truncated)` : result)}</result>`,
328
+ `<usage><total_tokens>${task.totalTokens}</total_tokens><tool_uses>${task.totalToolCalls}</tool_uses><duration_ms>${elapsedMs(task, now)}</duration_ms></usage>`,
329
+ `</task-notification>`,
330
+ ]
331
+ .filter(Boolean)
332
+ .join("\n")
333
+ }