@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.
- package/README.md +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/workflow/progress.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
517
|
-
VOWELS.includes(lower.
|
|
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
|
) {
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|
package/src/workflow/saved.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
165
|
-
|
|
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:
|
package/src/workflow/task.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|