@ymy88/pi-subagents 0.0.0-stage → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/CHANGELOG.md +904 -0
  2. package/CONTRIBUTING.md +72 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1170 -2
  5. package/SECURITY.md +95 -0
  6. package/dist/abortable.d.ts +12 -0
  7. package/dist/abortable.js +42 -0
  8. package/dist/agent-color.d.ts +35 -0
  9. package/dist/agent-color.js +123 -0
  10. package/dist/agent-file-toggle.d.ts +133 -0
  11. package/dist/agent-file-toggle.js +287 -0
  12. package/dist/agent-manager.d.ts +552 -0
  13. package/dist/agent-manager.js +1466 -0
  14. package/dist/agent-record-entry.d.ts +96 -0
  15. package/dist/agent-record-entry.js +121 -0
  16. package/dist/agent-runner.d.ts +310 -0
  17. package/dist/agent-runner.js +1195 -0
  18. package/dist/agent-source-badge.d.ts +50 -0
  19. package/dist/agent-source-badge.js +74 -0
  20. package/dist/agent-types.d.ts +119 -0
  21. package/dist/agent-types.js +286 -0
  22. package/dist/child-context.d.ts +2 -0
  23. package/dist/child-context.js +12 -0
  24. package/dist/completion-outbox.d.ts +63 -0
  25. package/dist/completion-outbox.js +104 -0
  26. package/dist/context.d.ts +12 -0
  27. package/dist/context.js +56 -0
  28. package/dist/cross-extension-rpc.d.ts +72 -0
  29. package/dist/cross-extension-rpc.js +153 -0
  30. package/dist/custom-agents.d.ts +77 -0
  31. package/dist/custom-agents.js +396 -0
  32. package/dist/default-agents.d.ts +7 -0
  33. package/dist/default-agents.js +121 -0
  34. package/dist/enabled-models.d.ts +49 -0
  35. package/dist/enabled-models.js +145 -0
  36. package/dist/env.d.ts +6 -0
  37. package/dist/env.js +28 -0
  38. package/dist/group-join.d.ts +32 -0
  39. package/dist/group-join.js +116 -0
  40. package/dist/index.d.ts +50 -0
  41. package/dist/index.js +4111 -0
  42. package/dist/invocation-config.d.ts +108 -0
  43. package/dist/invocation-config.js +83 -0
  44. package/dist/memory.d.ts +53 -0
  45. package/dist/memory.js +165 -0
  46. package/dist/mention-clone.d.ts +79 -0
  47. package/dist/mention-clone.js +155 -0
  48. package/dist/mention.d.ts +81 -0
  49. package/dist/mention.js +131 -0
  50. package/dist/model-resolver.d.ts +36 -0
  51. package/dist/model-resolver.js +95 -0
  52. package/dist/model-scope.d.ts +49 -0
  53. package/dist/model-scope.js +48 -0
  54. package/dist/nested-tools.d.ts +57 -0
  55. package/dist/nested-tools.js +303 -0
  56. package/dist/output-file.d.ts +43 -0
  57. package/dist/output-file.js +153 -0
  58. package/dist/package-resources.d.ts +240 -0
  59. package/dist/package-resources.js +519 -0
  60. package/dist/prompts.d.ts +76 -0
  61. package/dist/prompts.js +121 -0
  62. package/dist/schedule-store.d.ts +38 -0
  63. package/dist/schedule-store.js +155 -0
  64. package/dist/schedule.d.ts +109 -0
  65. package/dist/schedule.js +359 -0
  66. package/dist/settings.d.ts +398 -0
  67. package/dist/settings.js +282 -0
  68. package/dist/skill-loader.d.ts +24 -0
  69. package/dist/skill-loader.js +93 -0
  70. package/dist/status-note.d.ts +61 -0
  71. package/dist/status-note.js +85 -0
  72. package/dist/structured-output.d.ts +61 -0
  73. package/dist/structured-output.js +112 -0
  74. package/dist/types.d.ts +391 -0
  75. package/dist/types.js +5 -0
  76. package/dist/ui/agent-mention.d.ts +82 -0
  77. package/dist/ui/agent-mention.js +187 -0
  78. package/dist/ui/agent-widget.d.ts +219 -0
  79. package/dist/ui/agent-widget.js +585 -0
  80. package/dist/ui/conversation-viewer.d.ts +177 -0
  81. package/dist/ui/conversation-viewer.js +905 -0
  82. package/dist/ui/fleet-list.d.ts +211 -0
  83. package/dist/ui/fleet-list.js +489 -0
  84. package/dist/ui/schedule-menu.d.ts +16 -0
  85. package/dist/ui/schedule-menu.js +94 -0
  86. package/dist/ui/select-item.d.ts +27 -0
  87. package/dist/ui/select-item.js +34 -0
  88. package/dist/ui/viewer-keys.d.ts +22 -0
  89. package/dist/ui/viewer-keys.js +18 -0
  90. package/dist/ui/workflow-card.d.ts +175 -0
  91. package/dist/ui/workflow-card.js +332 -0
  92. package/dist/ui/workflow-dialog.d.ts +305 -0
  93. package/dist/ui/workflow-dialog.js +843 -0
  94. package/dist/ui/workflow-menu.d.ts +60 -0
  95. package/dist/ui/workflow-menu.js +147 -0
  96. package/dist/usage.d.ts +135 -0
  97. package/dist/usage.js +120 -0
  98. package/dist/workflow/collisions.d.ts +95 -0
  99. package/dist/workflow/collisions.js +88 -0
  100. package/dist/workflow/entry.d.ts +32 -0
  101. package/dist/workflow/entry.js +29 -0
  102. package/dist/workflow/host.d.ts +62 -0
  103. package/dist/workflow/host.js +419 -0
  104. package/dist/workflow/journal.d.ts +97 -0
  105. package/dist/workflow/journal.js +120 -0
  106. package/dist/workflow/json-schema.d.ts +52 -0
  107. package/dist/workflow/json-schema.js +112 -0
  108. package/dist/workflow/meta.d.ts +67 -0
  109. package/dist/workflow/meta.js +317 -0
  110. package/dist/workflow/progress.d.ts +224 -0
  111. package/dist/workflow/progress.js +361 -0
  112. package/dist/workflow/runtime.d.ts +334 -0
  113. package/dist/workflow/runtime.js +830 -0
  114. package/dist/workflow/saved.d.ts +102 -0
  115. package/dist/workflow/saved.js +247 -0
  116. package/dist/workflow/task.d.ts +136 -0
  117. package/dist/workflow/task.js +207 -0
  118. package/dist/workflow/tool-description.d.ts +38 -0
  119. package/dist/workflow/tool-description.js +199 -0
  120. package/dist/workflow/worker-source.d.ts +47 -0
  121. package/dist/workflow/worker-source.js +778 -0
  122. package/dist/worktree.d.ts +52 -0
  123. package/dist/worktree.js +164 -0
  124. package/dist/xml.d.ts +10 -0
  125. package/dist/xml.js +12 -0
  126. package/docs/packages.md +211 -0
  127. package/docs/rpc.md +192 -0
  128. package/docs/workflows.md +447 -0
  129. package/examples/agent-tool-description.md +42 -0
  130. package/examples/workflows/compose.js +51 -0
  131. package/examples/workflows/fan-out-audit.js +47 -0
  132. package/examples/workflows/gated-fix.js +60 -0
  133. package/examples/workflows/lib/count-child.js +27 -0
  134. package/examples/workflows/review-panel.js +63 -0
  135. package/examples/workflows/structured-findings.js +78 -0
  136. package/package.json +74 -4
  137. package/src/abortable.ts +43 -0
  138. package/src/agent-color.ts +161 -0
  139. package/src/agent-file-toggle.ts +297 -0
  140. package/src/agent-manager.ts +1735 -0
  141. package/src/agent-record-entry.ts +153 -0
  142. package/src/agent-runner.ts +1468 -0
  143. package/src/agent-source-badge.ts +75 -0
  144. package/src/agent-types.ts +346 -0
  145. package/src/child-context.ts +15 -0
  146. package/src/completion-outbox.ts +116 -0
  147. package/src/context.ts +58 -0
  148. package/src/cross-extension-rpc.ts +221 -0
  149. package/src/custom-agents.ts +452 -0
  150. package/src/default-agents.ts +125 -0
  151. package/src/enabled-models.ts +180 -0
  152. package/src/env.ts +33 -0
  153. package/src/group-join.ts +141 -0
  154. package/src/index.ts +4433 -0
  155. package/src/invocation-config.ts +155 -0
  156. package/src/memory.ts +179 -0
  157. package/src/mention-clone.ts +195 -0
  158. package/src/mention.ts +141 -0
  159. package/src/model-resolver.ts +118 -0
  160. package/src/model-scope.ts +70 -0
  161. package/src/nested-tools.ts +426 -0
  162. package/src/output-file.ts +164 -0
  163. package/src/package-resources.ts +652 -0
  164. package/src/prompts.ts +171 -0
  165. package/src/schedule-store.ts +153 -0
  166. package/src/schedule.ts +386 -0
  167. package/src/settings.ts +650 -0
  168. package/src/skill-loader.ts +102 -0
  169. package/src/status-note.ts +90 -0
  170. package/src/structured-output.ts +130 -0
  171. package/src/types.ts +404 -0
  172. package/src/ui/agent-mention.ts +216 -0
  173. package/src/ui/agent-widget.ts +661 -0
  174. package/src/ui/conversation-viewer.ts +928 -0
  175. package/src/ui/fleet-list.ts +559 -0
  176. package/src/ui/schedule-menu.ts +105 -0
  177. package/src/ui/select-item.ts +45 -0
  178. package/src/ui/viewer-keys.ts +43 -0
  179. package/src/ui/workflow-card.ts +470 -0
  180. package/src/ui/workflow-dialog.ts +1115 -0
  181. package/src/ui/workflow-menu.ts +193 -0
  182. package/src/usage.ts +167 -0
  183. package/src/workflow/collisions.ts +123 -0
  184. package/src/workflow/entry.ts +47 -0
  185. package/src/workflow/host.ts +467 -0
  186. package/src/workflow/journal.ts +164 -0
  187. package/src/workflow/json-schema.ts +129 -0
  188. package/src/workflow/meta.ts +325 -0
  189. package/src/workflow/progress.ts +551 -0
  190. package/src/workflow/runtime.ts +1219 -0
  191. package/src/workflow/saved.ts +259 -0
  192. package/src/workflow/task.ts +302 -0
  193. package/src/workflow/tool-description.ts +200 -0
  194. package/src/workflow/worker-source.ts +781 -0
  195. package/src/worktree.ts +205 -0
  196. package/src/xml.ts +13 -0
@@ -0,0 +1,120 @@
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
+ import { createHash } from "node:crypto";
47
+ import { appendFileSync, readFileSync } from "node:fs";
48
+ /** Stable hash of a call's payload. Field order is fixed here, not by the caller. */
49
+ export function journalKey(input) {
50
+ const canonical = JSON.stringify([
51
+ input.prompt,
52
+ input.label ?? null,
53
+ input.model ?? null,
54
+ input.agentType ?? null,
55
+ input.effort ?? null,
56
+ input.isolation ?? null,
57
+ input.gate ?? null,
58
+ input.resume ?? null,
59
+ // Appended only when present, which looks like a hack and is not: adding a
60
+ // ninth slot unconditionally would change the canonical form of every entry
61
+ // and invalidate every journal already on disk. Conditional, a schema-less
62
+ // call keys exactly as it always did, and adding or changing a schema still
63
+ // produces a different key.
64
+ ...(input.schema !== undefined ? [input.schema] : []),
65
+ ]);
66
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
67
+ }
68
+ /**
69
+ * Read a journal file into position order.
70
+ *
71
+ * Never throws: a missing, truncated or hand-mangled journal means "nothing to
72
+ * replay", which costs tokens. Refusing to run would cost the whole run.
73
+ * A partial last line is normal — the file is appended to while agents settle.
74
+ */
75
+ export function readJournal(path) {
76
+ let raw;
77
+ try {
78
+ raw = readFileSync(path, "utf-8");
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const entries = [];
84
+ for (const line of raw.split("\n")) {
85
+ if (line.trim() === "")
86
+ continue;
87
+ try {
88
+ const parsed = JSON.parse(line);
89
+ if (!isEntry(parsed))
90
+ continue;
91
+ entries.push(parsed);
92
+ }
93
+ catch {
94
+ // A half-written final line, or someone editing the file. Skipping it
95
+ // keeps what came before, and a shorter prefix is still a useful one.
96
+ }
97
+ }
98
+ entries.sort((a, b) => a.index - b.index);
99
+ return entries;
100
+ }
101
+ /** Append one settled call. Failure to write is not failure to run. */
102
+ export function appendJournal(path, entry) {
103
+ try {
104
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8");
105
+ }
106
+ catch {
107
+ // A journal that cannot be written costs a future resume, nothing more.
108
+ }
109
+ }
110
+ function isEntry(value) {
111
+ if (typeof value !== "object" || value === null)
112
+ return false;
113
+ const entry = value;
114
+ return (Number.isInteger(entry.index) &&
115
+ entry.index >= 0 &&
116
+ typeof entry.key === "string" &&
117
+ typeof entry.ok === "boolean" &&
118
+ (entry.text === undefined || typeof entry.text === "string") &&
119
+ (entry.resumed === undefined || entry.resumed === true));
120
+ }
@@ -0,0 +1,52 @@
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.
11
+ * `@sinclair/typebox` (0.34) dispatches on a `Kind` symbol that a schema
12
+ * arriving over the wire does not carry, so `Value.Check` throws `Unknown type`
13
+ * on a plain JSON Schema — and `Type.Unsafe` does not help, it stamps a `Kind`
14
+ * that is not registered. `typebox` v1 is a standards JSON Schema validator and
15
+ * takes the schema as-is. It is also the package pi itself types
16
+ * `ToolDefinition.parameters` against and supplies to extensions at runtime
17
+ * (a peer dependency here), so the same schema object serves both roles with no
18
+ * conversion.
19
+ *
20
+ * ## Why we validate at all
21
+ *
22
+ * Nothing in pi checks a tool call's arguments against the tool's `parameters`.
23
+ * `validateToolCall`/`validateToolArguments` exist in `pi-ai` but are never
24
+ * called from either shipped package, so a schema on a tool is a *prompt to the
25
+ * provider*, not an enforcement point. Every guarantee the script gets about
26
+ * the shape of its result is made here.
27
+ *
28
+ * Pure and pi-free on purpose, so `runtime.ts` can import it without dragging
29
+ * sessions and models into the runtime's tests.
30
+ */
31
+ export interface CompiledSchema {
32
+ /** The schema as given, for the tool's `parameters` and the journal key. */
33
+ readonly schema: Record<string, unknown>;
34
+ /** `true`, or a human-readable account of what is wrong. */
35
+ check(value: unknown): true | string;
36
+ }
37
+ export type SchemaCompilation = {
38
+ ok: true;
39
+ compiled: CompiledSchema;
40
+ } | {
41
+ ok: false;
42
+ message: string;
43
+ };
44
+ /**
45
+ * Turn a script-supplied schema into something we can check against.
46
+ *
47
+ * Rejects up front rather than at the first tool call. A schema whose root is
48
+ * not an object cannot be a tool's input schema at all, so it would break every
49
+ * request the child makes rather than just the last one — and the author should
50
+ * hear about that before a model is paid to discover it.
51
+ */
52
+ export declare function compileJsonSchema(schema: unknown): SchemaCompilation;
@@ -0,0 +1,112 @@
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.
11
+ * `@sinclair/typebox` (0.34) dispatches on a `Kind` symbol that a schema
12
+ * arriving over the wire does not carry, so `Value.Check` throws `Unknown type`
13
+ * on a plain JSON Schema — and `Type.Unsafe` does not help, it stamps a `Kind`
14
+ * that is not registered. `typebox` v1 is a standards JSON Schema validator and
15
+ * takes the schema as-is. It is also the package pi itself types
16
+ * `ToolDefinition.parameters` against and supplies to extensions at runtime
17
+ * (a peer dependency here), so the same schema object serves both roles with no
18
+ * conversion.
19
+ *
20
+ * ## Why we validate at all
21
+ *
22
+ * Nothing in pi checks a tool call's arguments against the tool's `parameters`.
23
+ * `validateToolCall`/`validateToolArguments` exist in `pi-ai` but are never
24
+ * called from either shipped package, so a schema on a tool is a *prompt to the
25
+ * provider*, not an enforcement point. Every guarantee the script gets about
26
+ * the shape of its result is made here.
27
+ *
28
+ * Pure and pi-free on purpose, so `runtime.ts` can import it without dragging
29
+ * sessions and models into the runtime's tests.
30
+ */
31
+ import { Check, Errors } from "typebox/value";
32
+ /** Largest schema we will accept, serialized. */
33
+ const MAX_SCHEMA_BYTES = 64 * 1024;
34
+ /** How many validation errors are quoted back to the model. */
35
+ const MAX_REPORTED_ERRORS = 5;
36
+ /**
37
+ * Turn a script-supplied schema into something we can check against.
38
+ *
39
+ * Rejects up front rather than at the first tool call. A schema whose root is
40
+ * not an object cannot be a tool's input schema at all, so it would break every
41
+ * request the child makes rather than just the last one — and the author should
42
+ * hear about that before a model is paid to discover it.
43
+ */
44
+ export function compileJsonSchema(schema) {
45
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
46
+ return { ok: false, message: "agent() opts.schema must be a JSON Schema object." };
47
+ }
48
+ const root = schema;
49
+ if (root.type !== "object") {
50
+ return {
51
+ ok: false,
52
+ message: 'agent() opts.schema must have `type: "object"` at its root — it becomes the tool\'s input schema, '
53
+ + "and a non-object root is not something a model can be asked to fill.",
54
+ };
55
+ }
56
+ let serialized;
57
+ try {
58
+ serialized = JSON.stringify(root);
59
+ }
60
+ catch {
61
+ return { ok: false, message: "agent() opts.schema must be JSON-serializable." };
62
+ }
63
+ if (serialized.length > MAX_SCHEMA_BYTES) {
64
+ return {
65
+ ok: false,
66
+ message: `agent() opts.schema is too large (${serialized.length} bytes; the limit is ${MAX_SCHEMA_BYTES}).`,
67
+ };
68
+ }
69
+ // Smoke-tested here so a schema the validator cannot walk fails at the call
70
+ // that wrote it, with the schema in hand, rather than inside a child's tool
71
+ // handler where the only symptom is an agent that never returns.
72
+ try {
73
+ Check(root, {});
74
+ }
75
+ catch (error) {
76
+ return {
77
+ ok: false,
78
+ message: `agent() opts.schema is not a schema this runtime can validate: ${error instanceof Error ? error.message : String(error)}`,
79
+ };
80
+ }
81
+ return { ok: true, compiled: { schema: root, check: value => checkAgainst(root, value) } };
82
+ }
83
+ function checkAgainst(schema, value) {
84
+ let valid;
85
+ try {
86
+ valid = Check(schema, value);
87
+ }
88
+ catch (error) {
89
+ // Reported rather than thrown: a schema that compiled but trips on a
90
+ // particular value must fail that call, not the run.
91
+ return `the value could not be validated: ${error instanceof Error ? error.message : String(error)}`;
92
+ }
93
+ if (valid)
94
+ return true;
95
+ const reported = [];
96
+ try {
97
+ for (const error of Errors(schema, value)) {
98
+ // `instancePath` is JSON Pointer (`/a/b`); the model wrote the schema in
99
+ // JavaScript, so it reads `$.a.b` far more easily.
100
+ const path = String(error.instancePath ?? "");
101
+ const where = path === "" ? "$" : `$${path.replace(/\//g, ".")}`;
102
+ reported.push(`${where}: ${error.message}`);
103
+ if (reported.length >= MAX_REPORTED_ERRORS)
104
+ break;
105
+ }
106
+ }
107
+ catch {
108
+ // Errors() can trip where Check() merely returned false. A vaguer message
109
+ // still names the right problem.
110
+ }
111
+ return reported.length > 0 ? reported.join("; ") : "the value does not match the required schema";
112
+ }
@@ -0,0 +1,67 @@
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
+ /** A phase declared up front, so the UI can show it before any agent runs. */
21
+ export interface WorkflowPhaseMeta {
22
+ title: string;
23
+ detail?: string;
24
+ /** Set when a phase pins a model; display-only, the runtime does not read it. */
25
+ model?: string;
26
+ }
27
+ export interface WorkflowMeta {
28
+ name: string;
29
+ description: string;
30
+ /** Shown in the saved-workflow listing. Not used by the runtime. */
31
+ whenToUse?: string;
32
+ phases?: WorkflowPhaseMeta[];
33
+ }
34
+ export interface MetaExtraction {
35
+ meta: WorkflowMeta;
36
+ /**
37
+ * The script with the leading `export ` stripped, so `const meta = {...}`
38
+ * compiles inside the vm. Byte offsets after the keyword are untouched, which
39
+ * keeps stack-trace line numbers aligned with what the author wrote.
40
+ */
41
+ body: string;
42
+ }
43
+ export declare class WorkflowMetaError extends Error {
44
+ }
45
+ /**
46
+ * Whether `source` even claims to be a workflow script.
47
+ *
48
+ * The cheap half of {@link extractMeta}, exported so a directory of `.js` files
49
+ * can be told apart from a directory of workflows without evaluating anything.
50
+ * A saved-workflow folder is a normal folder — it may hold a build artifact, a
51
+ * config, someone's scratch script — and those should neither be offered as
52
+ * workflows nor produce a parser error when named.
53
+ */
54
+ export declare function hasMetaDeclaration(source: string): boolean;
55
+ /**
56
+ * Pull `meta` off the front of a workflow script and hand back the runnable body.
57
+ *
58
+ * Throws {@link WorkflowMetaError} with author-facing guidance for every
59
+ * rejection — these messages are shown verbatim to whoever wrote the script.
60
+ */
61
+ export declare function extractMeta(source: string): MetaExtraction;
62
+ /** The label a `SubagentWorkflow` call renders under, from whichever field it carries. */
63
+ export declare function workflowCallName(args: {
64
+ script?: string;
65
+ scriptPath?: string;
66
+ name?: string;
67
+ }): string;
@@ -0,0 +1,317 @@
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
+ import { createContext, Script } from "node:vm";
21
+ export class WorkflowMetaError extends Error {
22
+ }
23
+ /**
24
+ * Wall-clock bound on evaluating the `meta` fragment. Generous for a literal —
25
+ * this exists only to stop a pathological one from hanging the host thread.
26
+ */
27
+ const META_EVAL_TIMEOUT_MS = 100;
28
+ const PURE_LITERAL_HINT = "The `meta` object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation.";
29
+ /** Matches `export const meta =` allowing arbitrary inner whitespace. */
30
+ const META_DECLARATION = /(^|[\r\n])[ \t]*export[ \t\r\n]+const[ \t\r\n]+meta[ \t\r\n]*=/;
31
+ /**
32
+ * Whether `source` even claims to be a workflow script.
33
+ *
34
+ * The cheap half of {@link extractMeta}, exported so a directory of `.js` files
35
+ * can be told apart from a directory of workflows without evaluating anything.
36
+ * A saved-workflow folder is a normal folder — it may hold a build artifact, a
37
+ * config, someone's scratch script — and those should neither be offered as
38
+ * workflows nor produce a parser error when named.
39
+ */
40
+ export function hasMetaDeclaration(source) {
41
+ return META_DECLARATION.test(source);
42
+ }
43
+ /**
44
+ * Find the index just past the object literal that starts at `open`.
45
+ *
46
+ * Tracks string, template, comment, and regex context so braces inside them do
47
+ * not move the depth counter.
48
+ */
49
+ function scanObjectLiteral(source, open) {
50
+ let depth = 0;
51
+ let i = open;
52
+ let sawInterpolation = false;
53
+ // What we are currently inside of. "code" means brace counting is live.
54
+ let mode = "code";
55
+ // Template literals nest: `${ {a:1} }` re-enters code, and the closing brace
56
+ // of that substitution must not be read as the object's. One depth per level.
57
+ const templateStack = [];
58
+ while (i < source.length) {
59
+ const c = source[i];
60
+ const next = source[i + 1];
61
+ if (mode === "line-comment") {
62
+ if (c === "\n")
63
+ mode = "code";
64
+ i++;
65
+ continue;
66
+ }
67
+ if (mode === "block-comment") {
68
+ if (c === "*" && next === "/") {
69
+ mode = "code";
70
+ i += 2;
71
+ continue;
72
+ }
73
+ i++;
74
+ continue;
75
+ }
76
+ if (mode === "single" || mode === "double" || mode === "regex") {
77
+ if (c === "\\") {
78
+ i += 2;
79
+ continue;
80
+ }
81
+ if (mode === "single" && c === "'")
82
+ mode = "code";
83
+ else if (mode === "double" && c === '"')
84
+ mode = "code";
85
+ else if (mode === "regex" && c === "/")
86
+ mode = "code";
87
+ // An unterminated regex/string can't run past a newline; bail to code so a
88
+ // misdetected regex (see below) cannot swallow the rest of the literal.
89
+ else if (c === "\n" && mode !== "double")
90
+ mode = "code";
91
+ i++;
92
+ continue;
93
+ }
94
+ if (mode === "template") {
95
+ if (c === "\\") {
96
+ i += 2;
97
+ continue;
98
+ }
99
+ if (c === "`") {
100
+ mode = "code";
101
+ i++;
102
+ continue;
103
+ }
104
+ if (c === "$" && next === "{") {
105
+ sawInterpolation = true;
106
+ templateStack.push(depth);
107
+ depth++;
108
+ mode = "code";
109
+ i += 2;
110
+ continue;
111
+ }
112
+ i++;
113
+ continue;
114
+ }
115
+ // mode === "code"
116
+ if (c === "/" && next === "/") {
117
+ mode = "line-comment";
118
+ i += 2;
119
+ continue;
120
+ }
121
+ if (c === "/" && next === "*") {
122
+ mode = "block-comment";
123
+ i += 2;
124
+ continue;
125
+ }
126
+ if (c === "'") {
127
+ mode = "single";
128
+ i++;
129
+ continue;
130
+ }
131
+ if (c === '"') {
132
+ mode = "double";
133
+ i++;
134
+ continue;
135
+ }
136
+ if (c === "`") {
137
+ mode = "template";
138
+ i++;
139
+ continue;
140
+ }
141
+ if (c === "/" && isRegexPosition(source, i)) {
142
+ mode = "regex";
143
+ i++;
144
+ continue;
145
+ }
146
+ if (c === "{") {
147
+ depth++;
148
+ i++;
149
+ continue;
150
+ }
151
+ if (c === "}") {
152
+ depth--;
153
+ i++;
154
+ if (templateStack.length > 0 && depth === templateStack[templateStack.length - 1]) {
155
+ templateStack.pop();
156
+ mode = "template";
157
+ continue;
158
+ }
159
+ if (depth === 0)
160
+ return { end: i, sawInterpolation };
161
+ continue;
162
+ }
163
+ i++;
164
+ }
165
+ return { end: -1, sawInterpolation };
166
+ }
167
+ /**
168
+ * Decide whether the `/` at `i` opens a regex literal rather than a division.
169
+ *
170
+ * Walks back past whitespace and comments to the previous significant char: a
171
+ * regex can only follow an operator or opener, never a value. This is the usual
172
+ * heuristic and it is sufficient here, because the only thing riding on it is
173
+ * not miscounting braces inside a `meta` literal — and a `meta` literal
174
+ * containing division is already not a pure literal.
175
+ */
176
+ function isRegexPosition(source, i) {
177
+ let j = i - 1;
178
+ while (j >= 0 && /\s/.test(source[j]))
179
+ j--;
180
+ if (j < 0)
181
+ return true;
182
+ const prev = source[j];
183
+ // Identifier/number/closer before `/` means division.
184
+ return !/[\w$)\]]/.test(prev);
185
+ }
186
+ function fail(message) {
187
+ throw new WorkflowMetaError(message);
188
+ }
189
+ function assertPhases(value) {
190
+ if (value === undefined)
191
+ return undefined;
192
+ if (!Array.isArray(value))
193
+ fail("`meta.phases` must be an array of { title, detail?, model? } objects.");
194
+ return value.map((entry, index) => {
195
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
196
+ fail(`\`meta.phases[${index}]\` must be an object with a \`title\`.`);
197
+ }
198
+ const { title, detail, model } = entry;
199
+ if (typeof title !== "string" || title.trim() === "") {
200
+ fail(`\`meta.phases[${index}].title\` must be a non-empty string.`);
201
+ }
202
+ if (detail !== undefined && typeof detail !== "string") {
203
+ fail(`\`meta.phases[${index}].detail\` must be a string.`);
204
+ }
205
+ if (model !== undefined && typeof model !== "string") {
206
+ fail(`\`meta.phases[${index}].model\` must be a string.`);
207
+ }
208
+ return { title, ...(detail !== undefined ? { detail } : {}), ...(model !== undefined ? { model } : {}) };
209
+ });
210
+ }
211
+ /**
212
+ * Pull `meta` off the front of a workflow script and hand back the runnable body.
213
+ *
214
+ * Throws {@link WorkflowMetaError} with author-facing guidance for every
215
+ * rejection — these messages are shown verbatim to whoever wrote the script.
216
+ */
217
+ export function extractMeta(source) {
218
+ const declaration = META_DECLARATION.exec(source);
219
+ if (!declaration) {
220
+ fail("A workflow script must begin with `export const meta = { name, description }`.\n" +
221
+ PURE_LITERAL_HINT);
222
+ }
223
+ const open = source.indexOf("{", declaration.index + declaration[0].length);
224
+ if (open === -1)
225
+ fail("`export const meta` must be assigned an object literal.\n" + PURE_LITERAL_HINT);
226
+ const { end: close, sawInterpolation } = scanObjectLiteral(source, open);
227
+ if (close === -1)
228
+ fail("`meta` object literal is never closed — check for an unbalanced `{`.");
229
+ // Caught here rather than by evaluation: a self-contained substitution such as
230
+ // `` `a${1 + 1}b` `` resolves without touching a single global, so it would
231
+ // sail through the empty-context check below and silently produce "a2b".
232
+ if (sawInterpolation) {
233
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: quoting the syntax being rejected
234
+ fail("`meta` must not use template interpolation (`${...}`).\n" + PURE_LITERAL_HINT);
235
+ }
236
+ const fragment = source.slice(open, close);
237
+ let value;
238
+ try {
239
+ // Empty context: a pure literal needs no globals, so anything reaching for
240
+ // one (a variable, a helper call) throws here and is reported as impure.
241
+ //
242
+ // The timeout is not belt-and-braces. An IIFE needs no globals either, so
243
+ // `name: (() => { while (true); })()` is evaluable — and this runs on the
244
+ // host thread, before the script ever reaches the worker. Without a bound it
245
+ // would wedge pi itself. `timeout` only governs synchronous execution, which
246
+ // is all a literal can contain.
247
+ value = new Script(`(${fragment})`, { filename: "workflow-meta.js" })
248
+ .runInContext(createContext({}), { timeout: META_EVAL_TIMEOUT_MS });
249
+ }
250
+ catch (error) {
251
+ const detail = error instanceof Error ? error.message : String(error);
252
+ if (/timed out|Script execution/i.test(detail)) {
253
+ fail(`\`meta\` did not finish evaluating within ${META_EVAL_TIMEOUT_MS}ms — it must be a literal, not a computation.\n` +
254
+ PURE_LITERAL_HINT);
255
+ }
256
+ fail(`\`meta\` could not be evaluated: ${detail}\n${PURE_LITERAL_HINT}`);
257
+ }
258
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
259
+ fail("`meta` must be an object literal.\n" + PURE_LITERAL_HINT);
260
+ }
261
+ const raw = value;
262
+ if (typeof raw.name !== "string" || raw.name.trim() === "") {
263
+ fail("`meta.name` is required and must be a non-empty string.");
264
+ }
265
+ if (typeof raw.description !== "string" || raw.description.trim() === "") {
266
+ fail("`meta.description` is required and must be a non-empty string.");
267
+ }
268
+ if (raw.whenToUse !== undefined && typeof raw.whenToUse !== "string") {
269
+ fail("`meta.whenToUse` must be a string.");
270
+ }
271
+ const phases = assertPhases(raw.phases);
272
+ const meta = {
273
+ name: raw.name,
274
+ description: raw.description,
275
+ ...(raw.whenToUse !== undefined ? { whenToUse: raw.whenToUse } : {}),
276
+ ...(phases !== undefined ? { phases } : {}),
277
+ };
278
+ // Strip only the `export ` keyword. Replacing it with spaces rather than
279
+ // deleting it keeps every subsequent offset — and therefore every reported
280
+ // line and column — identical to the source the author wrote.
281
+ const exportAt = source.indexOf("export", declaration.index);
282
+ const body = `${source.slice(0, exportAt)}${" ".repeat(6)}${source.slice(exportAt + 6)}`;
283
+ return { meta, body };
284
+ }
285
+ /**
286
+ * `meta.name` for a call line, without re-parsing on every frame.
287
+ *
288
+ * `renderCall` runs on every repaint, and extraction evaluates a literal in a
289
+ * vm — cheap, but not free at that cadence. Keyed by the exact source, so an
290
+ * edit-and-rerun cycle re-reads it and a hit is always the answer a fresh parse
291
+ * would give.
292
+ */
293
+ const workflowNames = new Map();
294
+ /** The label a `SubagentWorkflow` call renders under, from whichever field it carries. */
295
+ export function workflowCallName(args) {
296
+ const source = args.script;
297
+ if (source === undefined || source === "") {
298
+ // A path-only call would need a synchronous file read per repaint to do
299
+ // better than this, and the file name is what the author will recognize.
300
+ if (args.scriptPath !== undefined)
301
+ return args.scriptPath.split(/[/\\]/).pop() ?? "workflow";
302
+ // A saved workflow is already named by the caller; no read needed at all.
303
+ return args.name !== undefined && args.name !== "" ? args.name : "workflow";
304
+ }
305
+ const cached = workflowNames.get(source);
306
+ if (cached !== undefined)
307
+ return cached;
308
+ let name = "workflow";
309
+ try {
310
+ name = extractMeta(source).meta.name;
311
+ }
312
+ catch {
313
+ // An invalid script still gets a call line; `execute` reports why.
314
+ }
315
+ workflowNames.set(source, name);
316
+ return name;
317
+ }