@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.8

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 (46) hide show
  1. package/README.md +160 -43
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +418 -118
  5. package/src/agent-runner.ts +155 -62
  6. package/src/cross-extension-rpc.ts +11 -36
  7. package/src/custom-agents.ts +9 -5
  8. package/src/default-agents.ts +2 -4
  9. package/src/enabled-models.ts +8 -7
  10. package/src/enterprise-local.ts +5 -5
  11. package/src/home-paths.ts +27 -0
  12. package/src/index.ts +231 -107
  13. package/src/invocation-config.ts +8 -35
  14. package/src/memory.ts +9 -9
  15. package/src/mention-clone.ts +9 -5
  16. package/src/model-resolver.ts +41 -11
  17. package/src/model-scope.ts +2 -1
  18. package/src/model-selection.ts +159 -0
  19. package/src/model-tiers.ts +171 -0
  20. package/src/nested-tools.ts +31 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +346 -0
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +41 -19
  27. package/src/settings.ts +111 -8
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +33 -0
  31. package/src/ui/agent-row.ts +13 -3
  32. package/src/ui/agent-widget.ts +15 -4
  33. package/src/ui/conversation-viewer.ts +6 -3
  34. package/src/ui/fallback-flag.ts +90 -0
  35. package/src/ui/fleet-list.ts +363 -53
  36. package/src/ui/fleet-state.ts +154 -0
  37. package/src/ui/notification-view.ts +18 -2
  38. package/src/ui/workflow-card.ts +5 -1
  39. package/src/ui/workflow-dialog.ts +10 -0
  40. package/src/workflow/host.ts +43 -45
  41. package/src/workflow/journal.ts +13 -1
  42. package/src/workflow/progress.ts +4 -0
  43. package/src/workflow/runtime.ts +25 -8
  44. package/src/workflow/saved.ts +19 -17
  45. package/src/workflow/task.ts +10 -0
  46. package/src/workflow/worker-source.ts +156 -38
@@ -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,7 +42,10 @@ 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)}`;
45
49
  const stats = statParts(d, showCost).map(p => theme.fg("dim", p));
46
50
  return [head, ...stats].join(dot);
47
51
  }
@@ -49,7 +53,18 @@ export function noticeSummary(d: NotificationDetails, theme: Theme, showCost: bo
49
53
  /** `3 agents finished · ✓ a, ✓ b, ✗ c`. */
50
54
  export function groupSummary(all: NotificationDetails[], theme: Theme): string {
51
55
  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}`;
56
+ // In the heading, ahead of the member list that truncation eats from the end,
57
+ // so a fallback member listed last is still counted.
58
+ const groups = new Map<string, { carrier: NotificationDetails; count: number }>();
59
+ for (const carrier of all.filter(d => d.modelTier !== undefined || hasFallback(d))) {
60
+ const key = `${hasFallback(carrier)}:${fallbackFlagText(1, carrier)}`;
61
+ const group = groups.get(key);
62
+ if (group) group.count++;
63
+ else groups.set(key, { carrier, count: 1 });
64
+ }
65
+ const flags = [...groups.values()].map(({ carrier, count }) => fallbackFlag(theme, carrier, count)).join(" ");
66
+ const flag = flags ? ` ${flags}` : "";
67
+ return `${theme.bold(`${all.length} agents finished`)}${flag} ${theme.fg("dim", "·")} ${names}`;
53
68
  }
54
69
 
55
70
  /** The transcript as an OSC 8 hyperlink, so a click opens it. */
@@ -68,6 +83,7 @@ function detailLines(d: NotificationDetails, theme: Theme, showCost: boolean, in
68
83
  stats.push(...statParts(d, showCost));
69
84
  if (stats.length > 0) lines.push(indent + theme.fg("dim", stats.join(" · ")));
70
85
  if (d.error) lines.push(indent + theme.fg("error", d.error));
86
+ if (d.modelNote) lines.push(indent + theme.fg("warning", d.modelNote));
71
87
  for (const line of d.resultPreview.split("\n").slice(0, EXPANDED_PREVIEW_LINES)) {
72
88
  lines.push(indent + theme.fg("dim", line));
73
89
  }
@@ -46,6 +46,7 @@ import {
46
46
  type WorkflowRunStatus,
47
47
  } from "../workflow/progress.js";
48
48
  import 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,
@@ -355,12 +356,15 @@ export function layoutWorkflowCard(input: WorkflowCardInput): WorkflowCardLine[]
355
356
  const rail = lastGroup ? " " : `${glyphs.vertical} `;
356
357
  group.agents.forEach((entry, agentIndex) => {
357
358
  const lastAgent = agentIndex === group.agents.length - 1;
359
+ const flag = entry.modelTier || hasFallback(entry) ? fallbackFlagText(1, entry) : "";
358
360
  const segments: WorkflowCardLine = [
359
361
  { text: " " },
360
362
  { text: rail, color: "dim" },
361
363
  { text: `${lastAgent ? glyphs.lastBranch : glyphs.branch} `, color: "dim" },
362
364
  rowGlyph(entry, glyphs),
363
365
  { text: " " },
366
+ // Leading, ahead of the label: the row is clamped from the right.
367
+ ...(flag ? [{ text: `${flag} `, color: hasFallback(entry) ? "warning" as const : "dim" as const }] : []),
364
368
  ];
365
369
 
366
370
  // Prepended rather than folded into `agentStatSegments`, which is the
@@ -370,7 +374,7 @@ export function layoutWorkflowCard(input: WorkflowCardInput): WorkflowCardLine[]
370
374
  const statParts = entry.cached
371
375
  ? [REPLAYED_ANNOTATION, ...agentStatSegments(entry)]
372
376
  : agentStatSegments(entry);
373
- const pad = Math.max(0, labelColumn - visibleWidth(entry.label));
377
+ const pad = Math.max(0, labelColumn - visibleWidth(entry.label) - (flag ? visibleWidth(flag) + 1 : 0));
374
378
  segments.push({ text: statParts.length > 0 ? entry.label + " ".repeat(pad) : entry.label });
375
379
  for (const part of statParts) {
376
380
  segments.push({ text: " · ", color: "dim" }, { text: part, color: "dim" });
@@ -62,6 +62,7 @@ import {
62
62
  type WorkflowEntry,
63
63
  } from "../workflow/progress.js";
64
64
  import { SPINNER, type Theme } from "./agent-widget.js";
65
+ import { fallbackFlagText, hasFallback } from "./fallback-flag.js";
65
66
  import {
66
67
  ASCII_GLYPHS,
67
68
  clampLine,
@@ -643,6 +644,8 @@ function agentRow(options: {
643
644
  { text: " " },
644
645
  dialogRowGlyph(display, glyphs, options.spinnerFrame),
645
646
  { text: " " },
647
+ // Leading, ahead of the label, in both pane modes: the row clamps from the right.
648
+ ...(entry.modelTier || hasFallback(entry) ? [{ text: `${fallbackFlagText(1, entry)} `, color: hasFallback(entry) ? "warning" as const : "dim" as const }] : []),
646
649
  { text: entry.label, color: selected ? "accent" : undefined },
647
650
  ];
648
651
  // The narrow pane holds the label and nothing else; there is no room for a
@@ -796,6 +799,13 @@ export function layoutWorkflowDialog(input: WorkflowDialogInput): WorkflowCardLi
796
799
  if (stats.length > 0) {
797
800
  detailRows.push(clampLine([{ text: ` ${stats.join(" · ")}`, color: "dim" }], rightWidth));
798
801
  }
802
+ // A substituted model is never cosmetic: name the failed input and what ran.
803
+ // Wrapped, not clamped: this is the one place the full note is readable.
804
+ if (entry.modelNote) {
805
+ for (const line of wrapTextWithAnsi(entry.modelNote, Math.max(1, rightWidth - 1))) {
806
+ detailRows.push(clampLine([{ text: ` ${line}`, color: "warning" }], rightWidth));
807
+ }
808
+ }
799
809
 
800
810
  const prompt = previewLines(entry.promptPreview);
801
811
  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,9 +162,9 @@ 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
170
  /** Shell used to run a `gate` command, mirroring how a user would type it. */
@@ -196,45 +211,17 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
196
211
  const dispatch = resolveSpawnType(request.agentType);
197
212
  if (!dispatch.ok) return { ok: false, error: dispatch.message };
198
213
 
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
214
  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,
215
+ const selection = selectModel({
216
+ homeDir: deps.homeDir,
217
+ agentType: dispatch.type, definitionModel: config?.model, paramModel: request.model,
218
+ parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd, agentLabel: config?.displayName,
230
219
  });
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");
220
+ if (typeof selection === "string") return { ok: false, error: selection };
221
+ const model = selection.model;
222
+ if (selection.scopeWarning && !warnedScopeMessages.has(selection.scopeWarning)) {
223
+ warnedScopeMessages.add(selection.scopeWarning);
224
+ ctx.ui.notify(selection.scopeWarning, "warning");
238
225
  }
239
226
 
240
227
  /**
@@ -298,6 +285,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
298
285
  request.prompt,
299
286
  {
300
287
  description: request.label,
288
+ modelSelection: selection,
301
289
  // Same as the Agent tool: `isolated` comes off the agent definition.
302
290
  // Left out, a hermetic agent's script-spawned children loaded every
303
291
  // host extension and picked up its MCP and web tools.
@@ -343,6 +331,10 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
343
331
  reportResolved();
344
332
  },
345
333
  );
334
+ if (record.modelTier) {
335
+ const finalInfo = resolvedInfo(record);
336
+ if (finalInfo) request.onResolved?.(finalInfo);
337
+ }
346
338
  return { ...toSpawnResult(record), ...(gate !== undefined ? { gate } : {}) };
347
339
  } catch (error) {
348
340
  // Strict worktree isolation rejects out of `awaitStartup` — the child
@@ -364,6 +356,12 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
364
356
  if (id === undefined) {
365
357
  return { ok: false, error: `Cannot resume "${agentId}" — it never started.` };
366
358
  }
359
+ // The provenance is already on the record, so the running row shows it
360
+ // now rather than only once the resume has finished.
361
+ const existing = manager.getRecord(id);
362
+ if (modelSelectionNote(existing)) {
363
+ onResolved?.({ recordId: id, modelNote: modelSelectionNote(existing), modelTier: existing?.modelTier });
364
+ }
367
365
  const record = await manager.resume(id, prompt, deps.signal);
368
366
  if (record === undefined) {
369
367
  return {
@@ -379,7 +377,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
379
377
  onResolved?.({ recordId: id });
380
378
  const info = resolvedInfo(record);
381
379
  if (info !== undefined) onResolved?.(info);
382
- return toSpawnResult(record);
380
+ return toSpawnResult(record, { resumed: true });
383
381
  },
384
382
 
385
383
  /**
@@ -387,7 +385,7 @@ export function createWorkflowHost(deps: WorkflowHostOptions): WorkflowHost {
387
385
  * comes back is a workflow; this only finds it.
388
386
  */
389
387
  loadWorkflow(ref) {
390
- return resolveWorkflowSource(ref, ctx.cwd);
388
+ return resolveWorkflowSource(ref, ctx.cwd, deps.homeDir);
391
389
  },
392
390
 
393
391
  // 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
  }
@@ -20,6 +20,7 @@
20
20
  * unit-testable without a terminal.
21
21
  */
22
22
 
23
+ import type { TierDisclosure } from "../model-tiers.js";
23
24
  import type { WorkflowMeta, WorkflowPhaseMeta } from "./meta.js";
24
25
 
25
26
  /** Raw entry lifecycle, as written by the runtime. */
@@ -93,6 +94,9 @@ export interface WorkflowAgentEntry {
93
94
  * it.
94
95
  */
95
96
  requestedThinking?: string;
97
+ /** `Note:` line set when the child ran on the parent model because its configured one did not resolve. */
98
+ modelNote?: string;
99
+ modelTier?: TierDisclosure;
96
100
  fallbackModel?: string;
97
101
  isolation?: "worktree";
98
102
  error?: string;
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { cpus } from "node:os";
17
17
  import { Worker } from "node:worker_threads";
18
+ import type { TierDisclosure } from "../model-tiers.js";
18
19
  import { type JournalKeyInput, journalKey, type WorkflowJournalEntry } from "./journal.js";
19
20
  import { type CompiledSchema, compileJsonSchema } from "./json-schema.js";
20
21
  import { extractMeta, type WorkflowMeta } from "./meta.js";
@@ -95,6 +96,9 @@ export interface WorkflowSpawnRequest {
95
96
  modelId?: string;
96
97
  thinking?: string;
97
98
  requestedThinking?: string;
99
+ /** `Note:` line when the child ran on the parent model because its configured one did not resolve. */
100
+ modelNote?: string;
101
+ modelTier?: TierDisclosure;
98
102
  }): void;
99
103
  /**
100
104
  * Compiled from the script's `agent({ schema })`.
@@ -125,6 +129,9 @@ export interface WorkflowSpawnResult {
125
129
  text?: string;
126
130
  /** Why it failed. Present when not `ok`. */
127
131
  error?: string;
132
+ /** `Note:` line when the child ran on a different model than configured; also leads `text`/`error` (not a schema'd payload). */
133
+ modelNote?: string;
134
+ modelTier?: TierDisclosure;
128
135
  /** The user dismissed it rather than it failing; renders as skipped. */
129
136
  skipped?: boolean;
130
137
  tokens?: number;
@@ -315,6 +322,8 @@ export interface WorkflowRunResult {
315
322
  /** The script's return value, JSON-checked at the boundary. */
316
323
  value?: unknown;
317
324
  error?: string;
325
+ /** Authored-source stack, kept separately from the concise failure message. */
326
+ errorStack?: string;
318
327
  /** The append-only log, in emission order. */
319
328
  progress: WorkflowEntry[];
320
329
  /** Agents scheduled, including those that failed. */
@@ -733,14 +742,14 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
733
742
  options.onProgress?.(entries);
734
743
  };
735
744
 
736
- const respond = (callId: number, ok: boolean, value?: unknown, error?: string, fatal?: boolean) => {
745
+ const respond = (callId: number, ok: boolean, value?: unknown, error?: string, fatal?: boolean, modelNote?: string) => {
737
746
  // Cleared before the settled check: a launch answered by a run that is
738
747
  // already finishing is not an unawaited launch either.
739
748
  openLaunches.delete(callId);
740
749
  if (settled) return;
741
750
  // `spent` rides on every response, so the worker's `budget.spent()` is a
742
751
  // mirror of this number rather than a second tally of its own.
743
- worker.postMessage({ type: "response", callId, ok, value, error, fatal, spent: spentOutputTokens });
752
+ worker.postMessage({ type: "response", callId, ok, value, error, fatal, modelNote, spent: spentOutputTokens });
744
753
  };
745
754
 
746
755
  const finish = (result: Omit<WorkflowRunResult, "meta" | "progress" | "agentCount" | "replayedCount">) => {
@@ -908,6 +917,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
908
917
  // dialog to annotate it "from resume journal" rather than letting a
909
918
  // 0ms agent look like one that did the work impossibly fast.
910
919
  cached: true,
920
+ ...(replayed.modelNote !== undefined ? { modelNote: replayed.modelNote } : {}),
921
+ ...(replayed.modelTier ? { modelTier: replayed.modelTier } : {}),
911
922
  resultPreview: preview(replayedText),
912
923
  },
913
924
  ]);
@@ -915,8 +926,8 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
915
926
  // Re-recorded so this run's journal is complete on its own terms: a
916
927
  // resume of a resume must not have to walk back through a chain of
917
928
  // earlier files to find the prefix.
918
- recordJournal?.({ index, key: replayed.key, ok: true, text: replayedText });
919
- respond(callId, true, replayedText);
929
+ recordJournal?.({ index, key: replayed.key, ok: true, text: replayedText, ...(replayed.modelNote !== undefined ? { modelNote: replayed.modelNote } : {}), ...(replayed.modelTier ? { modelTier: replayed.modelTier } : {}) });
930
+ respond(callId, true, replayedText, undefined, undefined, replayed.modelNote);
920
931
  return;
921
932
  }
922
933
 
@@ -992,12 +1003,16 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
992
1003
  modelId?: string;
993
1004
  thinking?: string;
994
1005
  requestedThinking?: string;
1006
+ modelNote?: string;
1007
+ modelTier?: TierDisclosure;
995
1008
  }) => {
996
1009
  if (info.recordId !== undefined) base.recordId = info.recordId;
997
1010
  if (info.modelName !== undefined) base.model = info.modelName;
998
1011
  if (info.modelId !== undefined) base.modelId = info.modelId;
999
1012
  if (info.thinking !== undefined) base.thinking = info.thinking;
1000
1013
  if (info.requestedThinking !== undefined) base.requestedThinking = info.requestedThinking;
1014
+ if (info.modelNote !== undefined) base.modelNote = info.modelNote;
1015
+ if (info.modelTier !== undefined) base.modelTier = info.modelTier;
1001
1016
  // `base.state` is still "start", so emitting after the row reached a
1002
1017
  // terminal state would revert it to running under last-write-wins.
1003
1018
  // Not reachable from this repo's host, which reports during startup
@@ -1091,13 +1106,15 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1091
1106
  durationMs: finishedAt - startedAt,
1092
1107
  ...(result.tokens !== undefined ? { tokens: result.tokens } : {}),
1093
1108
  ...(result.toolCalls !== undefined ? { toolCalls: result.toolCalls } : {}),
1109
+ ...(result.modelNote !== undefined ? { modelNote: result.modelNote } : {}),
1110
+ ...(result.modelTier ? { modelTier: result.modelTier } : {}),
1094
1111
  };
1095
1112
 
1096
1113
  if (result.ok) {
1097
1114
  const text = result.text ?? "";
1098
1115
  emit([{ ...common, state: "done", resultPreview: preview(text) }]);
1099
- recordJournal?.({ index, key, ok: true, text, ...resumeMark });
1100
- respond(callId, true, text);
1116
+ recordJournal?.({ index, key, ok: true, text, ...(result.modelNote !== undefined ? { modelNote: result.modelNote } : {}), ...(result.modelTier ? { modelTier: result.modelTier } : {}), ...resumeMark });
1117
+ respond(callId, true, text, undefined, undefined, result.modelNote);
1101
1118
  return;
1102
1119
  }
1103
1120
  // Recorded as a failure rather than left out: a gap would be read as an
@@ -1118,7 +1135,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1118
1135
  ...(result.skipped ? { skipped: true } : {}),
1119
1136
  },
1120
1137
  ]);
1121
- respond(callId, true, null);
1138
+ respond(callId, true, null, undefined, undefined, result.modelNote);
1122
1139
  return;
1123
1140
  }
1124
1141
  } finally {
@@ -1198,7 +1215,7 @@ export async function runWorkflow(options: RunWorkflowOptions): Promise<Workflow
1198
1215
  break;
1199
1216
  }
1200
1217
  case "error":
1201
- finish({ status: "failed", error: message.message });
1218
+ finish({ status: "failed", error: message.message, errorStack: message.stack });
1202
1219
  break;
1203
1220
  }
1204
1221
  });