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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -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
- const head = `${icon(d, theme)} ${theme.bold(d.description)}`;
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
- return `${theme.bold(`${all.length} agents finished`)} ${theme.fg("dim", "·")} ${names}`;
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
  }
@@ -45,7 +45,8 @@ import {
45
45
  type WorkflowEntry,
46
46
  type WorkflowRunStatus,
47
47
  } from "../workflow/progress.js";
48
- import type { Theme } from "./agent-widget.js";
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
- ...padTitle(frameTitle(options.leftTitle, left, glyphs), left, box.horizontal),
517
+ ...frameTitle(options.leftTitle, left, glyphs),
522
518
  { text: box.topTee, color: "dim" },
523
- ...padTitle(frameTitle(options.rightTitle, right, glyphs), right, box.horizontal),
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 head: WorkflowCardLine = [
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;
@@ -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 { configuredAgentModel, resolveModel } from "../model-resolver.js";
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
- text: record.structuredJson ?? record.result ?? "",
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
- const result = await pi.exec(GATE_SHELL[0], [GATE_SHELL[1], command], {
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 modelInput = request.model ?? configuredAgentModel(dispatch.type, config?.model);
207
- if (modelInput !== undefined) {
208
- const resolved = resolveModel(modelInput, ctx.modelRegistry);
209
- if (typeof resolved === "string") {
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
- // This agent's failure, not the run's — the same shape a bad agent type
232
- // takes above. The script sees `null` and its siblings carry on, which is
233
- // the difference between one refused model and a discarded fan-out.
234
- if (scopeVerdict.kind === "error") return { ok: false, error: scopeVerdict.message };
235
- if (scopeVerdict.kind === "warn" && !warnedScopeMessages.has(scopeVerdict.message)) {
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
@@ -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
- appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf-8");
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
  }
@@ -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
- while (j >= 0 && /\s/.test(source[j])) j--;
179
- if (j < 0) return true;
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.split(/[/\\]/).pop() ?? "workflow";
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
  }