@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,142 @@
1
+ /**
2
+ * json-schema.ts — validating a script-supplied JSON Schema.
3
+ *
4
+ * `agent(prompt, { schema })` hands us a raw JSON Schema written by a model, to
5
+ * be used two ways: as a tool's `parameters` (so the provider fills the fields)
6
+ * and as the check that decides whether what came back is usable.
7
+ *
8
+ * ## Which typebox
9
+ *
10
+ * **`typebox`, not `@sinclair/typebox`.** They are different packages and both
11
+ * are installed here. `@sinclair/typebox` (0.34) dispatches on a `Kind` symbol
12
+ * that a schema arriving over the wire does not carry, so `Value.Check` throws
13
+ * `Unknown type` on a plain JSON Schema — and `Type.Unsafe` does not help, it
14
+ * stamps a `Kind` that is not registered. `typebox` v1 is a standards JSON
15
+ * Schema validator and takes the schema as-is. It is also the package pi itself
16
+ * types `ToolDefinition.parameters` against, so the same schema object serves
17
+ * both roles with no conversion.
18
+ *
19
+ * ## Why we validate at all
20
+ *
21
+ * Nothing in pi checks a tool call's arguments against the tool's `parameters`.
22
+ * `validateToolCall`/`validateToolArguments` exist in `pi-ai` but are never
23
+ * called from either shipped package, so a schema on a tool is a *prompt to the
24
+ * provider*, not an enforcement point. Every guarantee the script gets about
25
+ * the shape of its result is made here.
26
+ *
27
+ * Pure and pi-free on purpose, so `runtime.ts` can import it without dragging
28
+ * sessions and models into the runtime's tests.
29
+ */
30
+
31
+ import { Check, Errors } from "typebox/value"
32
+
33
+ /** Largest schema we will accept, serialized. */
34
+ const MAX_SCHEMA_BYTES = 64 * 1024
35
+
36
+ /** How many validation errors are quoted back to the model. */
37
+ const MAX_REPORTED_ERRORS = 5
38
+
39
+ export interface CompiledSchema {
40
+ /** The schema as given, for the tool's `parameters` and the journal key. */
41
+ readonly schema: Record<string, unknown>
42
+ /** `true`, or a human-readable account of what is wrong. */
43
+ check(value: unknown): true | string
44
+ }
45
+
46
+ export type SchemaCompilation =
47
+ | { ok: true; compiled: CompiledSchema }
48
+ | { ok: false; message: string }
49
+
50
+ /**
51
+ * Turn a script-supplied schema into something we can check against.
52
+ *
53
+ * Rejects up front rather than at the first tool call. A schema whose root is
54
+ * not an object cannot be a tool's input schema at all, so it would break every
55
+ * request the child makes rather than just the last one — and the author should
56
+ * hear about that before a model is paid to discover it.
57
+ */
58
+ export function compileJsonSchema(schema: unknown): SchemaCompilation {
59
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
60
+ return {
61
+ ok: false,
62
+ message: "agent() opts.schema must be a JSON Schema object.",
63
+ }
64
+ }
65
+ const root = schema as Record<string, unknown>
66
+ if (root.type !== "object") {
67
+ return {
68
+ ok: false,
69
+ message:
70
+ 'agent() opts.schema must have `type: "object"` at its root — it becomes the tool\'s input schema, ' +
71
+ "and a non-object root is not something a model can be asked to fill.",
72
+ }
73
+ }
74
+
75
+ let serialized: string
76
+ try {
77
+ serialized = JSON.stringify(root)
78
+ } catch {
79
+ return {
80
+ ok: false,
81
+ message: "agent() opts.schema must be JSON-serializable.",
82
+ }
83
+ }
84
+ if (serialized.length > MAX_SCHEMA_BYTES) {
85
+ return {
86
+ ok: false,
87
+ message: `agent() opts.schema is too large (${serialized.length} bytes; the limit is ${MAX_SCHEMA_BYTES}).`,
88
+ }
89
+ }
90
+
91
+ // Smoke-tested here so a schema the validator cannot walk fails at the call
92
+ // that wrote it, with the schema in hand, rather than inside a child's tool
93
+ // handler where the only symptom is an agent that never returns.
94
+ try {
95
+ Check(root, {})
96
+ } catch (error) {
97
+ return {
98
+ ok: false,
99
+ message: `agent() opts.schema is not a schema this runtime can validate: ${
100
+ error instanceof Error ? error.message : String(error)
101
+ }`,
102
+ }
103
+ }
104
+
105
+ return {
106
+ ok: true,
107
+ compiled: { schema: root, check: (value) => checkAgainst(root, value) },
108
+ }
109
+ }
110
+
111
+ function checkAgainst(
112
+ schema: Record<string, unknown>,
113
+ value: unknown,
114
+ ): true | string {
115
+ let valid: boolean
116
+ try {
117
+ valid = Check(schema, value)
118
+ } catch (error) {
119
+ // Reported rather than thrown: a schema that compiled but trips on a
120
+ // particular value must fail that call, not the run.
121
+ return `the value could not be validated: ${error instanceof Error ? error.message : String(error)}`
122
+ }
123
+ if (valid) return true
124
+
125
+ const reported: string[] = []
126
+ try {
127
+ for (const error of Errors(schema, value)) {
128
+ // `instancePath` is JSON Pointer (`/a/b`); the model wrote the schema in
129
+ // JavaScript, so it reads `$.a.b` far more easily.
130
+ const path = String(error.instancePath ?? "")
131
+ const where = path === "" ? "$" : `$${path.replace(/\//g, ".")}`
132
+ reported.push(`${where}: ${error.message}`)
133
+ if (reported.length >= MAX_REPORTED_ERRORS) break
134
+ }
135
+ } catch {
136
+ // Errors() can trip where Check() merely returned false. A vaguer message
137
+ // still names the right problem.
138
+ }
139
+ return reported.length > 0
140
+ ? reported.join("; ")
141
+ : "the value does not match the required schema"
142
+ }
@@ -0,0 +1,401 @@
1
+ /**
2
+ * meta.ts — extract and validate a workflow script's `meta` block.
3
+ *
4
+ * Workflow scripts open with `export const meta = { ... }`, but the script body
5
+ * runs through `node:vm`, which has no module loader — `export` is a syntax
6
+ * error there. The block also has to be readable *before* execution, because the
7
+ * declared phases seed the progress groups the UI renders from the first frame.
8
+ *
9
+ * Claude Code solves this by parsing with acorn and requiring `meta` to be a
10
+ * pure literal (no variables, calls, spreads, or template interpolation). We
11
+ * take the same contract without the dependency: scan to the matching brace,
12
+ * then evaluate *only* that fragment in an empty vm context. A pure literal has
13
+ * nothing to call, so evaluating it cannot reach anything — and anything that
14
+ * isn't a pure literal either throws (unbound identifier) or is rejected below.
15
+ *
16
+ * The scanner is string-, comment-, and regex-aware. That matters: a workflow's
17
+ * `detail` text routinely contains braces, and `phases: [{ title: "a}b" }]` must
18
+ * not terminate the scan early.
19
+ */
20
+
21
+ import { createContext, Script } from "node:vm"
22
+
23
+ /** A phase declared up front, so the UI can show it before any agent runs. */
24
+ export interface WorkflowPhaseMeta {
25
+ title: string
26
+ detail?: string
27
+ /** Set when a phase pins a model; display-only, the runtime does not read it. */
28
+ model?: string
29
+ }
30
+
31
+ export interface WorkflowMeta {
32
+ name: string
33
+ description: string
34
+ /** Shown in the saved-workflow listing. Not used by the runtime. */
35
+ whenToUse?: string
36
+ phases?: WorkflowPhaseMeta[]
37
+ }
38
+
39
+ export interface MetaExtraction {
40
+ meta: WorkflowMeta
41
+ /**
42
+ * The script with the leading `export ` stripped, so `const meta = {...}`
43
+ * compiles inside the vm. Byte offsets after the keyword are untouched, which
44
+ * keeps stack-trace line numbers aligned with what the author wrote.
45
+ */
46
+ body: string
47
+ }
48
+
49
+ export class WorkflowMetaError extends Error {}
50
+
51
+ /**
52
+ * Wall-clock bound on evaluating the `meta` fragment. Generous for a literal —
53
+ * this exists only to stop a pathological one from hanging the host thread.
54
+ */
55
+ const META_EVAL_TIMEOUT_MS = 100
56
+
57
+ const PURE_LITERAL_HINT =
58
+ "The `meta` object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation."
59
+
60
+ /** Matches `export const meta =` allowing arbitrary inner whitespace. */
61
+ const META_DECLARATION =
62
+ /(^|[\r\n])[ \t]*export[ \t\r\n]+const[ \t\r\n]+meta[ \t\r\n]*=/
63
+
64
+ /**
65
+ * Whether `source` even claims to be a workflow script.
66
+ *
67
+ * The cheap half of {@link extractMeta}, exported so a directory of `.js` files
68
+ * can be told apart from a directory of workflows without evaluating anything.
69
+ * A saved-workflow folder is a normal folder — it may hold a build artifact, a
70
+ * config, someone's scratch script — and those should neither be offered as
71
+ * workflows nor produce a parser error when named.
72
+ */
73
+ export function hasMetaDeclaration(source: string): boolean {
74
+ return META_DECLARATION.test(source)
75
+ }
76
+
77
+ interface ScanResult {
78
+ /** Index of the literal's closing brace, or -1 when braces never balance. */
79
+ end: number
80
+ /**
81
+ * True when a `${` substitution opened inside a template literal. Reported
82
+ * separately because such a fragment can still *evaluate* — `` `a${1+1}b` ``
83
+ * needs no globals — so the impure-literal check below cannot catch it.
84
+ */
85
+ sawInterpolation: boolean
86
+ }
87
+
88
+ /**
89
+ * Find the index just past the object literal that starts at `open`.
90
+ *
91
+ * Tracks string, template, comment, and regex context so braces inside them do
92
+ * not move the depth counter.
93
+ */
94
+ function scanObjectLiteral(source: string, open: number): ScanResult {
95
+ let depth = 0
96
+ let i = open
97
+ let sawInterpolation = false
98
+ // What we are currently inside of. "code" means brace counting is live.
99
+ let mode:
100
+ | "code"
101
+ | "line-comment"
102
+ | "block-comment"
103
+ | "single"
104
+ | "double"
105
+ | "template"
106
+ | "regex" = "code"
107
+ // Template literals nest: `${ {a:1} }` re-enters code, and the closing brace
108
+ // of that substitution must not be read as the object's. One depth per level.
109
+ const templateStack: number[] = []
110
+
111
+ while (i < source.length) {
112
+ const c = source[i]
113
+ const next = source[i + 1]
114
+
115
+ if (mode === "line-comment") {
116
+ if (c === "\n") mode = "code"
117
+ i++
118
+ continue
119
+ }
120
+ if (mode === "block-comment") {
121
+ if (c === "*" && next === "/") {
122
+ mode = "code"
123
+ i += 2
124
+ continue
125
+ }
126
+ i++
127
+ continue
128
+ }
129
+ if (mode === "single" || mode === "double" || mode === "regex") {
130
+ if (c === "\\") {
131
+ i += 2
132
+ continue
133
+ }
134
+ if (mode === "single" && c === "'") mode = "code"
135
+ else if (mode === "double" && c === '"') mode = "code"
136
+ else if (mode === "regex" && c === "/") mode = "code"
137
+ // An unterminated regex/string can't run past a newline; bail to code so a
138
+ // misdetected regex (see below) cannot swallow the rest of the literal.
139
+ else if (c === "\n" && mode !== "double") mode = "code"
140
+ i++
141
+ continue
142
+ }
143
+ if (mode === "template") {
144
+ if (c === "\\") {
145
+ i += 2
146
+ continue
147
+ }
148
+ if (c === "`") {
149
+ mode = "code"
150
+ i++
151
+ continue
152
+ }
153
+ if (c === "$" && next === "{") {
154
+ sawInterpolation = true
155
+ templateStack.push(depth)
156
+ depth++
157
+ mode = "code"
158
+ i += 2
159
+ continue
160
+ }
161
+ i++
162
+ continue
163
+ }
164
+
165
+ // mode === "code"
166
+ if (c === "/" && next === "/") {
167
+ mode = "line-comment"
168
+ i += 2
169
+ continue
170
+ }
171
+ if (c === "/" && next === "*") {
172
+ mode = "block-comment"
173
+ i += 2
174
+ continue
175
+ }
176
+ if (c === "'") {
177
+ mode = "single"
178
+ i++
179
+ continue
180
+ }
181
+ if (c === '"') {
182
+ mode = "double"
183
+ i++
184
+ continue
185
+ }
186
+ if (c === "`") {
187
+ mode = "template"
188
+ i++
189
+ continue
190
+ }
191
+ if (c === "/" && isRegexPosition(source, i)) {
192
+ mode = "regex"
193
+ i++
194
+ continue
195
+ }
196
+ if (c === "{") {
197
+ depth++
198
+ i++
199
+ continue
200
+ }
201
+ if (c === "}") {
202
+ depth--
203
+ i++
204
+ if (
205
+ templateStack.length > 0 &&
206
+ depth === templateStack[templateStack.length - 1]
207
+ ) {
208
+ templateStack.pop()
209
+ mode = "template"
210
+ continue
211
+ }
212
+ if (depth === 0) return { end: i, sawInterpolation }
213
+ continue
214
+ }
215
+ i++
216
+ }
217
+ return { end: -1, sawInterpolation }
218
+ }
219
+
220
+ /**
221
+ * Decide whether the `/` at `i` opens a regex literal rather than a division.
222
+ *
223
+ * Walks back past whitespace and comments to the previous significant char: a
224
+ * regex can only follow an operator or opener, never a value. This is the usual
225
+ * heuristic and it is sufficient here, because the only thing riding on it is
226
+ * not miscounting braces inside a `meta` literal — and a `meta` literal
227
+ * containing division is already not a pure literal.
228
+ */
229
+ function isRegexPosition(source: string, i: number): boolean {
230
+ let j = i - 1
231
+ while (j >= 0 && /\s/.test(source[j])) j--
232
+ if (j < 0) return true
233
+ const prev = source[j]
234
+ // Identifier/number/closer before `/` means division.
235
+ return !/[\w$)\]]/.test(prev)
236
+ }
237
+
238
+ function fail(message: string): never {
239
+ throw new WorkflowMetaError(message)
240
+ }
241
+
242
+ function assertPhases(value: unknown): WorkflowPhaseMeta[] | undefined {
243
+ if (value === undefined) return undefined
244
+ if (!Array.isArray(value))
245
+ fail(
246
+ "`meta.phases` must be an array of { title, detail?, model? } objects.",
247
+ )
248
+ return value.map((entry, index) => {
249
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
250
+ fail(`\`meta.phases[${index}]\` must be an object with a \`title\`.`)
251
+ }
252
+ const { title, detail, model } = entry as Record<string, unknown>
253
+ if (typeof title !== "string" || title.trim() === "") {
254
+ fail(`\`meta.phases[${index}].title\` must be a non-empty string.`)
255
+ }
256
+ if (detail !== undefined && typeof detail !== "string") {
257
+ fail(`\`meta.phases[${index}].detail\` must be a string.`)
258
+ }
259
+ if (model !== undefined && typeof model !== "string") {
260
+ fail(`\`meta.phases[${index}].model\` must be a string.`)
261
+ }
262
+ return {
263
+ title,
264
+ ...(detail !== undefined ? { detail } : {}),
265
+ ...(model !== undefined ? { model } : {}),
266
+ }
267
+ })
268
+ }
269
+
270
+ /**
271
+ * Pull `meta` off the front of a workflow script and hand back the runnable body.
272
+ *
273
+ * Throws {@link WorkflowMetaError} with author-facing guidance for every
274
+ * rejection — these messages are shown verbatim to whoever wrote the script.
275
+ */
276
+ export function extractMeta(source: string): MetaExtraction {
277
+ const declaration = META_DECLARATION.exec(source)
278
+ if (!declaration) {
279
+ fail(
280
+ "A workflow script must begin with `export const meta = { name, description }`.\n" +
281
+ PURE_LITERAL_HINT,
282
+ )
283
+ }
284
+
285
+ const open = source.indexOf("{", declaration.index + declaration[0].length)
286
+ if (open === -1)
287
+ fail(
288
+ "`export const meta` must be assigned an object literal.\n" +
289
+ PURE_LITERAL_HINT,
290
+ )
291
+
292
+ const { end: close, sawInterpolation } = scanObjectLiteral(source, open)
293
+ if (close === -1)
294
+ fail("`meta` object literal is never closed — check for an unbalanced `{`.")
295
+
296
+ // Caught here rather than by evaluation: a self-contained substitution such as
297
+ // `` `a${1 + 1}b` `` resolves without touching a single global, so it would
298
+ // sail through the empty-context check below and silently produce "a2b".
299
+ if (sawInterpolation) {
300
+ fail(
301
+ "`meta` must not use template interpolation (`$" +
302
+ "{...}`).\n" +
303
+ PURE_LITERAL_HINT,
304
+ )
305
+ }
306
+
307
+ const fragment = source.slice(open, close)
308
+ let value: unknown
309
+ try {
310
+ // Empty context: a pure literal needs no globals, so anything reaching for
311
+ // one (a variable, a helper call) throws here and is reported as impure.
312
+ //
313
+ // The timeout is not belt-and-braces. An IIFE needs no globals either, so
314
+ // `name: (() => { while (true); })()` is evaluable — and this runs on the
315
+ // host thread, before the script ever reaches the worker. Without a bound it
316
+ // would wedge pi itself. `timeout` only governs synchronous execution, which
317
+ // is all a literal can contain.
318
+ value = new Script(`(${fragment})`, {
319
+ filename: "workflow-meta.js",
320
+ }).runInContext(createContext({}), { timeout: META_EVAL_TIMEOUT_MS })
321
+ } catch (error) {
322
+ const detail = error instanceof Error ? error.message : String(error)
323
+ if (/timed out|Script execution/i.test(detail)) {
324
+ fail(
325
+ `\`meta\` did not finish evaluating within ${META_EVAL_TIMEOUT_MS}ms — it must be a literal, not a computation.\n` +
326
+ PURE_LITERAL_HINT,
327
+ )
328
+ }
329
+ fail(`\`meta\` could not be evaluated: ${detail}\n${PURE_LITERAL_HINT}`)
330
+ }
331
+
332
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
333
+ fail("`meta` must be an object literal.\n" + PURE_LITERAL_HINT)
334
+ }
335
+ const raw = value as Record<string, unknown>
336
+
337
+ if (typeof raw.name !== "string" || raw.name.trim() === "") {
338
+ fail("`meta.name` is required and must be a non-empty string.")
339
+ }
340
+ if (typeof raw.description !== "string" || raw.description.trim() === "") {
341
+ fail("`meta.description` is required and must be a non-empty string.")
342
+ }
343
+ if (raw.whenToUse !== undefined && typeof raw.whenToUse !== "string") {
344
+ fail("`meta.whenToUse` must be a string.")
345
+ }
346
+ const phases = assertPhases(raw.phases)
347
+
348
+ const meta: WorkflowMeta = {
349
+ name: raw.name,
350
+ description: raw.description,
351
+ ...(raw.whenToUse !== undefined
352
+ ? { whenToUse: raw.whenToUse as string }
353
+ : {}),
354
+ ...(phases !== undefined ? { phases } : {}),
355
+ }
356
+
357
+ // Strip only the `export ` keyword. Replacing it with spaces rather than
358
+ // deleting it keeps every subsequent offset — and therefore every reported
359
+ // line and column — identical to the source the author wrote.
360
+ const exportAt = source.indexOf("export", declaration.index)
361
+ const body = `${source.slice(0, exportAt)}${" ".repeat(6)}${source.slice(exportAt + 6)}`
362
+
363
+ return { meta, body }
364
+ }
365
+
366
+ /**
367
+ * `meta.name` for a call line, without re-parsing on every frame.
368
+ *
369
+ * `renderCall` runs on every repaint, and extraction evaluates a literal in a
370
+ * vm — cheap, but not free at that cadence. Keyed by the exact source, so an
371
+ * edit-and-rerun cycle re-reads it and a hit is always the answer a fresh parse
372
+ * would give.
373
+ */
374
+ const workflowNames = new Map<string, string>()
375
+
376
+ /** The label a `SubagentWorkflow` call renders under, from whichever field it carries. */
377
+ export function workflowCallName(args: {
378
+ script?: string
379
+ scriptPath?: string
380
+ name?: string
381
+ }): string {
382
+ const source = args.script
383
+ if (source === undefined || source === "") {
384
+ // A path-only call would need a synchronous file read per repaint to do
385
+ // better than this, and the file name is what the author will recognize.
386
+ if (args.scriptPath !== undefined)
387
+ return args.scriptPath.split(/[/\\]/).pop() ?? "workflow"
388
+ // A saved workflow is already named by the caller; no read needed at all.
389
+ return args.name !== undefined && args.name !== "" ? args.name : "workflow"
390
+ }
391
+ const cached = workflowNames.get(source)
392
+ if (cached !== undefined) return cached
393
+ let name = "workflow"
394
+ try {
395
+ name = extractMeta(source).meta.name
396
+ } catch {
397
+ // An invalid script still gets a call line; `execute` reports why.
398
+ }
399
+ workflowNames.set(source, name)
400
+ return name
401
+ }