@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9

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 (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -20,6 +20,7 @@
20
20
  * unit-testable without a terminal.
21
21
  */
22
22
 
23
+ import type { TierDisclosure } from "../model-tiers.js";
23
24
  import type { WorkflowMeta, WorkflowPhaseMeta } from "./meta.js";
24
25
 
25
26
  /** Raw entry lifecycle, as written by the runtime. */
@@ -93,6 +94,9 @@ export interface WorkflowAgentEntry {
93
94
  * it.
94
95
  */
95
96
  requestedThinking?: string;
97
+ /** `Note:` line set when the child ran on the parent model because its configured one did not resolve. */
98
+ modelNote?: string;
99
+ modelTier?: TierDisclosure;
96
100
  fallbackModel?: string;
97
101
  isolation?: "worktree";
98
102
  error?: string;
@@ -510,11 +514,12 @@ export function gerund(word: string): string {
510
514
 
511
515
  // Short consonant-vowel-consonant words double the final consonant: run →
512
516
  // running. `w`, `x` and `y` never double.
513
- const last = lower.at(-1) ?? "";
517
+ // GERUND_CANDIDATE guarantees at least three letters, so every index exists.
518
+ const last = lower[lower.length - 1];
514
519
  if (
515
520
  lower.length <= 4 &&
516
- !VOWELS.includes(lower.at(-3) ?? "") &&
517
- VOWELS.includes(lower.at(-2) ?? "") &&
521
+ !VOWELS.includes(lower[lower.length - 3]) &&
522
+ VOWELS.includes(lower[lower.length - 2]) &&
518
523
  !VOWELS.includes(last) &&
519
524
  !"wxy".includes(last)
520
525
  ) {
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { cpus } from "node:os";
17
17
  import { Worker } from "node:worker_threads";
18
+ import type { TierDisclosure } from "../model-tiers.js";
18
19
  import { type JournalKeyInput, journalKey, type WorkflowJournalEntry } from "./journal.js";
19
20
  import { type CompiledSchema, compileJsonSchema } from "./json-schema.js";
20
21
  import { extractMeta, type WorkflowMeta } from "./meta.js";
@@ -95,6 +96,9 @@ export interface WorkflowSpawnRequest {
95
96
  modelId?: string;
96
97
  thinking?: string;
97
98
  requestedThinking?: string;
99
+ /** `Note:` line when the child ran on the parent model because its configured one did not resolve. */
100
+ modelNote?: string;
101
+ modelTier?: TierDisclosure;
98
102
  }): void;
99
103
  /**
100
104
  * Compiled from the script's `agent({ schema })`.
@@ -125,6 +129,9 @@ export interface WorkflowSpawnResult {
125
129
  text?: string;
126
130
  /** Why it failed. Present when not `ok`. */
127
131
  error?: string;
132
+ /** `Note:` line when the child ran on a different model than configured; also leads `text`/`error` (not a schema'd payload). */
133
+ modelNote?: string;
134
+ modelTier?: TierDisclosure;
128
135
  /** The user dismissed it rather than it failing; renders as skipped. */
129
136
  skipped?: boolean;
130
137
  tokens?: number;
@@ -146,8 +153,8 @@ export interface WorkflowSpawnResult {
146
153
  * verifies the wrong working copy. Left unset, a gate runs wherever the host
147
154
  * runs commands by default.
148
155
  *
149
- * Usually unset for a worktree child even so: the copy is removed during the
150
- * child's own settle, so it no longer exists by the time this is read. That
156
+ * Usually unset for a worktree child even so: the copy is normally removed during
157
+ * the child's own settle, so it no longer exists by the time this is read. That
151
158
  * is what {@link gate} is for.
152
159
  */
153
160
  cwd?: string;
@@ -275,6 +282,19 @@ export interface RunWorkflowOptions {
275
282
  signal?: AbortSignal;
276
283
  /** Fired per batch, not per entry — see the worker's progress batching. */
277
284
  onProgress?(entries: readonly WorkflowEntry[]): void;
285
+ /**
286
+ * Receives optional Istanbul counters authored inside a workflow VM.
287
+ *
288
+ * The runtime itself never instruments scripts. Test producers may pass
289
+ * instrumented source and merge these counters into their own collector,
290
+ * retaining worker/VM execution rather than measuring a substitute runner.
291
+ */
292
+ onScriptCoverage?(coverage: unknown): void;
293
+ /**
294
+ * Trusted caller-side transform applied after the workflow's metadata has
295
+ * passed validation and before its body enters the worker VM.
296
+ */
297
+ transformScriptBody?(input: { script: string; body: string }): string;
278
298
  concurrency?: number;
279
299
  agentCap?: number;
280
300
  itemCap?: number;
@@ -315,6 +335,8 @@ export interface WorkflowRunResult {
315
335
  /** The script's return value, JSON-checked at the boundary. */
316
336
  value?: unknown;
317
337
  error?: string;
338
+ /** Authored-source stack, kept separately from the concise failure message. */
339
+ errorStack?: string;
318
340
  /** The append-only log, in emission order. */
319
341
  progress: WorkflowEntry[];
320
342
  /** Agents scheduled, including those that failed. */
@@ -348,7 +370,8 @@ function walk(value: unknown, path: string, seen: Set<object>): void {
348
370
  if (kind === "bigint") throw boundaryError("a BigInt", path);
349
371
  if (kind === "symbol") throw boundaryError("a symbol", path);
350
372
  if (kind === "function") throw boundaryError("a function", path);
351
- if (kind !== "object") throw boundaryError(`a ${kind}`, path);
373
+ // Every other `typeof` result is handled above; a new one fails `tsc` here.
374
+ kind satisfies "object";
352
375
 
353
376
  const object = value as object;
354
377
  if (seen.has(object)) throw boundaryError("a circular structure", path);
@@ -448,7 +471,7 @@ interface AgentCallPayload {
448
471
  type WorkerMessage =
449
472
  | { type: "call"; callId: number; method: string; payload: AgentCallPayload }
450
473
  | { type: "progress"; entries: WorkflowEntry[] }
451
- | { type: "complete"; resultJson?: string }
474
+ | { type: "complete"; resultJson?: string; coverage?: unknown }
452
475
  | { type: "error"; message: string; stack?: string };
453
476
 
454
477
  /** Everything below 0x20 except tab, newline and carriage return, plus DEL. */
@@ -586,7 +609,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
586
609
 
587
610
  assertBoundarySafe(options.args, "args");
588
611
 
589
- const { meta, body } = validateScript(script);
612
+ const { meta, body: validatedBody } = validateScript(script);
613
+ const body = options.transformScriptBody?.({ script, body: validatedBody }) ?? validatedBody;
590
614
  const agentCap = options.agentCap ?? WORKFLOW_AGENT_CAP;
591
615
  const itemCap = options.itemCap ?? WORKFLOW_ITEM_CAP;
592
616
  const semaphore = new Semaphore(options.concurrency ?? workflowConcurrency());
@@ -723,24 +747,24 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
723
747
  argsJson: options.args === undefined ? undefined : JSON.stringify(options.args),
724
748
  itemCap,
725
749
  nestedCap: options.nestedCap ?? WORKFLOW_NESTED_CAP,
750
+ collectCoverage: options.onScriptCoverage !== undefined,
726
751
  },
727
752
  });
728
753
 
729
754
  return await new Promise<WorkflowRunResult>(resolve => {
730
755
  const emit = (entries: WorkflowEntry[]) => {
731
- if (entries.length === 0) return;
732
756
  progress.push(...entries);
733
757
  options.onProgress?.(entries);
734
758
  };
735
759
 
736
- const respond = (callId: number, ok: boolean, value?: unknown, error?: string, fatal?: boolean) => {
760
+ const respond = (callId: number, ok: boolean, value?: unknown, error?: string, fatal?: boolean, modelNote?: string) => {
737
761
  // Cleared before the settled check: a launch answered by a run that is
738
762
  // already finishing is not an unawaited launch either.
739
763
  openLaunches.delete(callId);
740
764
  if (settled) return;
741
765
  // `spent` rides on every response, so the worker's `budget.spent()` is a
742
766
  // mirror of this number rather than a second tally of its own.
743
- worker.postMessage({ type: "response", callId, ok, value, error, fatal, spent: spentOutputTokens });
767
+ worker.postMessage({ type: "response", callId, ok, value, error, fatal, modelNote, spent: spentOutputTokens });
744
768
  };
745
769
 
746
770
  const finish = (result: Omit<WorkflowRunResult, "meta" | "progress" | "agentCount" | "replayedCount">) => {
@@ -908,6 +932,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
908
932
  // dialog to annotate it "from resume journal" rather than letting a
909
933
  // 0ms agent look like one that did the work impossibly fast.
910
934
  cached: true,
935
+ ...(replayed.modelNote !== undefined ? { modelNote: replayed.modelNote } : {}),
936
+ ...(replayed.modelTier ? { modelTier: replayed.modelTier } : {}),
911
937
  resultPreview: preview(replayedText),
912
938
  },
913
939
  ]);
@@ -915,8 +941,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
915
941
  // Re-recorded so this run's journal is complete on its own terms: a
916
942
  // resume of a resume must not have to walk back through a chain of
917
943
  // earlier files to find the prefix.
918
- recordJournal?.({ index, key: replayed.key, ok: true, text: replayedText });
919
- respond(callId, true, replayedText);
944
+ recordJournal?.({ index, key: replayed.key, ok: true, text: replayedText, ...(replayed.modelNote !== undefined ? { modelNote: replayed.modelNote } : {}), ...(replayed.modelTier ? { modelTier: replayed.modelTier } : {}) });
945
+ respond(callId, true, replayedText, undefined, undefined, replayed.modelNote);
920
946
  return;
921
947
  }
922
948
 
@@ -992,12 +1018,16 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
992
1018
  modelId?: string;
993
1019
  thinking?: string;
994
1020
  requestedThinking?: string;
1021
+ modelNote?: string;
1022
+ modelTier?: TierDisclosure;
995
1023
  }) => {
996
1024
  if (info.recordId !== undefined) base.recordId = info.recordId;
997
1025
  if (info.modelName !== undefined) base.model = info.modelName;
998
1026
  if (info.modelId !== undefined) base.modelId = info.modelId;
999
1027
  if (info.thinking !== undefined) base.thinking = info.thinking;
1000
1028
  if (info.requestedThinking !== undefined) base.requestedThinking = info.requestedThinking;
1029
+ if (info.modelNote !== undefined) base.modelNote = info.modelNote;
1030
+ if (info.modelTier !== undefined) base.modelTier = info.modelTier;
1001
1031
  // `base.state` is still "start", so emitting after the row reached a
1002
1032
  // terminal state would revert it to running under last-write-wins.
1003
1033
  // Not reachable from this repo's host, which reports during startup
@@ -1091,13 +1121,15 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1091
1121
  durationMs: finishedAt - startedAt,
1092
1122
  ...(result.tokens !== undefined ? { tokens: result.tokens } : {}),
1093
1123
  ...(result.toolCalls !== undefined ? { toolCalls: result.toolCalls } : {}),
1124
+ ...(result.modelNote !== undefined ? { modelNote: result.modelNote } : {}),
1125
+ ...(result.modelTier ? { modelTier: result.modelTier } : {}),
1094
1126
  };
1095
1127
 
1096
1128
  if (result.ok) {
1097
1129
  const text = result.text ?? "";
1098
1130
  emit([{ ...common, state: "done", resultPreview: preview(text) }]);
1099
- recordJournal?.({ index, key, ok: true, text, ...resumeMark });
1100
- respond(callId, true, text);
1131
+ recordJournal?.({ index, key, ok: true, text, ...(result.modelNote !== undefined ? { modelNote: result.modelNote } : {}), ...(result.modelTier ? { modelTier: result.modelTier } : {}), ...resumeMark });
1132
+ respond(callId, true, text, undefined, undefined, result.modelNote);
1101
1133
  return;
1102
1134
  }
1103
1135
  // Recorded as a failure rather than left out: a gap would be read as an
@@ -1118,7 +1150,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1118
1150
  ...(result.skipped ? { skipped: true } : {}),
1119
1151
  },
1120
1152
  ]);
1121
- respond(callId, true, null);
1153
+ respond(callId, true, null, undefined, undefined, result.modelNote);
1122
1154
  return;
1123
1155
  }
1124
1156
  } finally {
@@ -1157,7 +1189,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1157
1189
  respond(callId, true, {
1158
1190
  name: child.meta.name,
1159
1191
  metaJson: JSON.stringify(child.meta),
1160
- body: child.body,
1192
+ body: options.transformScriptBody?.({ script: source.script, body: child.body }) ?? child.body,
1161
1193
  });
1162
1194
  } catch (error) {
1163
1195
  respond(callId, false, undefined, error instanceof Error ? error.message : String(error));
@@ -1182,6 +1214,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1182
1214
  void handleAgent(message.callId, message.payload as AgentCallPayload);
1183
1215
  break;
1184
1216
  case "complete": {
1217
+ if (message.coverage !== undefined) options.onScriptCoverage?.(message.coverage);
1185
1218
  // The script is done, so every launch it made should have been
1186
1219
  // answered by now — a response is sent before the worker can post
1187
1220
  // this, so anything still open was never awaited. finish() aborts
@@ -1198,7 +1231,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1198
1231
  break;
1199
1232
  }
1200
1233
  case "error":
1201
- finish({ status: "failed", error: message.message });
1234
+ finish({ status: "failed", error: message.message, errorStack: message.stack });
1202
1235
  break;
1203
1236
  }
1204
1237
  });
@@ -39,7 +39,7 @@
39
39
 
40
40
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
41
41
  import { isAbsolute, join } from "node:path";
42
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
42
+ import { resolveAgentDir } from "../home-paths.js";
43
43
  import { isSymlink, isUnsafeName, safeReadFile } from "../memory.js";
44
44
  import { isPackageResourceExcluded, packageWorkflowDirs, packageWorkflowFiles } from "../package-resources.js";
45
45
  import { hasMetaDeclaration } from "./meta.js";
@@ -61,12 +61,12 @@ const WORKFLOW_EXTENSION = ".js";
61
61
  * reaches this without an options bag travelling through `resolveWorkflowScript`,
62
62
  * `resolveWorkflowSource` and `readSavedWorkflow` purely to be consulted once.
63
63
  */
64
- export function savedWorkflowRoots(cwd: string): string[] {
64
+ export function savedWorkflowRoots(cwd: string, homeDir?: string): string[] {
65
65
  return [
66
66
  join(cwd, ".pi", "workflows"),
67
67
  join(cwd, ".agents", "workflows"),
68
- join(getAgentDir(), "workflows"),
69
- ...packageWorkflowDirs(cwd),
68
+ join(resolveAgentDir(homeDir), "workflows"),
69
+ ...packageWorkflowDirs(cwd, { homeDir }),
70
70
  ];
71
71
  }
72
72
 
@@ -81,7 +81,7 @@ export type SavedWorkflow =
81
81
  * names that do exist — a model that guessed the name can correct itself from
82
82
  * the error instead of spending a turn asking.
83
83
  */
84
- export function readSavedWorkflow(name: string, cwd: string): SavedWorkflow {
84
+ export function readSavedWorkflow(name: string, cwd: string, homeDir?: string): SavedWorkflow {
85
85
  const trimmed = name.trim();
86
86
  if (isUnsafeName(trimmed)) {
87
87
  return {
@@ -92,13 +92,13 @@ export function readSavedWorkflow(name: string, cwd: string): SavedWorkflow {
92
92
  };
93
93
  }
94
94
 
95
- const roots = savedWorkflowRoots(cwd);
95
+ const roots = savedWorkflowRoots(cwd, homeDir);
96
96
  for (const root of roots) {
97
97
  if (isSymlink(root)) continue; // reject a symlinked root entirely, as skill-loader does
98
98
  const path = join(root, `${trimmed}${WORKFLOW_EXTENSION}`);
99
99
  // A `!` entry in the declaring package's manifest. Only ever true for a path
100
100
  // under a package root, so the local roots pay one set lookup for it.
101
- if (isPackageResourceExcluded(path, cwd, "workflows")) continue;
101
+ if (isPackageResourceExcluded(path, cwd, "workflows", { homeDir })) continue;
102
102
  const script = safeReadFile(path);
103
103
  if (script === undefined) continue;
104
104
  // Found the file, so stop looking — a shadowing name that turns out not to
@@ -117,7 +117,7 @@ export function readSavedWorkflow(name: string, cwd: string): SavedWorkflow {
117
117
  // Last: a package that declared an individual file rather than a directory.
118
118
  // It carries no root to look a name up in, so it is matched by name here
119
119
  // instead — after every root, which keeps package scripts lowest-precedence.
120
- const declared = packageWorkflowFiles(cwd).get(trimmed);
120
+ const declared = packageWorkflowFiles(cwd, { homeDir }).get(trimmed);
121
121
  if (declared !== undefined) {
122
122
  const script = safeReadFile(declared);
123
123
  if (script !== undefined) {
@@ -133,7 +133,7 @@ export function readSavedWorkflow(name: string, cwd: string): SavedWorkflow {
133
133
  }
134
134
  }
135
135
 
136
- const known = listSavedWorkflows(cwd);
136
+ const known = listSavedWorkflows(cwd, homeDir);
137
137
  return {
138
138
  ok: false,
139
139
  message:
@@ -154,6 +154,7 @@ export function readSavedWorkflow(name: string, cwd: string): SavedWorkflow {
154
154
  export function resolveWorkflowSource(
155
155
  ref: { name?: string; scriptPath?: string },
156
156
  cwd: string,
157
+ homeDir?: string,
157
158
  ): SavedWorkflow {
158
159
  const path = ref.scriptPath?.trim();
159
160
  if (path !== undefined && path !== "") {
@@ -161,21 +162,19 @@ export function resolveWorkflowSource(
161
162
  try {
162
163
  return { ok: true, script: readFileSync(resolved, "utf-8"), path: resolved };
163
164
  } catch (err) {
164
- return {
165
- ok: false,
166
- message: `Could not read workflow script "${resolved}": ${err instanceof Error ? err.message : String(err)}`,
167
- };
165
+ // `readFileSync` only ever throws Node's own `Error`s.
166
+ return { ok: false, message: `Could not read workflow script "${resolved}": ${(err as Error).message}` };
168
167
  }
169
168
  }
170
169
  const name = ref.name?.trim();
171
- if (name !== undefined && name !== "") return readSavedWorkflow(name, cwd);
170
+ if (name !== undefined && name !== "") return readSavedWorkflow(name, cwd, homeDir);
172
171
  return { ok: false, message: "A workflow reference needs a `name` or a `scriptPath`." };
173
172
  }
174
173
 
175
174
  /** Every saved workflow name, de-duplicated across roots and sorted. */
176
- export function listSavedWorkflows(cwd: string): string[] {
175
+ export function listSavedWorkflows(cwd: string, homeDir?: string): string[] {
177
176
  const names = new Set<string>();
178
- for (const root of savedWorkflowRoots(cwd)) {
177
+ for (const root of savedWorkflowRoots(cwd, homeDir)) {
179
178
  if (!existsSync(root) || isSymlink(root)) continue;
180
179
  let entries: string[];
181
180
  try {
@@ -188,11 +187,11 @@ export function listSavedWorkflows(cwd: string): string[] {
188
187
  const name = entry.slice(0, -WORKFLOW_EXTENSION.length);
189
188
  if (isUnsafeName(name)) continue;
190
189
  const path = join(root, entry);
191
- if (isPackageResourceExcluded(path, cwd, "workflows")) continue;
190
+ if (isPackageResourceExcluded(path, cwd, "workflows", { homeDir })) continue;
192
191
  if (isWorkflowFile(path)) names.add(name);
193
192
  }
194
193
  }
195
- for (const [name, path] of packageWorkflowFiles(cwd)) {
194
+ for (const [name, path] of packageWorkflowFiles(cwd, { homeDir })) {
196
195
  if (isUnsafeName(name)) continue;
197
196
  if (isWorkflowFile(path)) names.add(name);
198
197
  }
@@ -228,10 +227,11 @@ function isWorkflowFile(path: string): boolean {
228
227
  export function resolveWorkflowScript(
229
228
  params: { script?: string; scriptPath?: string; name?: string },
230
229
  cwd: string,
230
+ homeDir?: string,
231
231
  ): { ok: true; script: string; scriptPath?: string } | { ok: false; message: string } {
232
232
  const path = params.scriptPath?.trim();
233
233
  if (path !== undefined && path !== "") {
234
- const resolved = resolveWorkflowSource({ scriptPath: path }, cwd);
234
+ const resolved = resolveWorkflowSource({ scriptPath: path }, cwd, homeDir);
235
235
  return resolved.ok ? { ok: true, script: resolved.script, scriptPath: resolved.path } : resolved;
236
236
  }
237
237
  const script = params.script;
@@ -244,11 +244,11 @@ export function resolveWorkflowScript(
244
244
  // reference means.
245
245
  const name = params.name?.trim();
246
246
  if (name !== undefined && name !== "") {
247
- const saved = resolveWorkflowSource({ name }, cwd);
247
+ const saved = resolveWorkflowSource({ name }, cwd, homeDir);
248
248
  return saved.ok ? { ok: true, script: saved.script, scriptPath: saved.path } : saved;
249
249
  }
250
250
 
251
- const known = listSavedWorkflows(cwd);
251
+ const known = listSavedWorkflows(cwd, homeDir);
252
252
  return {
253
253
  ok: false,
254
254
  message:
@@ -78,6 +78,8 @@ export interface WorkflowTask {
78
78
  logs: string[];
79
79
 
80
80
  abortController: AbortController;
81
+ /** Set when the parent stopped the run with `stop_subagent`; unset for a human stop. */
82
+ stoppedBy?: "parent";
81
83
  startTime: number;
82
84
  endTime?: number;
83
85
  /** Excluded from the elapsed clock the header shows. */
@@ -86,6 +88,8 @@ export interface WorkflowTask {
86
88
  /** The script's return value, once the run produced one. */
87
89
  value?: unknown;
88
90
  error?: string;
91
+ /** Full authored stack for the result; the compact status keeps only error. */
92
+ errorStack?: string;
89
93
  }
90
94
 
91
95
  export function createWorkflowTask(init: {
@@ -182,19 +186,22 @@ export function resumeWorkflowTask(task: WorkflowTask, now = Date.now()): boolea
182
186
  if (task.status !== "paused" || task.control === undefined) return false;
183
187
  task.control.resume();
184
188
  task.status = "running";
185
- task.totalPausedMs = (task.totalPausedMs ?? 0) + Math.max(0, now - (task.pausedAt ?? now));
186
- task.pausedAt = undefined;
189
+ bankPause(task, now);
187
190
  return true;
188
191
  }
189
192
 
193
+ /** Close out an open pause into `totalPausedMs`; a no-op when the run is not held. */
194
+ function bankPause(task: WorkflowTask, now: number): void {
195
+ if (task.pausedAt === undefined) return;
196
+ task.totalPausedMs += Math.max(0, now - task.pausedAt);
197
+ task.pausedAt = undefined;
198
+ }
199
+
190
200
  /** Settle a task from the run's own result. */
191
201
  export function completeWorkflowTask(task: WorkflowTask, result: WorkflowRunResult): void {
192
202
  // Banked before the status moves off "paused": a run that finished while held
193
203
  // still spent that time held, and the elapsed figure has to say so.
194
- if (task.pausedAt !== undefined) {
195
- task.totalPausedMs = (task.totalPausedMs ?? 0) + Math.max(0, Date.now() - task.pausedAt);
196
- task.pausedAt = undefined;
197
- }
204
+ bankPause(task, Date.now());
198
205
  // Nothing left to control, and holding the handle would let the dialog offer
199
206
  // pause on a run that has already stopped.
200
207
  task.control = undefined;
@@ -205,6 +212,7 @@ export function completeWorkflowTask(task: WorkflowTask, result: WorkflowRunResu
205
212
  task.replayedCount = result.replayedCount;
206
213
  task.value = result.value;
207
214
  task.error = result.error;
215
+ task.errorStack = result.errorStack;
208
216
  task.endTime = Date.now();
209
217
  }
210
218
 
@@ -222,6 +230,13 @@ export function failWorkflowTask(task: WorkflowTask, error: string): void {
222
230
 
223
231
  /** The run's outcome as text, for the notification and the LLM-facing result. */
224
232
  export function workflowResultText(task: WorkflowTask): string {
233
+ if (task.errorStack !== undefined) {
234
+ const stack = task.errorStack.slice(0, 16384);
235
+ const firstLine = stack.split("\n", 1)[0];
236
+ const message = task.error;
237
+ if (message === undefined || message === "" || firstLine === message || firstLine.endsWith(`: ${message}`)) return stack;
238
+ return `${message}\n${stack}`;
239
+ }
225
240
  if (task.error !== undefined) return task.error;
226
241
  if (task.value === undefined) return "No output.";
227
242
  if (typeof task.value === "string") return task.value;
@@ -283,7 +298,7 @@ export function formatWorkflowNotification(task: WorkflowTask, now = Date.now())
283
298
  const totals = stats(task.workflowProgress, task.agentCount);
284
299
  const status =
285
300
  task.status === "completed" ? "Done"
286
- : task.status === "killed" ? "Stopped"
301
+ : task.status === "killed" ? (task.stoppedBy === "parent" ? "Stopped by the parent with stop_subagent" : "Stopped")
287
302
  : `Error: ${task.error ?? "unknown"}`;
288
303
  const result = workflowResultText(task);
289
304
  return [
@@ -19,7 +19,7 @@
19
19
  * - `budget.total` is always null; pi has no token-target directive.
20
20
  * - `parallel` propagates a fatal run error instead of folding it to null.
21
21
  * - `effort` inherits the agent definition's level, then the parent's.
22
- * - `isolation` removes the worktree on settle, changes kept on a branch.
22
+ * - `isolation` removes the worktree on settle (kept if cleanup fails), changes kept on a branch.
23
23
  * Additions with no upstream counterpart: `gate`, `resume`, `effort: "minimal"`,
24
24
  * the saved-workflow directories, and the reject-unknown-options guarantee.
25
25
  *
@@ -77,7 +77,7 @@ Every script must begin with \`export const meta = {...}\`:
77
77
  The \`meta\` object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation. Required fields: \`name\`, \`description\`. Optional: \`whenToUse\` (shown in the workflow list), \`phases\`. Use the SAME phase titles in meta.phases as in phase() calls — titles are matched exactly; a phase() call with no matching meta entry just gets its own progress group. Add \`model\` to a phase entry when that phase uses a specific model override.
78
78
 
79
79
  Script body hooks:
80
- - agent(prompt: string, opts?: {label?: string, phase?: string, schema?: object, model?: string, effort?: string, isolation?: 'worktree', agentType?: string, gate?: string, resume?: string}): Promise<any> — spawn a subagent. Without schema, returns its final text as a string. With schema (a JSON Schema), the subagent is given a StructuredOutput tool built from it and agent() returns the validated object — no parsing needed. A payload that does not match is rejected back to the child, which corrects it; a child that never answers through the tool gets one more prompt and then fails, so the call returns null — filter after every schema stage. Returns null if the user skips the agent mid-run or the subagent dies on a terminal API error after retries (filter with .filter(Boolean)). opts.label overrides the display label. opts.phase explicitly assigns this agent to a progress group (use this inside pipeline()/parallel() stages to avoid races on the global phase() state — same phase string → same group box). opts.model overrides the model for this agent call. Default to omitting it — the agent inherits the main-loop model (the resolved session model), which is almost always correct. Only set it when you're highly confident a different tier fits the task; when unsure, omit. opts.effort overrides the reasoning effort for this agent call ('minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max') — omit to inherit the agent definition's own level, then the parent's; use 'low' for cheap mechanical stages and higher tiers only for the hardest verify/judge stages. opts.isolation: 'worktree' runs the agent in a fresh git worktree — EXPENSIVE (setup time + disk per agent), use ONLY when agents mutate files in parallel and would otherwise conflict; the worktree is removed when the agent settles, its changes preserved on a branch. opts.gate: '<command>' runs a shell command after the agent finishes and requires it to pass — a non-zero exit marks the agent failed and the command's output becomes the error; prefer gate: 'npm test' over asking another agent whether the code looks right. opts.resume: '<label>' continues the child that ran under that label instead of starting fresh, so an iterative loop keeps its context — it cannot be combined with agentType, model, effort, isolation, gate or schema. opts.agentType uses a custom subagent type instead of the default workflow subagent — resolved from the same registry as the Agent tool; composes with schema. Available types:
80
+ - agent(prompt: string, opts?: {label?: string, phase?: string, schema?: object, model?: string, effort?: string, isolation?: 'worktree', agentType?: string, gate?: string, resume?: string}): Promise<any> — spawn a subagent. Without schema, returns its final text as a string. With schema (a JSON Schema), the subagent is given a StructuredOutput tool built from it and agent() returns the validated object — no parsing needed. A payload that does not match is rejected back to the child, which corrects it; a child that never answers through the tool gets one more prompt and then fails, so the call returns null — filter after every schema stage. Returns null if the user skips the agent mid-run or the subagent dies on a terminal API error after retries (filter with .filter(Boolean)). opts.label overrides the display label. opts.phase explicitly assigns this agent to a progress group (use this inside pipeline()/parallel() stages to avoid races on the global phase() state — same phase string → same group box). opts.model overrides the model for this agent call. Default to omitting it so the configured selection policy applies: agentModels for the type, then agentModels["*"], then the agent definition, then the parent model. Only set it when you're highly confident a different tier fits the task; when unsure, omit. opts.effort overrides the reasoning effort for this agent call ('minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max') — omit to inherit the agent definition's own level, then the parent's; use 'low' for cheap mechanical stages and higher tiers only for the hardest verify/judge stages. opts.isolation: 'worktree' runs the agent in a fresh git worktree — EXPENSIVE (setup time + disk per agent), use ONLY when agents mutate files in parallel and would otherwise conflict; the worktree is removed when the agent settles, its changes preserved on a branch; if cleanup fails the worktree is kept and the agent errors. opts.gate: '<command>' runs a shell command after the agent finishes and requires it to pass — a non-zero exit marks the agent failed, returns null to the script, and records the command output on the workflow agent error; prefer gate: 'npm test' over asking another agent whether the code looks right. opts.resume: '<label>' continues the child that ran under that label instead of starting fresh, so an iterative loop keeps its context — it cannot be combined with agentType, model, effort, isolation, gate or schema. opts.agentType uses a custom subagent type instead of the default workflow subagent — resolved from the same registry as the Agent tool; composes with schema. Available types:
81
81
  {{typeList}}
82
82
  - pipeline(items, stage1, stage2, ...): Promise<any[]> — run each item through all stages independently, NO barrier between stages. Item A can be in stage 3 while item B is still in stage 1. This is the DEFAULT for multi-stage work. Wall-clock = slowest single-item chain, not sum-of-slowest-per-stage. Every stage callback receives (prevResult, originalItem, index) — use originalItem/index in later stages to label work without threading context through stage 1's return value. A stage that throws drops that item to \`null\` and skips its remaining stages.
83
83
  - parallel(thunks: Array<() => Promise<any>>): Promise<any[]> — run tasks concurrently. This is a BARRIER: awaits all thunks before returning. A thunk that throws (or whose agent errors) resolves to \`null\` in the result array, so \`.filter(Boolean)\` before using the results; only a fatal run error — a cap breach, or a nested workflow that could not load — propagates instead of being folded into a null. Use ONLY when you genuinely need all results together.