pi-subagents 0.65.0 → 0.65.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/docs/agents.md +1 -1
- package/docs/configuration.md +16 -0
- package/docs/extension-api.md +3 -0
- package/docs/tool-reference.md +8 -2
- package/docs/workflows.md +8 -0
- package/package.json +3 -1
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/execution-controls.md +5 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/src/api/preflight.ts +5 -1
- package/src/extension/config.ts +4 -2
- package/src/extension/index.ts +31 -2
- package/src/extension/schemas.ts +1 -1
- package/src/extension/tool-description.ts +5 -1
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +22 -36
- package/src/intercom/supervisor-ui.ts +3 -2
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/async-execution.ts +8 -1
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-retention.ts +9 -0
- package/src/runs/background/notify.ts +2 -0
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +2 -1
- package/src/runs/background/runner-aliases.ts +32 -5
- package/src/runs/background/subagent-runner.ts +19 -0
- package/src/runs/foreground/execution.ts +15 -1
- package/src/runs/foreground/foreground-history.ts +3 -1
- package/src/runs/foreground/prompt-audit.ts +9 -5
- package/src/runs/foreground/subagent-executor.ts +61 -34
- package/src/runs/shared/acceptance.ts +14 -1
- package/src/runs/shared/child-session.ts +13 -18
- package/src/runs/shared/llm-intent-arbiter.ts +20 -11
- package/src/runs/shared/model-exclusions.ts +2 -1
- package/src/runs/shared/model-fallback.ts +31 -2
- package/src/runs/shared/nested-events.ts +3 -3
- package/src/runs/shared/parallel-utils.ts +1 -0
- package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
- package/src/runs/shared/worktree.ts +75 -10
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/types.ts +8 -0
- package/src/shared/utils.ts +3 -0
- package/src/shared/watch-strategy.ts +2 -0
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/render.ts +21 -10
- package/src/workflows/scripted-workflow.ts +32 -6
- package/src/workflows/workflow-checklist.ts +4 -3
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "../../shared/types.ts";
|
|
16
16
|
import { getAgentDir } from "../../shared/utils.ts";
|
|
17
17
|
import { isTerminalParallelHandoffChildStatus } from "./parallel-handoff.ts";
|
|
18
|
+
import { MACHINE_DIFF_OPTIONS, validateWorktreePatchRepresentsCurrentWorktree } from "./worktree.ts";
|
|
18
19
|
|
|
19
20
|
export const WORKTREE_CLEANUP_PLAN_VERSION = 1 as const;
|
|
20
21
|
export const WORKTREE_CLEANUP_PLAN_TTL_MS = 30 * 60 * 1000;
|
|
@@ -572,7 +573,7 @@ function resolveBranchTip(repoRoot: string, branch: string): { value?: string; e
|
|
|
572
573
|
return { value: result.stdout.trim() };
|
|
573
574
|
}
|
|
574
575
|
|
|
575
|
-
function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string): { path?: string; error?: string } {
|
|
576
|
+
function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string, baseCommit: string): { path?: string; error?: string } {
|
|
576
577
|
const patchPath = metadataPatchPath(record);
|
|
577
578
|
if (!patchPath || record.child.patch?.error !== undefined || record.child.patch?.changed !== true) return {};
|
|
578
579
|
const inspection = inspectPath(patchPath);
|
|
@@ -587,6 +588,8 @@ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string):
|
|
|
587
588
|
if (!patchStat.isFile()) return { error: "captured handoff patch is not a file" };
|
|
588
589
|
if (pathInside(worktreePath, inspection.realpath)) return { error: "durable handoff patch lives inside the worktree" };
|
|
589
590
|
if (patchStat.size <= 0) return { error: "captured handoff patch is empty" };
|
|
591
|
+
const validationError = validateWorktreePatchRepresentsCurrentWorktree(worktreePath, baseCommit, patchPath);
|
|
592
|
+
if (validationError) return { error: `captured handoff patch failed validation: ${validationError}` };
|
|
590
593
|
return { path: patchPath };
|
|
591
594
|
}
|
|
592
595
|
|
|
@@ -665,14 +668,14 @@ function buildManagedEntry(input: {
|
|
|
665
668
|
entry.preconditions.statusDigest = status.digest;
|
|
666
669
|
if (status.output && status.output.trim()) return blockedEntry(entry, "dirty", "keep", "worktree has uncommitted or untracked changes");
|
|
667
670
|
|
|
668
|
-
const diff = runGit(worktreePath, ["diff", "--quiet", resolvedBase.value, "--"]);
|
|
671
|
+
const diff = runGit(worktreePath, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, resolvedBase.value, "--"]);
|
|
669
672
|
if (diff.status !== 0 && diff.status !== 1) return blockedEntry(entry, "unknown", "unknown", `git diff safety check failed: ${gitFailure(diff, "git diff")}`);
|
|
670
673
|
const ancestor = runGit(repoRoot, ["merge-base", "--is-ancestor", git.head, targetHead]);
|
|
671
674
|
if (ancestor.status !== 0 && ancestor.status !== 1) return blockedEntry(entry, "unknown", "unknown", `local merge safety check failed: ${gitFailure(ancestor, "git merge-base --is-ancestor")}`);
|
|
672
675
|
const branchTipIsAncestor = ancestor.status === 0;
|
|
673
676
|
let divergenceSafe = diff.status === 0;
|
|
674
677
|
if (!divergenceSafe) {
|
|
675
|
-
const captured = isPatchCaptured(record, worktreePath);
|
|
678
|
+
const captured = isPatchCaptured(record, worktreePath, resolvedBase.value);
|
|
676
679
|
if (captured.error) return blockedEntry(entry, "ineligible", "keep", captured.error);
|
|
677
680
|
if (captured.path) {
|
|
678
681
|
entry.patchPath = captured.path;
|
|
@@ -17,6 +17,9 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
|
|
|
17
17
|
const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
|
|
18
18
|
const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
|
|
19
19
|
const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
|
|
20
|
+
export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
|
|
21
|
+
const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
|
|
22
|
+
const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
|
|
20
23
|
|
|
21
24
|
export interface WorktreeNamingInput {
|
|
22
25
|
runId: string;
|
|
@@ -168,8 +171,8 @@ interface RepoState {
|
|
|
168
171
|
|
|
169
172
|
const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
|
|
170
173
|
|
|
171
|
-
function runGit(cwd: string, args: string[]): GitResult {
|
|
172
|
-
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false });
|
|
174
|
+
function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
|
|
175
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
173
176
|
return {
|
|
174
177
|
stdout: result.stdout ?? "",
|
|
175
178
|
stderr: result.stderr ?? "",
|
|
@@ -187,6 +190,53 @@ function runGitChecked(cwd: string, args: string[]): string {
|
|
|
187
190
|
return result.stdout;
|
|
188
191
|
}
|
|
189
192
|
|
|
193
|
+
/** Validate a captured patch against the worktree index without changing either. */
|
|
194
|
+
export function validateWorktreePatch(worktreePath: string, patchPath: string): string | undefined {
|
|
195
|
+
const result = runGit(worktreePath, [...PATCH_VALIDATION_OPTIONS, patchPath]);
|
|
196
|
+
if (result.status === 0) return undefined;
|
|
197
|
+
return result.stderr.trim() || result.stdout.trim() || `git -C ${worktreePath} apply --check failed`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
|
|
201
|
+
let tempDir: string;
|
|
202
|
+
try {
|
|
203
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
206
|
+
}
|
|
207
|
+
const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
|
|
208
|
+
try {
|
|
209
|
+
const readTree = runGit(worktreePath, ["read-tree", "HEAD"], env);
|
|
210
|
+
if (readTree.status !== 0) return { error: readTree.stderr.trim() || readTree.stdout.trim() || "git read-tree failed" };
|
|
211
|
+
const add = runGit(worktreePath, ["add", "-A"], env);
|
|
212
|
+
if (add.status !== 0) return { error: add.stderr.trim() || add.stdout.trim() || "git add failed" };
|
|
213
|
+
const diff = runGit(worktreePath, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, baseCommit], env);
|
|
214
|
+
if (diff.status !== 0) return { error: diff.stderr.trim() || diff.stdout.trim() || "git diff failed" };
|
|
215
|
+
return { patch: diff.stdout };
|
|
216
|
+
} finally {
|
|
217
|
+
try {
|
|
218
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
219
|
+
} catch {
|
|
220
|
+
// Cleanup safety depends on preserving the worktree, not on best-effort temp-index deletion.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: string, baseCommit: string, patchPath: string): string | undefined {
|
|
226
|
+
const validationError = validateWorktreePatch(worktreePath, patchPath);
|
|
227
|
+
if (validationError) return validationError;
|
|
228
|
+
let capturedPatch: string;
|
|
229
|
+
try {
|
|
230
|
+
capturedPatch = fs.readFileSync(patchPath, "utf-8");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return error instanceof Error ? error.message : String(error);
|
|
233
|
+
}
|
|
234
|
+
const current = currentWorktreePatch(worktreePath, baseCommit);
|
|
235
|
+
if ("error" in current) return current.error || "failed to capture current worktree patch";
|
|
236
|
+
if (capturedPatch !== current.patch) return "captured handoff patch does not match current worktree changes";
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
190
240
|
function findGitWorktreePath(cwd: string, branch: string): string | undefined {
|
|
191
241
|
const targetBranch = `branch refs/heads/${branch}`;
|
|
192
242
|
let currentPath: string | undefined;
|
|
@@ -957,15 +1007,18 @@ function captureWorktreeDiff(
|
|
|
957
1007
|
): WorktreeDiff {
|
|
958
1008
|
removeSyntheticPathsBeforeDiff(worktree);
|
|
959
1009
|
runGitChecked(worktree.path, ["add", "-A"]);
|
|
960
|
-
const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
|
|
961
|
-
const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
|
|
962
|
-
const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
|
|
1010
|
+
const diffStat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--stat", setup.baseCommit]).trim();
|
|
1011
|
+
const patch = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, setup.baseCommit]);
|
|
1012
|
+
const numstat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--numstat", setup.baseCommit]);
|
|
963
1013
|
fs.writeFileSync(patchPath, patch, "utf-8");
|
|
964
1014
|
|
|
965
1015
|
if (!patch.trim()) {
|
|
966
1016
|
return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
|
|
967
1017
|
}
|
|
968
1018
|
|
|
1019
|
+
const validationError = validateWorktreePatch(worktree.path, patchPath);
|
|
1020
|
+
if (validationError) throw new Error(`captured worktree patch is not machine-applyable: ${validationError}`);
|
|
1021
|
+
|
|
969
1022
|
const parsed = parseNumstat(numstat);
|
|
970
1023
|
return {
|
|
971
1024
|
index: worktree.index,
|
|
@@ -1034,7 +1087,7 @@ function cleanupSingleWorktree(
|
|
|
1034
1087
|
errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1035
1088
|
}
|
|
1036
1089
|
const status = runGit(worktree.path, ["status", "--porcelain"]);
|
|
1037
|
-
const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
|
|
1090
|
+
const baseDiff = runGit(worktree.path, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, setup.baseCommit, "--"]);
|
|
1038
1091
|
if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
|
|
1039
1092
|
const reason = status.status !== 0
|
|
1040
1093
|
? status.stderr.trim() || status.stdout.trim() || "git status failed"
|
|
@@ -1055,13 +1108,25 @@ function cleanupSingleWorktree(
|
|
|
1055
1108
|
const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
|
|
1056
1109
|
if (hasWork && intent.kind === "preserve") {
|
|
1057
1110
|
const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
|
|
1058
|
-
|
|
1111
|
+
let patchValidationError: string | undefined;
|
|
1112
|
+
let patchCaptured = false;
|
|
1113
|
+
if (captured !== undefined
|
|
1059
1114
|
&& captured.error === undefined
|
|
1060
1115
|
&& fs.existsSync(captured.patchPath)
|
|
1061
|
-
&&
|
|
1062
|
-
|
|
1116
|
+
&& handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath)) {
|
|
1117
|
+
try {
|
|
1118
|
+
if (fs.statSync(captured.patchPath).size > 0) {
|
|
1119
|
+
patchValidationError = validateWorktreePatchRepresentsCurrentWorktree(worktree.path, setup.baseCommit, captured.patchPath);
|
|
1120
|
+
patchCaptured = patchValidationError === undefined;
|
|
1121
|
+
}
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
patchValidationError = error instanceof Error ? error.message : String(error);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1063
1126
|
if (!patchCaptured) {
|
|
1064
|
-
const reason =
|
|
1127
|
+
const reason = patchValidationError
|
|
1128
|
+
? `captured handoff patch failed validation: ${patchValidationError}`
|
|
1129
|
+
: "worktree contains changes that are not represented by a captured handoff patch";
|
|
1065
1130
|
return {
|
|
1066
1131
|
index: worktree.index,
|
|
1067
1132
|
path: worktree.path,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function validateModelResponseAliases(value: unknown, label = "config.modelResponseAliases"): void {
|
|
2
|
+
if (value === undefined) return;
|
|
3
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object`);
|
|
4
|
+
for (const [candidate, aliases] of Object.entries(value)) {
|
|
5
|
+
const slash = candidate.indexOf("/");
|
|
6
|
+
if (slash <= 0 || !candidate.slice(0, slash).trim() || !candidate.slice(slash + 1).trim()) {
|
|
7
|
+
throw new Error(`${label} key ${JSON.stringify(candidate)} must be a non-empty provider/model ID`);
|
|
8
|
+
}
|
|
9
|
+
if (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== "string" || !alias.trim())) {
|
|
10
|
+
throw new Error(`${label}[${JSON.stringify(candidate)}] must be an array of non-empty response ID strings`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -779,6 +779,8 @@ export interface RunFanoutRejection extends RunFanoutBudgetSnapshot {
|
|
|
779
779
|
}
|
|
780
780
|
|
|
781
781
|
export interface SteeringRecoveryDescriptor {
|
|
782
|
+
/** Captured response identity authority; absence means no declared aliases on revival. */
|
|
783
|
+
modelResponseAliases?: Record<string, string[]>;
|
|
782
784
|
version: 1;
|
|
783
785
|
launchContractDigest?: string;
|
|
784
786
|
extensionBindings?: ExtensionBindings;
|
|
@@ -2047,6 +2049,7 @@ export interface ForegroundResumeChild {
|
|
|
2047
2049
|
agentContract?: AgentContract;
|
|
2048
2050
|
/** Private bounded launch fields needed to preserve the child contract on resume. */
|
|
2049
2051
|
resumeContract?: {
|
|
2052
|
+
modelResponseAliases?: Record<string, string[]>;
|
|
2050
2053
|
outputSchema?: JsonSchemaObject;
|
|
2051
2054
|
agentContract?: AgentContract;
|
|
2052
2055
|
acceptance?: AcceptanceInput;
|
|
@@ -2201,6 +2204,8 @@ export interface SubagentState {
|
|
|
2201
2204
|
trustedSessionFileRoot?: string;
|
|
2202
2205
|
/** Live async session roots created by this parent executor, keyed by run id. */
|
|
2203
2206
|
liveAsyncSessionRoots?: Map<string, string>;
|
|
2207
|
+
/** Foreground nested routes retained after their direct parent settles, keyed by root run id. */
|
|
2208
|
+
retainedForegroundNestedRoutes?: Map<string, NestedRouteInfo>;
|
|
2204
2209
|
/** Last valid parent session model observed for this session; used when continuation contexts omit ctx.model. */
|
|
2205
2210
|
lastParentModel?: { provider: string; id: string };
|
|
2206
2211
|
subagentInProgress?: boolean;
|
|
@@ -2409,6 +2414,7 @@ export interface RunSyncOptions {
|
|
|
2409
2414
|
workflowChildPermitLaunch?: WorkflowChildPermitContext;
|
|
2410
2415
|
/** Registry models available for heuristic bare-model resolution */
|
|
2411
2416
|
availableModels?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
|
|
2417
|
+
modelResponseAliases?: Record<string, string[]>;
|
|
2412
2418
|
/** Current parent-session provider to prefer for ambiguous bare model ids */
|
|
2413
2419
|
preferredModelProvider?: string;
|
|
2414
2420
|
/** Parent Pi event host used to snapshot runtime-registered MCP servers before child launch. */
|
|
@@ -2544,6 +2550,8 @@ export interface ExtensionConfig {
|
|
|
2544
2550
|
asyncWidget?: boolean;
|
|
2545
2551
|
/** Configure the process-wide TTL policy for persisted model exclusions. */
|
|
2546
2552
|
modelExclusions?: ModelExclusionsConfig;
|
|
2553
|
+
/** Exact provider/model candidates mapped to operator-declared equivalent response IDs. Empty arrays add no accepted IDs. */
|
|
2554
|
+
modelResponseAliases?: Record<string, string[]>;
|
|
2547
2555
|
/** Tool description variant registered for the parent-facing subagent tool. Defaults to split metadata. */
|
|
2548
2556
|
toolDescriptionMode?: ToolDescriptionMode;
|
|
2549
2557
|
/** Inline chat rendering for the subagent tool. Defaults to rich. */
|
package/src/shared/utils.ts
CHANGED
|
@@ -135,6 +135,9 @@ function isNotFoundError(error: unknown): boolean {
|
|
|
135
135
|
* Read async job status from disk (with mtime-based caching)
|
|
136
136
|
*/
|
|
137
137
|
export function readStatus(asyncDir: string): AsyncStatus | null {
|
|
138
|
+
if (Buffer.byteLength(path.basename(asyncDir), "utf-8") > 255) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
138
141
|
const statusPath = path.join(asyncDir, "status.json");
|
|
139
142
|
|
|
140
143
|
let stat: fs.Stats;
|
|
@@ -2,9 +2,11 @@ export type FileWatchPurpose =
|
|
|
2
2
|
| "result-delivery"
|
|
3
3
|
| "supervisor-channel"
|
|
4
4
|
| "async-job-tracker"
|
|
5
|
+
| "retained-nested-route-tracker"
|
|
5
6
|
| "runner-control-inbox"
|
|
6
7
|
| "child-steering-inbox";
|
|
7
8
|
|
|
8
9
|
export function shouldUseNativeFsWatch(_purpose: FileWatchPurpose, platform: NodeJS.Platform = process.platform): boolean {
|
|
10
|
+
if (_purpose === "retained-nested-route-tracker" && platform === "win32") return false;
|
|
9
11
|
return platform !== "darwin";
|
|
10
12
|
}
|
package/src/tui/fleet-status.ts
CHANGED
|
@@ -666,7 +666,7 @@ export class SubagentFleetStatus {
|
|
|
666
666
|
const capacity = this.state.activeAsyncCapacity;
|
|
667
667
|
const hasNativeRows = workEntries.some((entry) => !entry.external);
|
|
668
668
|
const showNativeSummary = hasNativeRows || Boolean(capacity?.used);
|
|
669
|
-
const asyncRuns = capacity && showNativeSummary ? `Async runs ${capacity.used}/${capacity.limit || "∞"}` : "";
|
|
669
|
+
const asyncRuns = capacity && showNativeSummary && (capacity.used > 0 || capacity.limit > 0) ? `Async runs ${capacity.used}/${capacity.limit || "∞"}` : "";
|
|
670
670
|
const activeEntries = activeLeafAgentCount(workEntries);
|
|
671
671
|
const noun = workEntries.some((entry) => entry.external) ? "job" : "agent";
|
|
672
672
|
const agents = activeEntries > 0 ? `${activeEntries} active ${noun}${activeEntries === 1 ? "" : "s"}` : "";
|
package/src/tui/render.ts
CHANGED
|
@@ -1284,7 +1284,7 @@ function workflowChecklistItemPriority(state: WorkflowChecklistState): number {
|
|
|
1284
1284
|
return 6;
|
|
1285
1285
|
}
|
|
1286
1286
|
|
|
1287
|
-
function workflowChecklistItemLine(item: WorkflowChecklistItem, theme: Theme, indent: string, frame
|
|
1287
|
+
function workflowChecklistItemLine(item: WorkflowChecklistItem, theme: Theme, indent: string, frame: number | undefined, includeError: boolean): string {
|
|
1288
1288
|
const identity = [item.label, item.agent && item.agent !== item.label ? item.agent : undefined].filter(Boolean).join(" · ");
|
|
1289
1289
|
const context = contextModeBadge(theme, item.context);
|
|
1290
1290
|
const state = item.state === "complete" ? "" : ` ${theme.fg("dim", `· ${workflowChecklistStateLabel(item.state)}`)}`;
|
|
@@ -1294,15 +1294,26 @@ function workflowChecklistItemLine(item: WorkflowChecklistItem, theme: Theme, in
|
|
|
1294
1294
|
!item.currentTool && item.durationMs !== undefined ? formatDuration(item.durationMs) : undefined,
|
|
1295
1295
|
item.toolCount !== undefined ? `${item.toolCount} tools` : undefined,
|
|
1296
1296
|
item.outputName ? `out:${item.outputName}` : undefined,
|
|
1297
|
-
item.error ? `error:${oneLine(item.error)}` : undefined,
|
|
1297
|
+
includeError && item.error ? `error:${oneLine(item.error)}` : undefined,
|
|
1298
1298
|
].filter(Boolean).join(" · ");
|
|
1299
1299
|
return `${indent}${workflowChecklistGlyph(item, theme, frame)} ${theme.bold(identity || item.key)}${context}${state}${details ? ` ${theme.fg("dim", `· ${details}`)}` : ""}`;
|
|
1300
1300
|
}
|
|
1301
1301
|
|
|
1302
1302
|
const COLLAPSED_WORKFLOW_PHASE_LIMIT = 4;
|
|
1303
1303
|
|
|
1304
|
-
|
|
1304
|
+
interface WorkflowChecklistWidgetOptions {
|
|
1305
|
+
includeSummary?: boolean;
|
|
1306
|
+
limitPhases?: boolean;
|
|
1307
|
+
includeBottleneckOutput?: boolean;
|
|
1308
|
+
includeItemErrors?: boolean;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function workflowChecklistWidgetLines(checklist: WorkflowChecklistProjection | undefined, theme: Theme, indent: string, expanded: boolean, frame?: number, options: WorkflowChecklistWidgetOptions = {}): string[] {
|
|
1305
1312
|
if (!checklist?.total) return [];
|
|
1313
|
+
const includeSummary = options.includeSummary ?? true;
|
|
1314
|
+
const limitPhases = options.limitPhases ?? !expanded;
|
|
1315
|
+
const includeBottleneckOutput = options.includeBottleneckOutput ?? expanded;
|
|
1316
|
+
const includeItemErrors = options.includeItemErrors ?? true;
|
|
1306
1317
|
const lines = includeSummary ? [`${indent}${theme.fg("dim", `Checklist ${formatWorkflowChecklistSummary(checklist)}`)}`] : [];
|
|
1307
1318
|
const phases = !limitPhases || checklist.phases.length <= COLLAPSED_WORKFLOW_PHASE_LIMIT
|
|
1308
1319
|
? checklist.phases
|
|
@@ -1326,11 +1337,11 @@ function workflowChecklistWidgetLines(checklist: WorkflowChecklistProjection | u
|
|
|
1326
1337
|
lines.push(`${indent}${glyph} ${theme.bold(formatWorkflowChecklistPhase(phase))}`);
|
|
1327
1338
|
if (expanded) {
|
|
1328
1339
|
for (const item of [...phase.items].sort((left, right) => workflowChecklistItemPriority(left.state) - workflowChecklistItemPriority(right.state))) {
|
|
1329
|
-
lines.push(workflowChecklistItemLine(item, theme, `${indent} `, frame));
|
|
1340
|
+
lines.push(workflowChecklistItemLine(item, theme, `${indent} `, frame, includeItemErrors || item.kind === "host"));
|
|
1330
1341
|
}
|
|
1331
1342
|
}
|
|
1332
1343
|
}
|
|
1333
|
-
const bottleneck = formatWorkflowChecklistBottleneck(checklist.bottleneck, { includeOutput: includeBottleneckOutput });
|
|
1344
|
+
const bottleneck = formatWorkflowChecklistBottleneck(checklist.bottleneck, { includeOutput: includeBottleneckOutput, includeError: !expanded });
|
|
1334
1345
|
if (bottleneck) {
|
|
1335
1346
|
const tone = checklist.bottleneck?.state === "blocked" || checklist.bottleneck?.state === "failed" ? "error" : checklist.bottleneck?.state === "running" ? "accent" : "warning";
|
|
1336
1347
|
lines.push(`${indent}${theme.fg(tone, `bottleneck · ${bottleneck}`)}`);
|
|
@@ -2371,7 +2382,7 @@ function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded
|
|
|
2371
2382
|
if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width, frame);
|
|
2372
2383
|
const lines: string[] = [
|
|
2373
2384
|
...(expanded ? workflowPreflightLines(job) : []),
|
|
2374
|
-
...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame),
|
|
2385
|
+
...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame, { includeItemErrors: !expanded }),
|
|
2375
2386
|
];
|
|
2376
2387
|
const group = activeParallelWidgetGroup(job);
|
|
2377
2388
|
if (group) {
|
|
@@ -2767,7 +2778,7 @@ function buildWidgetLinesWithProjection(jobs: AsyncJobState[], theme: Theme, wid
|
|
|
2767
2778
|
: [
|
|
2768
2779
|
` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`,
|
|
2769
2780
|
...widgetLaneDetailLines(job, theme, projection),
|
|
2770
|
-
...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame),
|
|
2781
|
+
...workflowChecklistWidgetLines(projection.checklist, theme, " ", expanded, frame, { includeItemErrors: !expanded || !job.steps?.length }),
|
|
2771
2782
|
...widgetParallelAgentDetails(job, theme, expanded, width, frame),
|
|
2772
2783
|
];
|
|
2773
2784
|
items.push([
|
|
@@ -2991,7 +3002,7 @@ function renderWorkflowChatProgress(d: Details, result: AgentToolResult<Details>
|
|
|
2991
3002
|
if (d.preflight) c.addChild(new Text(truncLine(theme.fg("dim", formatWorkflowPreflightPlanSummary(d.preflight, { indent: rowIndent })), width), 0, 0));
|
|
2992
3003
|
if (phase) c.addChild(new Text(truncLine(theme.fg("dim", `${rowIndent}Phase ${phase}`), width), 0, 0));
|
|
2993
3004
|
const checklist = projectWorkflowChecklist({ hostSteps: workflow?.receipt?.hostSteps, preflight: d.preflight, trace: workflow?.trace, now: Date.now() });
|
|
2994
|
-
for (const line of workflowChecklistWidgetLines(checklist, theme, rowIndent, false, frame,
|
|
3005
|
+
for (const line of workflowChecklistWidgetLines(checklist, theme, rowIndent, false, frame, { limitPhases: !expanded, includeBottleneckOutput: expanded })) {
|
|
2995
3006
|
c.addChild(new Text(truncLine(line, width), 0, 0));
|
|
2996
3007
|
}
|
|
2997
3008
|
if (rows.length === 0) {
|
|
@@ -3073,7 +3084,7 @@ function renderMultiCompact(d: Details, theme: Theme, layout: MainWindowRenderLa
|
|
|
3073
3084
|
const detailIndent = mainWindowIndent(layout, 2);
|
|
3074
3085
|
c.addChild(new Text(truncLine(`${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
|
|
3075
3086
|
if (checklistPrimary) {
|
|
3076
|
-
for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, rowIndent, false, frame, false)) {
|
|
3087
|
+
for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, rowIndent, false, frame, { includeSummary: false })) {
|
|
3077
3088
|
c.addChild(new Text(truncLine(line, width), 0, 0));
|
|
3078
3089
|
}
|
|
3079
3090
|
if (hasRunning || workflowChecklist.running > 0) c.addChild(new Text(truncLine(theme.fg("accent", `${rowIndent}${liveDetailHintText()}`), width), 0, 0));
|
|
@@ -3424,7 +3435,7 @@ export function renderSubagentResult(
|
|
|
3424
3435
|
c.addChild(new Text(fit(` ${chainVis}`), 0, 0));
|
|
3425
3436
|
}
|
|
3426
3437
|
const workflowChecklist = foregroundWorkflowChecklist(d);
|
|
3427
|
-
for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, " ", true, frame)) {
|
|
3438
|
+
for (const line of workflowChecklistWidgetLines(workflowChecklist, theme, " ", true, frame, { includeItemErrors: false })) {
|
|
3428
3439
|
c.addChild(new Text(fit(line), 0, 0));
|
|
3429
3440
|
}
|
|
3430
3441
|
|
|
@@ -5,6 +5,7 @@ import { Worker } from "node:worker_threads";
|
|
|
5
5
|
import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
|
|
6
6
|
import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
|
|
7
7
|
import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
|
|
8
|
+
import { describeGateAcceptanceConflict } from "../runs/shared/acceptance.ts";
|
|
8
9
|
import type { AcceptanceRecoveryMetadata, HostStepNodeV1, SingleResult } from "../shared/types.ts";
|
|
9
10
|
import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
|
|
10
11
|
|
|
@@ -74,6 +75,12 @@ function stableRunJson(value) {
|
|
|
74
75
|
return JSON.stringify(value) ?? "undefined";
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function canonicalRunParams(params) {
|
|
79
|
+
if (params.gate === undefined || params.acceptance !== false) return params;
|
|
80
|
+
const { acceptance: _acceptance, ...withoutAcceptance } = params;
|
|
81
|
+
return withoutAcceptance;
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
function isDirectWorkflowScriptPromiseHandlerCall() {
|
|
78
85
|
const stack = new Error().stack;
|
|
79
86
|
if (typeof stack !== "string") return false;
|
|
@@ -445,7 +452,7 @@ function validateLaneSpecs(laneSpecs) {
|
|
|
445
452
|
validateLaneStageBounds(validationParams, stageLabel);
|
|
446
453
|
validateRunCall(generatedKey, validationParams, stageLabel, validationFingerprints);
|
|
447
454
|
const existingFingerprint = runFingerprints.get(generatedKey);
|
|
448
|
-
if (existingFingerprint !== undefined && (resume === "previous" || existingFingerprint !== stableRunJson(params))) {
|
|
455
|
+
if (existingFingerprint !== undefined && (resume === "previous" || existingFingerprint !== stableRunJson(canonicalRunParams(params)))) {
|
|
449
456
|
throw new Error("runs.lanes generated child key '" + generatedKey + "' is already used with incompatible launch params.");
|
|
450
457
|
}
|
|
451
458
|
stages.push({ key: stageKey, generatedKey, resume, params });
|
|
@@ -614,6 +621,19 @@ function validateLaneMetadata(value, label, workflowKey) {
|
|
|
614
621
|
}
|
|
615
622
|
}
|
|
616
623
|
|
|
624
|
+
function describeGateAcceptanceConflict(gate, acceptance) {
|
|
625
|
+
const render = (value) => {
|
|
626
|
+
let encoded;
|
|
627
|
+
try {
|
|
628
|
+
encoded = JSON.stringify(value) ?? String(value);
|
|
629
|
+
} catch {
|
|
630
|
+
encoded = String(value);
|
|
631
|
+
}
|
|
632
|
+
return encoded.length > 120 ? encoded.slice(0, 120) + "..." : encoded;
|
|
633
|
+
};
|
|
634
|
+
return " Both fields were present: gate=" + render(gate) + " acceptance=" + render(acceptance) + ".";
|
|
635
|
+
}
|
|
636
|
+
|
|
617
637
|
function validateRunCall(key, params, label, fingerprints) {
|
|
618
638
|
if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
|
|
619
639
|
if (hostKeys.has(key)) throw new Error("Workflow key '" + key + "' is already used by runs.host.");
|
|
@@ -627,7 +647,7 @@ function validateRunCall(key, params, label, fingerprints) {
|
|
|
627
647
|
if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref.");
|
|
628
648
|
validateLaneMetadata(params.lane, label + " lane", key);
|
|
629
649
|
if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
|
|
630
|
-
if (params.gate !== undefined && params.acceptance !== undefined) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify.");
|
|
650
|
+
if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(params.gate, params.acceptance));
|
|
631
651
|
if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
|
|
632
652
|
if (params.extensionBindings !== undefined && params.resume !== undefined) throw new Error(label + " extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
|
|
633
653
|
if (params.resume !== undefined && typeof params.resume !== "string") {
|
|
@@ -644,7 +664,7 @@ function validateRunCall(key, params, label, fingerprints) {
|
|
|
644
664
|
if (params.resume !== undefined && (typeof params.task !== "string" || !params.task.trim())) throw new Error(label + " resume requires a non-empty task follow-up.");
|
|
645
665
|
validateExtensionBindings(params.extensionBindings, label);
|
|
646
666
|
assertJsonValue(params, label + " params");
|
|
647
|
-
const fingerprint = stableRunJson(params);
|
|
667
|
+
const fingerprint = stableRunJson(canonicalRunParams(params));
|
|
648
668
|
const existing = fingerprints.get(key);
|
|
649
669
|
if (existing !== undefined && existing !== fingerprint) throw new Error("Duplicate workflow key '" + key + "' used with incompatible launch params.");
|
|
650
670
|
fingerprints.set(key, fingerprint);
|
|
@@ -1370,6 +1390,12 @@ function stableJson(value: unknown): string {
|
|
|
1370
1390
|
return JSON.stringify(value) ?? "undefined";
|
|
1371
1391
|
}
|
|
1372
1392
|
|
|
1393
|
+
function canonicalRunParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
1394
|
+
if (params.gate === undefined || params.acceptance !== false) return params;
|
|
1395
|
+
const { acceptance: _acceptance, ...withoutAcceptance } = params;
|
|
1396
|
+
return withoutAcceptance;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1373
1399
|
function validateKey(value: unknown, owner = "runs.run"): string {
|
|
1374
1400
|
if (typeof value !== "string" || !KEY_PATTERN.test(value)) {
|
|
1375
1401
|
throw new Error(`${owner} key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.`);
|
|
@@ -2082,7 +2108,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
2082
2108
|
}
|
|
2083
2109
|
return result;
|
|
2084
2110
|
});
|
|
2085
|
-
const fingerprint = stableJson(params);
|
|
2111
|
+
const fingerprint = stableJson(canonicalRunParams(params));
|
|
2086
2112
|
const existing = launches.get(key);
|
|
2087
2113
|
if (existing) {
|
|
2088
2114
|
if (existing.fingerprint !== fingerprint) return respond(Promise.reject(new Error(`Duplicate workflow key '${key}' used with incompatible launch params.`)));
|
|
@@ -2109,8 +2135,8 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
2109
2135
|
if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) {
|
|
2110
2136
|
return respond(Promise.reject(new Error(`runs.run('${key}') gate must be a non-empty command string.`)));
|
|
2111
2137
|
}
|
|
2112
|
-
if (params.gate !== undefined && params.acceptance !== undefined) {
|
|
2113
|
-
return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.`)));
|
|
2138
|
+
if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) {
|
|
2139
|
+
return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.` + describeGateAcceptanceConflict(params.gate, params.acceptance))));
|
|
2114
2140
|
}
|
|
2115
2141
|
if (params.gate !== undefined && params.resume !== undefined) {
|
|
2116
2142
|
return respond(Promise.reject(new Error(`runs.run('${key}') gate is not supported with retained resume.`)));
|
|
@@ -402,11 +402,12 @@ export function formatWorkflowChecklistPhase(phase: WorkflowChecklistPhase): str
|
|
|
402
402
|
return counts.length ? `${phase.label} ${counts.join(" · ")}` : phase.label;
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
export function formatWorkflowChecklistBottleneck(item: WorkflowChecklistItem | undefined, options: { includeOutput?: boolean } = {}): string | undefined {
|
|
405
|
+
export function formatWorkflowChecklistBottleneck(item: WorkflowChecklistItem | undefined, options: { includeOutput?: boolean; includeError?: boolean } = {}): string | undefined {
|
|
406
406
|
if (!item) return undefined;
|
|
407
407
|
const identity = [item.label, item.agent && item.agent !== item.label ? item.agent : undefined].filter((value): value is string => Boolean(value)).join(" · ") || item.key;
|
|
408
408
|
const includeOutput = options.includeOutput ?? true;
|
|
409
|
-
const
|
|
409
|
+
const includeError = options.includeError ?? true;
|
|
410
|
+
const details = [item.context ? `(${item.context})` : undefined, item.currentTool ? `${item.currentTool}${item.durationMs !== undefined ? ` ${formatDurationText(item.durationMs)}` : ""}` : undefined, !item.currentTool && item.currentPath ? item.currentPath : undefined, !item.currentTool && item.durationMs !== undefined ? formatDurationText(item.durationMs) : undefined, item.toolCount !== undefined ? `${item.toolCount} tools` : undefined, includeOutput && item.outputName ? `out:${item.outputName}` : undefined, includeError && item.error ? `error:${item.error.replace(/\bOutput:/g, "output:")}` : undefined].filter((value): value is string => Boolean(value));
|
|
410
411
|
return [identity, ...details].join(" · ");
|
|
411
412
|
}
|
|
412
413
|
|
|
@@ -433,7 +434,7 @@ export function formatWorkflowChecklistText(projection: WorkflowChecklistProject
|
|
|
433
434
|
lines.push(`${indent} ${marker} ${formatWorkflowChecklistItem(item)}`);
|
|
434
435
|
}
|
|
435
436
|
}
|
|
436
|
-
const bottleneck = formatWorkflowChecklistBottleneck(projection.bottleneck);
|
|
437
|
+
const bottleneck = formatWorkflowChecklistBottleneck(projection.bottleneck, { includeError: options.includeItems === false });
|
|
437
438
|
if (bottleneck) lines.push(`${indent} bottleneck · ${bottleneck}`);
|
|
438
439
|
return lines;
|
|
439
440
|
}
|