@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.
- package/CHANGELOG.md +6 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +11 -9
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +10 -4
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- 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
|
+
}
|