@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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Session-journal authority for bounded fleet alerts and settled disclosure choices. */
|
|
2
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const FLEET_STATE_ENTRY = "subagents:fleet-state";
|
|
5
|
+
export const FLEET_STATE_VERSION = 1;
|
|
6
|
+
export const MAX_FLEET_ALERT_DETAILS = 128;
|
|
7
|
+
export const MAX_COMPACT_ALERT_COUNT = 99;
|
|
8
|
+
export const MAX_ALERT_ID_LENGTH = 160;
|
|
9
|
+
export const MAX_ALERT_TYPE_LENGTH = 80;
|
|
10
|
+
export const MAX_ALERT_DESCRIPTION_LENGTH = 240;
|
|
11
|
+
const ALERT_FIELD_COUNT = 5;
|
|
12
|
+
|
|
13
|
+
export type FailureKind = "error" | "aborted" | "failed" | "killed";
|
|
14
|
+
/** No AgentRecord, result, session, promise or usage references may cross this boundary. */
|
|
15
|
+
export interface FleetAlert {
|
|
16
|
+
id: string;
|
|
17
|
+
type: string;
|
|
18
|
+
description: string;
|
|
19
|
+
state: FailureKind;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
export interface FleetSnapshot {
|
|
23
|
+
version: typeof FLEET_STATE_VERSION;
|
|
24
|
+
collapsed: boolean;
|
|
25
|
+
alerts: FleetAlert[];
|
|
26
|
+
overflow: Record<FailureKind, number>;
|
|
27
|
+
/** Identity history lives in the existing journal, not an unbounded UI set. */
|
|
28
|
+
transition?: { alert: FleetAlert; acknowledged: boolean };
|
|
29
|
+
}
|
|
30
|
+
export interface FleetJournalEntry { type: string; customType?: string; data?: unknown }
|
|
31
|
+
const KINDS: readonly FailureKind[] = ["error", "aborted", "failed", "killed"];
|
|
32
|
+
const emptyOverflow = (): Record<FailureKind, number> => ({ error: 0, aborted: 0, failed: 0, killed: 0 });
|
|
33
|
+
|
|
34
|
+
/** Validate whole checkpoints; corrupt/unknown entries cannot partially replace valid state. */
|
|
35
|
+
function snapshot(data: unknown): FleetSnapshot | undefined {
|
|
36
|
+
if (!data || typeof data !== "object") return undefined;
|
|
37
|
+
const value = data as FleetSnapshot;
|
|
38
|
+
if (value.version !== FLEET_STATE_VERSION || typeof value.collapsed !== "boolean") return undefined;
|
|
39
|
+
// Earlier disclosure-only entries are valid empty checkpoints.
|
|
40
|
+
if (value.alerts === undefined && value.overflow === undefined) return { version: FLEET_STATE_VERSION, collapsed: value.collapsed, alerts: [], overflow: emptyOverflow() };
|
|
41
|
+
if (!Array.isArray(value.alerts) || value.alerts.length > MAX_FLEET_ALERT_DETAILS || !value.alerts.every(validAlert)
|
|
42
|
+
|| new Set(value.alerts.map(alert => alert.id)).size !== value.alerts.length
|
|
43
|
+
|| !value.overflow || !KINDS.every(kind => Number.isSafeInteger(value.overflow[kind]) && value.overflow[kind] >= 0)
|
|
44
|
+
|| (value.transition !== undefined && (!value.transition || !validAlert(value.transition.alert) || typeof value.transition.acknowledged !== "boolean"))) return undefined;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validAlert(value: unknown): value is FleetAlert {
|
|
49
|
+
if (!value || typeof value !== "object") return false;
|
|
50
|
+
const alert = value as FleetAlert;
|
|
51
|
+
return typeof alert.id === "string" && alert.id.length > 0 && alert.id.length <= MAX_ALERT_ID_LENGTH
|
|
52
|
+
&& typeof alert.type === "string" && alert.type.length <= MAX_ALERT_TYPE_LENGTH
|
|
53
|
+
&& typeof alert.description === "string" && alert.description.length <= MAX_ALERT_DESCRIPTION_LENGTH
|
|
54
|
+
&& Object.keys(alert).length === ALERT_FIELD_COUNT
|
|
55
|
+
&& KINDS.includes(alert.state) && Number.isFinite(alert.timestamp);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Bounded strings and plain values only, including when a detached spool supplied the input. */
|
|
59
|
+
export function fleetAlert(value: unknown): FleetAlert | undefined {
|
|
60
|
+
if (!value || typeof value !== "object") return undefined;
|
|
61
|
+
const record = value as { id?: unknown; type?: unknown; description?: unknown; status?: unknown; completedAt?: unknown; startedAt?: unknown };
|
|
62
|
+
if (typeof record.id !== "string" || !record.id || record.id.length > MAX_ALERT_ID_LENGTH || typeof record.type !== "string"
|
|
63
|
+
|| typeof record.description !== "string" || !KINDS.includes(record.status as FailureKind)) return undefined;
|
|
64
|
+
const timestamp = record.completedAt ?? record.startedAt;
|
|
65
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return undefined;
|
|
66
|
+
const clean = (text: string, limit: number) => stripTerminalSequences(text).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, limit);
|
|
67
|
+
return { id: record.id, type: clean(record.type, MAX_ALERT_TYPE_LENGTH), description: clean(record.description, MAX_ALERT_DESCRIPTION_LENGTH), state: record.status as FailureKind, timestamp };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* At most N detailed alerts plus four exact overflow counters in RAM. Each transition
|
|
72
|
+
* checkpoints bounded state. For an overflow id, scan the active branch backwards for
|
|
73
|
+
* its last transition (O(journal length) time, O(1) extra space). The host already owns
|
|
74
|
+
* that journal; no extra id/ack set, file, or global registry is retained here.
|
|
75
|
+
*/
|
|
76
|
+
export class FleetJournal {
|
|
77
|
+
collapsed = true;
|
|
78
|
+
private readonly details = new Map<string, FleetAlert>();
|
|
79
|
+
private overflow = emptyOverflow();
|
|
80
|
+
private entries: () => readonly FleetJournalEntry[] = () => [];
|
|
81
|
+
private append: ((state: FleetSnapshot) => void) | undefined;
|
|
82
|
+
private settledCollapsed = true;
|
|
83
|
+
|
|
84
|
+
restore(entries: () => readonly FleetJournalEntry[], append: (state: FleetSnapshot) => void): void {
|
|
85
|
+
this.entries = entries;
|
|
86
|
+
this.append = append;
|
|
87
|
+
this.details.clear();
|
|
88
|
+
this.overflow = emptyOverflow();
|
|
89
|
+
this.collapsed = true;
|
|
90
|
+
const branch = entries();
|
|
91
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
92
|
+
const entry = branch[i];
|
|
93
|
+
if (entry.type !== "custom" || entry.customType !== FLEET_STATE_ENTRY) continue;
|
|
94
|
+
const state = snapshot(entry.data);
|
|
95
|
+
if (!state) continue;
|
|
96
|
+
this.collapsed = state.collapsed;
|
|
97
|
+
this.overflow = { ...state.overflow };
|
|
98
|
+
for (const alert of state.alerts) this.details.set(alert.id, { ...alert });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
this.settledCollapsed = this.collapsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get alerts(): readonly FleetAlert[] { return [...this.details.values()]; }
|
|
105
|
+
get overflowCount(): number { return KINDS.reduce((sum, kind) => sum + this.overflow[kind], 0); }
|
|
106
|
+
get count(): number { return this.details.size + this.overflowCount; }
|
|
107
|
+
|
|
108
|
+
/** Duplicate notices and polling are not new failures; acknowledged ids stay read. */
|
|
109
|
+
report(alert: FleetAlert): void {
|
|
110
|
+
const detail = this.details.get(alert.id);
|
|
111
|
+
const previous = detail ? { alert: detail, acknowledged: false } : this.previous(alert.id);
|
|
112
|
+
if (previous && previous.alert.timestamp >= alert.timestamp) return;
|
|
113
|
+
// A resumed agent keeps its id. Replace its unread attempt, or reopen an
|
|
114
|
+
// acknowledged identity only for a newer failure, never for a stale notice.
|
|
115
|
+
if (previous && !previous.acknowledged) {
|
|
116
|
+
if (detail) this.details.set(alert.id, alert);
|
|
117
|
+
else { this.overflow[previous.alert.state]--; this.overflow[alert.state]++; }
|
|
118
|
+
} else if (this.details.size < MAX_FLEET_ALERT_DETAILS) this.details.set(alert.id, alert);
|
|
119
|
+
else this.overflow[alert.state]++;
|
|
120
|
+
this.write({ alert, acknowledged: false });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
acknowledge(id: string): void {
|
|
124
|
+
const detail = this.details.get(id);
|
|
125
|
+
const previous = detail ? { alert: detail, acknowledged: false } : this.previous(id);
|
|
126
|
+
if (!previous || previous.acknowledged) return;
|
|
127
|
+
if (!this.details.delete(id)) this.overflow[previous.alert.state]--;
|
|
128
|
+
this.write({ alert: previous.alert, acknowledged: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Debounced by the controller; a net-no-change burst writes nothing. */
|
|
132
|
+
flushDisclosure(): void {
|
|
133
|
+
if (this.collapsed !== this.settledCollapsed) this.write();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private previous(id: string): FleetSnapshot["transition"] {
|
|
137
|
+
const branch = this.entries();
|
|
138
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
139
|
+
const entry = branch[i];
|
|
140
|
+
if (entry.type !== "custom" || entry.customType !== FLEET_STATE_ENTRY) continue;
|
|
141
|
+
const data = entry.data as FleetSnapshot | undefined;
|
|
142
|
+
if (data?.version === FLEET_STATE_VERSION && data.transition?.alert?.id === id) {
|
|
143
|
+
const valid = snapshot(data);
|
|
144
|
+
if (valid) return valid.transition;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private write(transition?: FleetSnapshot["transition"]): void {
|
|
151
|
+
this.append?.({ version: FLEET_STATE_VERSION, collapsed: this.collapsed, alerts: [...this.details.values()], overflow: { ...this.overflow }, ...(transition ? { transition } : {}) });
|
|
152
|
+
this.settledCollapsed = this.collapsed;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -15,6 +15,7 @@ import { pathToFileURL } from "node:url";
|
|
|
15
15
|
import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
16
16
|
import type { NotificationDetails } from "../types.js";
|
|
17
17
|
import { formatCost, formatMs, formatTokens, formatTurns, type Theme } from "./agent-widget.js";
|
|
18
|
+
import { fallbackFlag, fallbackFlagText, hasFallback } from "./fallback-flag.js";
|
|
18
19
|
|
|
19
20
|
/** Lines of the result preview shown when expanded. */
|
|
20
21
|
const EXPANDED_PREVIEW_LINES = 30;
|
|
@@ -41,15 +42,31 @@ function statParts(d: NotificationDetails, showCost: boolean): string[] {
|
|
|
41
42
|
/** `✓ desc · 12 tools · 33.8k token · ~$0.0042 · 41.2s`. */
|
|
42
43
|
export function noticeSummary(d: NotificationDetails, theme: Theme, showCost: boolean): string {
|
|
43
44
|
const dot = ` ${theme.fg("dim", "·")} `;
|
|
44
|
-
|
|
45
|
+
// Leading, before the unbounded description: the collapsed line is truncated
|
|
46
|
+
// from the right, and a substituted model must not be what gets cut.
|
|
47
|
+
const flag = fallbackFlag(theme, d) ? `${fallbackFlag(theme, d)} ` : "";
|
|
48
|
+
const head = `${icon(d, theme)} ${flag}${theme.bold(d.description)}`;
|
|
49
|
+
// Ahead of the stats for the same reason: truncation must not cut it.
|
|
50
|
+
const resume = d.resumeHint ? [theme.fg("warning", `resume ${d.id} to continue`)] : [];
|
|
45
51
|
const stats = statParts(d, showCost).map(p => theme.fg("dim", p));
|
|
46
|
-
return [head, ...stats].join(dot);
|
|
52
|
+
return [head, ...resume, ...stats].join(dot);
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/** `3 agents finished · ✓ a, ✓ b, ✗ c`. */
|
|
50
56
|
export function groupSummary(all: NotificationDetails[], theme: Theme): string {
|
|
51
57
|
const names = all.map(d => `${icon(d, theme)} ${d.description}`).join(theme.fg("dim", ", "));
|
|
52
|
-
|
|
58
|
+
// In the heading, ahead of the member list that truncation eats from the end,
|
|
59
|
+
// so a fallback member listed last is still counted.
|
|
60
|
+
const groups = new Map<string, { carrier: NotificationDetails; count: number }>();
|
|
61
|
+
for (const carrier of all.filter(d => d.modelTier !== undefined || hasFallback(d))) {
|
|
62
|
+
const key = `${hasFallback(carrier)}:${fallbackFlagText(1, carrier)}`;
|
|
63
|
+
const group = groups.get(key);
|
|
64
|
+
if (group) group.count++;
|
|
65
|
+
else groups.set(key, { carrier, count: 1 });
|
|
66
|
+
}
|
|
67
|
+
const flags = [...groups.values()].map(({ carrier, count }) => fallbackFlag(theme, carrier, count)).join(" ");
|
|
68
|
+
const flag = flags ? ` ${flags}` : "";
|
|
69
|
+
return `${theme.bold(`${all.length} agents finished`)}${flag} ${theme.fg("dim", "·")} ${names}`;
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
/** The transcript as an OSC 8 hyperlink, so a click opens it. */
|
|
@@ -68,6 +85,8 @@ function detailLines(d: NotificationDetails, theme: Theme, showCost: boolean, in
|
|
|
68
85
|
stats.push(...statParts(d, showCost));
|
|
69
86
|
if (stats.length > 0) lines.push(indent + theme.fg("dim", stats.join(" · ")));
|
|
70
87
|
if (d.error) lines.push(indent + theme.fg("error", d.error));
|
|
88
|
+
if (d.resumeHint) lines.push(indent + theme.fg("warning", d.resumeHint));
|
|
89
|
+
if (d.modelNote) lines.push(indent + theme.fg("warning", d.modelNote));
|
|
71
90
|
for (const line of d.resultPreview.split("\n").slice(0, EXPANDED_PREVIEW_LINES)) {
|
|
72
91
|
lines.push(indent + theme.fg("dim", line));
|
|
73
92
|
}
|
package/src/ui/workflow-card.ts
CHANGED
|
@@ -45,7 +45,8 @@ import {
|
|
|
45
45
|
type WorkflowEntry,
|
|
46
46
|
type WorkflowRunStatus,
|
|
47
47
|
} from "../workflow/progress.js";
|
|
48
|
-
import type
|
|
48
|
+
import { formatCompactTokens, type Theme } from "./agent-widget.js";
|
|
49
|
+
import { fallbackFlagText, hasFallback } from "./fallback-flag.js";
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Header re-render cadence. Claude Code ticks the workflow clock once a second,
|
|
@@ -183,13 +184,6 @@ export interface WorkflowCardInput {
|
|
|
183
184
|
* Formatting
|
|
184
185
|
* ------------------------------------------------------------------------- */
|
|
185
186
|
|
|
186
|
-
/** `18.4k` / `1.2M` — bare magnitude, since the row already reads as a stat. */
|
|
187
|
-
export function formatCompactTokens(count: number): string {
|
|
188
|
-
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
189
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
190
|
-
return `${count}`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
187
|
/**
|
|
194
188
|
* One label for the model pair. A fallback that never differed from the primary
|
|
195
189
|
* would just be noise, so it only shows when the run actually has two models in
|
|
@@ -355,12 +349,15 @@ export function layoutWorkflowCard(input: WorkflowCardInput): WorkflowCardLine[]
|
|
|
355
349
|
const rail = lastGroup ? " " : `${glyphs.vertical} `;
|
|
356
350
|
group.agents.forEach((entry, agentIndex) => {
|
|
357
351
|
const lastAgent = agentIndex === group.agents.length - 1;
|
|
352
|
+
const flag = entry.modelTier || hasFallback(entry) ? fallbackFlagText(1, entry) : "";
|
|
358
353
|
const segments: WorkflowCardLine = [
|
|
359
354
|
{ text: " " },
|
|
360
355
|
{ text: rail, color: "dim" },
|
|
361
356
|
{ text: `${lastAgent ? glyphs.lastBranch : glyphs.branch} `, color: "dim" },
|
|
362
357
|
rowGlyph(entry, glyphs),
|
|
363
358
|
{ text: " " },
|
|
359
|
+
// Leading, ahead of the label: the row is clamped from the right.
|
|
360
|
+
...(flag ? [{ text: `${flag} `, color: hasFallback(entry) ? "warning" as const : "dim" as const }] : []),
|
|
364
361
|
];
|
|
365
362
|
|
|
366
363
|
// Prepended rather than folded into `agentStatSegments`, which is the
|
|
@@ -370,7 +367,7 @@ export function layoutWorkflowCard(input: WorkflowCardInput): WorkflowCardLine[]
|
|
|
370
367
|
const statParts = entry.cached
|
|
371
368
|
? [REPLAYED_ANNOTATION, ...agentStatSegments(entry)]
|
|
372
369
|
: agentStatSegments(entry);
|
|
373
|
-
const pad = Math.max(0, labelColumn - visibleWidth(entry.label));
|
|
370
|
+
const pad = Math.max(0, labelColumn - visibleWidth(entry.label) - (flag ? visibleWidth(flag) + 1 : 0));
|
|
374
371
|
segments.push({ text: statParts.length > 0 ? entry.label + " ".repeat(pad) : entry.label });
|
|
375
372
|
for (const part of statParts) {
|
|
376
373
|
segments.push({ text: " · ", color: "dim" }, { text: part, color: "dim" });
|
|
@@ -61,11 +61,11 @@ import {
|
|
|
61
61
|
type WorkflowDisplayState,
|
|
62
62
|
type WorkflowEntry,
|
|
63
63
|
} from "../workflow/progress.js";
|
|
64
|
-
import { SPINNER, type Theme } from "./agent-widget.js";
|
|
64
|
+
import { formatCompactTokens, SPINNER, type Theme } from "./agent-widget.js";
|
|
65
|
+
import { boundedWrappedRows, fallbackFlagText, hasFallback, withLeadingFlag } from "./fallback-flag.js";
|
|
65
66
|
import {
|
|
66
67
|
ASCII_GLYPHS,
|
|
67
68
|
clampLine,
|
|
68
|
-
formatCompactTokens,
|
|
69
69
|
formatModel,
|
|
70
70
|
formatThinking,
|
|
71
71
|
REPLAYED_ANNOTATION,
|
|
@@ -104,6 +104,8 @@ export const MIN_PANE_BODY_ROWS = 6;
|
|
|
104
104
|
export const PROMPT_COLLAPSED_LINES = 4;
|
|
105
105
|
/** Spinner cadence. Unlike the card's 1s header tick, this row really animates. */
|
|
106
106
|
export const WORKFLOW_DIALOG_SPINNER_MS = 80;
|
|
107
|
+
/** Maximum physical detail rows a fallback note may occupy. */
|
|
108
|
+
const MODEL_NOTE_ROWS = 4;
|
|
107
109
|
|
|
108
110
|
/* ------------------------------------------------------------------------- *
|
|
109
111
|
* Glyphs
|
|
@@ -458,12 +460,6 @@ export function leftPaneWidth(width: number): number {
|
|
|
458
460
|
return Math.max(1, Math.min(LEFT_PANE_WIDTH, Math.floor(available / 3), available - 1));
|
|
459
461
|
}
|
|
460
462
|
|
|
461
|
-
/** Fill a title out to the cell width with rule, so the corners stay put. */
|
|
462
|
-
function padTitle(line: WorkflowCardLine, width: number, horizontal: string): WorkflowCardLine {
|
|
463
|
-
const gap = Math.max(0, width - lineWidth(line));
|
|
464
|
-
return gap > 0 ? [...line, { text: horizontal.repeat(gap), color: "dim" }] : line;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
463
|
/** One pane row, padded to exactly `width` so the frame stays vertical. */
|
|
468
464
|
function padCell(line: WorkflowCardLine, width: number): WorkflowCardLine {
|
|
469
465
|
const clamped = clampLine(line, width);
|
|
@@ -518,9 +514,9 @@ function paneFrame(options: {
|
|
|
518
514
|
const lines: WorkflowCardLine[] = [];
|
|
519
515
|
lines.push([
|
|
520
516
|
{ text: box.topLeft, color: "dim" },
|
|
521
|
-
...
|
|
517
|
+
...frameTitle(options.leftTitle, left, glyphs),
|
|
522
518
|
{ text: box.topTee, color: "dim" },
|
|
523
|
-
...
|
|
519
|
+
...frameTitle(options.rightTitle, right, glyphs),
|
|
524
520
|
{ text: box.topRight, color: "dim" },
|
|
525
521
|
]);
|
|
526
522
|
|
|
@@ -637,16 +633,33 @@ function agentRow(options: {
|
|
|
637
633
|
}): WorkflowCardLine {
|
|
638
634
|
const { entry, selected, glyphs, width } = options;
|
|
639
635
|
const display = displayState(entry, options.workflowActive);
|
|
640
|
-
const
|
|
636
|
+
const prefix: WorkflowCardLine = [
|
|
641
637
|
{ text: " " },
|
|
642
638
|
{ text: selected ? glyphs.pointer : " ", color: "accent" },
|
|
643
639
|
{ text: " " },
|
|
644
640
|
dialogRowGlyph(display, glyphs, options.spinnerFrame),
|
|
645
641
|
{ text: " " },
|
|
642
|
+
];
|
|
643
|
+
const flag = entry.modelTier || hasFallback(entry) ? fallbackFlagText(1, entry) : "";
|
|
644
|
+
const flagColor = hasFallback(entry) ? "warning" as const : "dim" as const;
|
|
645
|
+
// The narrow pane holds the label and nothing else; reserve its status prefix,
|
|
646
|
+
// then let the shared flag degrade rather than clipping it to `[fal…`.
|
|
647
|
+
if (options.compact && flag) {
|
|
648
|
+
const flagWidth = Math.max(0, width - lineWidth(prefix));
|
|
649
|
+
const compactFlag = withLeadingFlag("", flag, "", flagWidth);
|
|
650
|
+
const labelWidth = Math.max(0, flagWidth - visibleWidth(compactFlag) - 1);
|
|
651
|
+
const label = truncateToWidth(entry.label, labelWidth, "");
|
|
652
|
+
return [
|
|
653
|
+
...prefix,
|
|
654
|
+
{ text: compactFlag, color: flagColor },
|
|
655
|
+
...(label ? [{ text: ` ${label}`, color: selected ? "accent" as const : undefined }] : []),
|
|
656
|
+
];
|
|
657
|
+
}
|
|
658
|
+
const head: WorkflowCardLine = [
|
|
659
|
+
...prefix,
|
|
660
|
+
...(flag ? [{ text: `${flag} `, color: flagColor }] : []),
|
|
646
661
|
{ text: entry.label, color: selected ? "accent" : undefined },
|
|
647
662
|
];
|
|
648
|
-
// The narrow pane holds the label and nothing else; there is no room for a
|
|
649
|
-
// stat tail, and clamping one would just spend columns on a truncated word.
|
|
650
663
|
if (options.compact) return clampLine(head, width);
|
|
651
664
|
|
|
652
665
|
const model = formatModel(entry);
|
|
@@ -796,6 +809,16 @@ export function layoutWorkflowDialog(input: WorkflowDialogInput): WorkflowCardLi
|
|
|
796
809
|
if (stats.length > 0) {
|
|
797
810
|
detailRows.push(clampLine([{ text: ` ${stats.join(" · ")}`, color: "dim" }], rightWidth));
|
|
798
811
|
}
|
|
812
|
+
// A substituted model is never cosmetic: name the failed input and what ran.
|
|
813
|
+
// Bound physical rows, not text width: a configured value can contain
|
|
814
|
+
// newlines, each of which otherwise displaces the final diagnosis below the
|
|
815
|
+
// pane's fixed row budget.
|
|
816
|
+
if (entry.modelNote) {
|
|
817
|
+
const noteWidth = Math.max(1, rightWidth - 1);
|
|
818
|
+
for (const line of boundedWrappedRows(entry.modelNote, noteWidth, MODEL_NOTE_ROWS, glyphs.ellipsis)) {
|
|
819
|
+
detailRows.push(clampLine([{ text: ` ${line}`, color: "warning" }], rightWidth));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
799
822
|
|
|
800
823
|
const prompt = previewLines(entry.promptPreview);
|
|
801
824
|
const collapsed = !state.promptExpanded && prompt.length > PROMPT_COLLAPSED_LINES;
|
package/src/workflow/host.ts
CHANGED
|
@@ -36,8 +36,7 @@ import { existsSync } from "node:fs";
|
|
|
36
36
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
37
|
import type { AgentManager } from "../agent-manager.js";
|
|
38
38
|
import { getAgentConfig, resolveSpawnType } from "../agent-types.js";
|
|
39
|
-
import {
|
|
40
|
-
import { checkModelScope } from "../model-scope.js";
|
|
39
|
+
import { modelSelectionNote, selectModel } from "../model-selection.js";
|
|
41
40
|
import type { AgentRecord, ThinkingLevel } from "../types.js";
|
|
42
41
|
import { getLifetimeTotal } from "../usage.js";
|
|
43
42
|
import type { WorkflowGateResult, WorkflowHost, WorkflowSpawnResult } from "./runtime.js";
|
|
@@ -51,6 +50,8 @@ import { resolveWorkflowSource } from "./saved.js";
|
|
|
51
50
|
export const DEFAULT_GATE_TIMEOUT_MS = 10 * 60_000;
|
|
52
51
|
|
|
53
52
|
export interface WorkflowHostOptions {
|
|
53
|
+
/** Home directory for nested saved workflows. */
|
|
54
|
+
homeDir?: string;
|
|
54
55
|
pi: ExtensionAPI;
|
|
55
56
|
ctx: ExtensionContext;
|
|
56
57
|
manager: AgentManager;
|
|
@@ -113,10 +114,13 @@ function resolvedInfo(record: AgentRecord | undefined) {
|
|
|
113
114
|
modelId: invocation.modelId,
|
|
114
115
|
thinking: invocation.thinking,
|
|
115
116
|
requestedThinking: invocation.requestedThinking,
|
|
117
|
+
// From the record, so a resumed child reports the model it actually ran on.
|
|
118
|
+
...(modelSelectionNote(record) ? { modelNote: modelSelectionNote(record) } : {}),
|
|
119
|
+
...(record?.modelTier ? { modelTier: record.modelTier } : {}),
|
|
116
120
|
};
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
123
|
+
function toSpawnResult(record: AgentRecord, opts: { resumed?: boolean } = {}): WorkflowSpawnResult {
|
|
120
124
|
const tokens = getLifetimeTotal(record.lifetimeUsage);
|
|
121
125
|
// Reported separately from `tokens`, which is the lifetime total. The script's
|
|
122
126
|
// `budget` counts *output* tokens, as Claude Code's does — billing the input
|
|
@@ -131,15 +135,26 @@ function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
|
131
135
|
...(cwd !== undefined ? { cwd } : {}),
|
|
132
136
|
};
|
|
133
137
|
|
|
138
|
+
// Disclosed in the text/error the script reads AND as a field. A schema'd
|
|
139
|
+
// payload is JSON the script parses, so the note is only a field there.
|
|
140
|
+
const note = modelSelectionNote(record);
|
|
141
|
+
const lead = note ? `${note}\n\n` : "";
|
|
142
|
+
const modelNote = { ...(note ? { modelNote: note } : {}), ...(record.modelTier ? { modelTier: record.modelTier } : {}) };
|
|
143
|
+
|
|
134
144
|
if (succeeded(record)) {
|
|
135
145
|
return {
|
|
136
146
|
...common,
|
|
147
|
+
...modelNote,
|
|
137
148
|
ok: true,
|
|
138
149
|
// The schema'd payload when there is one: `result` is prose, and for a
|
|
139
150
|
// worktree child it has had the branch note appended, so it would not
|
|
140
151
|
// parse. A child asked for a schema that produced none never reaches
|
|
141
152
|
// here — `runAgent` reports that through `failure`.
|
|
142
|
-
|
|
153
|
+
// A RESUMED child answers in prose (resume forbids `schema`), but its record
|
|
154
|
+
// still holds the original run's `structuredJson`; returning that would hand
|
|
155
|
+
// the script a stale payload with no note, and the worker only injects or
|
|
156
|
+
// logs for schema calls. So a resume reads `result` and leads with the note.
|
|
157
|
+
text: !opts.resumed && record.structuredJson !== undefined ? record.structuredJson : `${lead}${record.result ?? ""}`,
|
|
143
158
|
...(record.structuredRetried ? { structuredRetried: true } : {}),
|
|
144
159
|
};
|
|
145
160
|
}
|
|
@@ -147,15 +162,11 @@ function toSpawnResult(record: AgentRecord): WorkflowSpawnResult {
|
|
|
147
162
|
// fleet list, a workflow abort. That is the same thing the workflows dialog's
|
|
148
163
|
// skip action means, so it renders as skipped rather than failed.
|
|
149
164
|
if (record.status === "stopped") {
|
|
150
|
-
return { ...common, ok: false, skipped: true, error: record.error ?? "Stopped." };
|
|
165
|
+
return { ...common, ...modelNote, ok: false, skipped: true, error: `${lead}${record.error ?? "Stopped."}` };
|
|
151
166
|
}
|
|
152
|
-
return { ...common, ok: false, error: record.error ?? `Agent ${record.status}.` };
|
|
167
|
+
return { ...common, ...modelNote, ok: false, error: `${lead}${record.error ?? `Agent ${record.status}.`}` };
|
|
153
168
|
}
|
|
154
169
|
|
|
155
|
-
/** Shell used to run a `gate` command, mirroring how a user would type it. */
|
|
156
|
-
const GATE_SHELL: readonly [string, string] =
|
|
157
|
-
process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"];
|
|
158
|
-
|
|
159
170
|
export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
160
171
|
const { pi, ctx, manager } = deps;
|
|
161
172
|
/** Runtime agent id → the manager record it spawned. Never pruned mid-run. */
|
|
@@ -174,7 +185,10 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
174
185
|
* decided from one behaviour, whichever route the command took.
|
|
175
186
|
*/
|
|
176
187
|
async function executeGate(command: string, cwd: string): Promise<WorkflowGateResult> {
|
|
177
|
-
|
|
188
|
+
// The shell a user would type the command into. Read per call rather than
|
|
189
|
+
// at import, so the choice follows the platform the gate actually runs on.
|
|
190
|
+
const [shell, flag] = process.platform === "win32" ? ["cmd", "/c"] : ["sh", "-c"];
|
|
191
|
+
const result = await pi.exec(shell, [flag, command], {
|
|
178
192
|
cwd,
|
|
179
193
|
timeout: deps.gateTimeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
|
|
180
194
|
...(deps.signal !== undefined ? { signal: deps.signal } : {}),
|
|
@@ -196,45 +210,17 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
196
210
|
const dispatch = resolveSpawnType(request.agentType);
|
|
197
211
|
if (!dispatch.ok) return { ok: false, error: dispatch.message };
|
|
198
212
|
|
|
199
|
-
// Same precedence as the Agent tool: the caller's model wins, then the
|
|
200
|
-
// `agentModels` setting, the agent definition's is next, and the parent's
|
|
201
|
-
// is the floor. A model the script
|
|
202
|
-
// named and we cannot resolve is an error; one the definition named falls
|
|
203
|
-
// back to the parent silently, because the script never asked for it.
|
|
204
|
-
let model = ctx.model;
|
|
205
213
|
const config = getAgentConfig(dispatch.type);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (request.model !== undefined) return { ok: false, error: resolved };
|
|
211
|
-
} else {
|
|
212
|
-
model = resolved;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Same scopeModels policy as the Agent tool and the nested delegation
|
|
217
|
-
// tools: a script's `agent({ model })` is a runtime LLM choice, and the
|
|
218
|
-
// script is written by the model, so it must not reach a model the user's
|
|
219
|
-
// enabledModels list excludes. `callerSupplied` keys off `request.model`
|
|
220
|
-
// and NOT `modelInput` — the latter has already absorbed the agent file's
|
|
221
|
-
// own `model:`, which is user-authored config and so earns the
|
|
222
|
-
// warn-and-proceed branch rather than a refusal.
|
|
223
|
-
const scopeVerdict = checkModelScope({
|
|
224
|
-
model,
|
|
225
|
-
cwd: ctx.cwd,
|
|
226
|
-
modelRegistry: ctx.modelRegistry,
|
|
227
|
-
callerSupplied: request.model !== undefined,
|
|
228
|
-
agentLabel: config?.displayName ?? dispatch.type,
|
|
229
|
-
modelInput,
|
|
214
|
+
const selection = selectModel({
|
|
215
|
+
homeDir: deps.homeDir,
|
|
216
|
+
agentType: dispatch.type, definitionModel: config?.model, paramModel: request.model,
|
|
217
|
+
parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd, agentLabel: config?.displayName,
|
|
230
218
|
});
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
warnedScopeMessages.add(scopeVerdict.message);
|
|
237
|
-
ctx.ui.notify(scopeVerdict.message, "warning");
|
|
219
|
+
if (typeof selection === "string") return { ok: false, error: selection };
|
|
220
|
+
const model = selection.model;
|
|
221
|
+
if (selection.scopeWarning && !warnedScopeMessages.has(selection.scopeWarning)) {
|
|
222
|
+
warnedScopeMessages.add(selection.scopeWarning);
|
|
223
|
+
ctx.ui.notify(selection.scopeWarning, "warning");
|
|
238
224
|
}
|
|
239
225
|
|
|
240
226
|
/**
|
|
@@ -298,6 +284,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
298
284
|
request.prompt,
|
|
299
285
|
{
|
|
300
286
|
description: request.label,
|
|
287
|
+
modelSelection: selection,
|
|
301
288
|
// Same as the Agent tool: `isolated` comes off the agent definition.
|
|
302
289
|
// Left out, a hermetic agent's script-spawned children loaded every
|
|
303
290
|
// host extension and picked up its MCP and web tools.
|
|
@@ -343,6 +330,10 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
343
330
|
reportResolved();
|
|
344
331
|
},
|
|
345
332
|
);
|
|
333
|
+
if (record.modelTier) {
|
|
334
|
+
const finalInfo = resolvedInfo(record);
|
|
335
|
+
if (finalInfo) request.onResolved?.(finalInfo);
|
|
336
|
+
}
|
|
346
337
|
return { ...toSpawnResult(record), ...(gate !== undefined ? { gate } : {}) };
|
|
347
338
|
} catch (error) {
|
|
348
339
|
// Strict worktree isolation rejects out of `awaitStartup` — the child
|
|
@@ -364,6 +355,12 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
364
355
|
if (id === undefined) {
|
|
365
356
|
return { ok: false, error: `Cannot resume "${agentId}" — it never started.` };
|
|
366
357
|
}
|
|
358
|
+
// The provenance is already on the record, so the running row shows it
|
|
359
|
+
// now rather than only once the resume has finished.
|
|
360
|
+
const existing = manager.getRecord(id);
|
|
361
|
+
if (modelSelectionNote(existing)) {
|
|
362
|
+
onResolved?.({ recordId: id, modelNote: modelSelectionNote(existing), modelTier: existing?.modelTier });
|
|
363
|
+
}
|
|
367
364
|
const record = await manager.resume(id, prompt, deps.signal);
|
|
368
365
|
if (record === undefined) {
|
|
369
366
|
return {
|
|
@@ -379,7 +376,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
379
376
|
onResolved?.({ recordId: id });
|
|
380
377
|
const info = resolvedInfo(record);
|
|
381
378
|
if (info !== undefined) onResolved?.(info);
|
|
382
|
-
return toSpawnResult(record);
|
|
379
|
+
return toSpawnResult(record, { resumed: true });
|
|
383
380
|
},
|
|
384
381
|
|
|
385
382
|
/**
|
|
@@ -387,7 +384,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
|
|
|
387
384
|
* comes back is a workflow; this only finds it.
|
|
388
385
|
*/
|
|
389
386
|
loadWorkflow(ref) {
|
|
390
|
-
return resolveWorkflowSource(ref, ctx.cwd);
|
|
387
|
+
return resolveWorkflowSource(ref, ctx.cwd, deps.homeDir);
|
|
391
388
|
},
|
|
392
389
|
|
|
393
390
|
// Reached only for a gate the spawn did not already run — a child with no
|
package/src/workflow/journal.ts
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
48
48
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
49
|
+
import { isTierDisclosure, type TierDisclosure, tierDisclosure } from "../model-tiers.js";
|
|
49
50
|
|
|
50
51
|
/** One settled agent call, as replayed. */
|
|
51
52
|
export interface WorkflowJournalEntry {
|
|
@@ -57,6 +58,14 @@ export interface WorkflowJournalEntry {
|
|
|
57
58
|
ok: boolean;
|
|
58
59
|
/** The agent's answer, when it had one. */
|
|
59
60
|
text?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The `Note:` line when the child ran on another model than configured.
|
|
63
|
+
* Replayed with the text so a resumed run discloses it too; absent on
|
|
64
|
+
* journals written before it existed, which means "no note", not an error.
|
|
65
|
+
*/
|
|
66
|
+
modelNote?: string;
|
|
67
|
+
/** The original tier outcome, retained on replay and journal-of-journal runs. */
|
|
68
|
+
modelTier?: TierDisclosure;
|
|
60
69
|
/**
|
|
61
70
|
* Whether the call continued an earlier child (`agent({ resume })`).
|
|
62
71
|
*
|
|
@@ -144,7 +153,8 @@ export function readJournal(path: string): WorkflowJournalEntry[] {
|
|
|
144
153
|
/** Append one settled call. Failure to write is not failure to run. */
|
|
145
154
|
export function appendJournal(path: string, entry: WorkflowJournalEntry): void {
|
|
146
155
|
try {
|
|
147
|
-
|
|
156
|
+
const persisted = entry.modelTier ? { ...entry, modelTier: tierDisclosure(entry.modelTier) } : entry;
|
|
157
|
+
appendFileSync(path, `${JSON.stringify(persisted)}\n`, "utf-8");
|
|
148
158
|
} catch {
|
|
149
159
|
// A journal that cannot be written costs a future resume, nothing more.
|
|
150
160
|
}
|
|
@@ -159,6 +169,8 @@ function isEntry(value: unknown): value is WorkflowJournalEntry {
|
|
|
159
169
|
typeof entry.key === "string" &&
|
|
160
170
|
typeof entry.ok === "boolean" &&
|
|
161
171
|
(entry.text === undefined || typeof entry.text === "string") &&
|
|
172
|
+
(entry.modelNote === undefined || typeof entry.modelNote === "string") &&
|
|
173
|
+
(entry.modelTier === undefined || isTierDisclosure(entry.modelTier)) &&
|
|
162
174
|
(entry.resumed === undefined || entry.resumed === true)
|
|
163
175
|
);
|
|
164
176
|
}
|
package/src/workflow/meta.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface WorkflowMeta {
|
|
|
38
38
|
|
|
39
39
|
export interface MetaExtraction {
|
|
40
40
|
meta: WorkflowMeta;
|
|
41
|
+
/** Byte offset immediately after the validated meta object literal. */
|
|
42
|
+
metaEnd: number;
|
|
41
43
|
/**
|
|
42
44
|
* The script with the leading `export ` stripped, so `const meta = {...}`
|
|
43
45
|
* compiles inside the vm. Byte offsets after the keyword are untouched, which
|
|
@@ -175,8 +177,9 @@ function scanObjectLiteral(source: string, open: number): ScanResult {
|
|
|
175
177
|
*/
|
|
176
178
|
function isRegexPosition(source: string, i: number): boolean {
|
|
177
179
|
let j = i - 1;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
// Never runs off the front: the scan starts at the literal's `{`, so the
|
|
181
|
+
// walk back always stops on it or something after it.
|
|
182
|
+
while (/\s/.test(source[j])) j--;
|
|
180
183
|
const prev = source[j];
|
|
181
184
|
// Identifier/number/closer before `/` means division.
|
|
182
185
|
return !/[\w$)\]]/.test(prev);
|
|
@@ -289,7 +292,7 @@ export function extractMeta(source: string): MetaExtraction {
|
|
|
289
292
|
const exportAt = source.indexOf("export", declaration.index);
|
|
290
293
|
const body = `${source.slice(0, exportAt)}${" ".repeat(6)}${source.slice(exportAt + 6)}`;
|
|
291
294
|
|
|
292
|
-
return { meta, body };
|
|
295
|
+
return { meta, metaEnd: close, body };
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
/**
|
|
@@ -309,7 +312,7 @@ export function workflowCallName(args: { script?: string; scriptPath?: string; n
|
|
|
309
312
|
if (source === undefined || source === "") {
|
|
310
313
|
// A path-only call would need a synchronous file read per rebuild to do
|
|
311
314
|
// better than this, and the file name is what the author will recognize.
|
|
312
|
-
if (args.scriptPath !== undefined) return args.scriptPath.
|
|
315
|
+
if (args.scriptPath !== undefined) return args.scriptPath.replace(/^[\s\S]*[/\\]/, "");
|
|
313
316
|
// A saved workflow is already named by the caller; no read needed at all.
|
|
314
317
|
return args.name !== undefined && args.name !== "" ? args.name : "workflow";
|
|
315
318
|
}
|