@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0

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/src/index.ts CHANGED
@@ -10,46 +10,50 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getMarkdownTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
- import { Container, Key, Markdown, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getMarkdownTheme, getSettingsListTheme, keyText, type ToolDefinition } from "@earendil-works/pi-coding-agent";
16
+ import { Container, Key, Markdown, matchesKey, type SettingItem, SettingsList, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
- import { AgentManager } from "./agent-manager.js";
18
+ import { AgentManager, type SpawnOptions } from "./agent-manager.js";
19
19
  import { getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
20
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
20
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, IMPLICIT_FALLBACK, isDefaultsDisabled, isFallbackResolvable, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
21
+ import { saveCheckpoint } from "./checkpoint.js";
22
+ import { inChildSessionContext } from "./child-context.js";
21
23
  import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
22
24
  import { loadCustomAgents } from "./custom-agents.js";
23
- import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
24
- import { GroupJoinManager } from "./group-join.js";
25
- import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
26
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
27
- import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
25
+ import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
26
+ import { describeModel, type ModelRegistry, resolveModel } from "./model-resolver.js";
27
+ import { isScopeModelsEnabled, modelScopeWarning, setScopeModelsEnabled } from "./model-scope.js";
28
+ import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
29
+ import { createOutputFilePath, getOutputTranscriptDefault, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
30
+ import { formatLifetimeTokens, renderAgentReport, setReportShowCost } from "./result-report.js";
28
31
  import { SubagentScheduler } from "./schedule.js";
29
32
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
30
- import { applyAndEmitLoaded, RESULT_PREVIEW_MAX_CHARS_CEILING, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
33
+ import { applyAndEmitLoaded, DEFAULT_SHOW_COST, FAILURE_PREVIEW_MAX_CHARS_CEILING, fallbackRow, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
31
34
  import { getStatusNote } from "./status-note.js";
32
- import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type ResultPreviewMode, type SubagentType, type WidgetMode } from "./types.js";
35
+ import { safeTruncate, stripControlChars, toSingleLine } from "./text-safety.js";
36
+ import { type AgentConfig, type AgentInvocation, type AgentRecord, type SubagentType, type WidgetMode } from "./types.js";
33
37
  import {
34
38
  type AgentActivity,
35
39
  type AgentDetails,
36
40
  AgentWidget,
37
41
  buildInvocationTags,
38
- describeActivity,
39
42
  fgPreservingNestedStyles,
43
+ formatCost,
40
44
  formatDuration,
41
45
  formatMs,
42
46
  formatTokens,
43
47
  formatTurns,
44
48
  getDisplayName,
45
49
  getPromptModeLabel,
46
- SPINNER,
47
50
  type Theme,
48
51
  type UICtx,
49
52
  } from "./ui/agent-widget.js";
50
53
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
51
54
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
52
- import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
55
+ import { addUsage, formatSessionContext, getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js";
56
+ import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
53
57
 
54
58
  // ---- Shared helpers ----
55
59
 
@@ -58,38 +62,11 @@ function textResult<D>(msg: string, details?: D) {
58
62
  return { content: [{ type: "text" as const, text: msg }], details };
59
63
  }
60
64
 
61
- /** Derive the `.checkpoints.md` path from `outputFile`. Mirrors the
62
- * `record.outputFile` gate (undefined when suppressed). Appends the suffix
63
- * unconditionally — a `replace(/\.output$/, ...)` would silently return the
64
- * transcript path unchanged if `outputFile` ever lacked the suffix. */
65
- function checkpointsFilePath(outputFile: string | undefined): string | undefined {
66
- if (!outputFile) return undefined;
67
- return `${outputFile}.checkpoints.md`;
68
- }
69
-
70
- export function renderRunningAgentStatus(
71
- frame: string,
72
- statsText: string,
73
- activity: string,
74
- theme: Pick<Theme, "fg">,
75
- ): Container {
76
- const container = new Container();
77
- container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
78
- container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
79
- return container;
80
- }
81
-
82
- /** Format an agent's lifetime token total, or "" when zero. */
83
- function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
84
- const t = getLifetimeTotal(o.lifetimeUsage);
85
- return t > 0 ? formatTokens(t) : "";
86
- }
87
-
88
65
  /**
89
66
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
90
- * Used by both foreground and background paths to avoid duplication.
67
+ * Used by the background spawn and resume paths to avoid duplication.
91
68
  */
92
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
69
+ function createActivityTracker(maxTurns?: number) {
93
70
  const state: AgentActivity = {
94
71
  activeTools: new Map(),
95
72
  toolUses: 0,
@@ -110,22 +87,18 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
110
87
  }
111
88
  state.toolUses++;
112
89
  }
113
- onStreamUpdate?.();
114
90
  },
115
91
  onTextDelta: (_delta: string, fullText: string) => {
116
92
  state.responseText = fullText;
117
- onStreamUpdate?.();
118
93
  },
119
94
  onTurnEnd: (turnCount: number) => {
120
95
  state.turnCount = turnCount;
121
- onStreamUpdate?.();
122
96
  },
123
97
  onSessionCreated: (session: any) => {
124
98
  state.session = session;
125
99
  },
126
- onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
100
+ onAssistantUsage: (usage: LifetimeUsage) => {
127
101
  addUsage(state.lifetimeUsage, usage);
128
- onStreamUpdate?.();
129
102
  },
130
103
  };
131
104
 
@@ -134,244 +107,248 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
134
107
 
135
108
  /**
136
109
  * Advertised thinking levels, ordered to mirror pi-ai's EXTENDED_THINKING_LEVELS
137
- * (`off` + every `ThinkingLevel`). Single source for the Agent tool description,
138
- * the generated-agent template, and the `/agents` wizard so these lists can't
139
- * drift behind pi again (#147). Availability of any level still depends on the
140
- * host pi version and the selected model — pi clamps unsupported levels down.
110
+ * (`off` + every `ThinkingLevel`). Single source for the generated-agent template
111
+ * and the `/agents` wizard so these lists can't drift behind pi again (#147).
112
+ * Availability of any level still depends on the host pi version and the
113
+ * selected model — pi clamps unsupported levels down.
141
114
  */
142
115
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
143
116
 
144
- /**
145
- * Salvaged partial output of a failed run, as a labeled suffix for the error
146
- * surfaces (or "" if the run produced nothing). `record.result` is bounded to
147
- * the run's own turns, so this is never a stale earlier answer (#144).
148
- */
149
- function partialOutputSuffix(record: AgentRecord): string {
150
- const partial = record.result?.trim();
151
- return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
117
+ /** Human-readable status word for the completion report header. */
118
+ function getStatusWord(status: string): string {
119
+ switch (status) {
120
+ case "completed": return "completed";
121
+ case "error": return "error";
122
+ case "stopped": return "stopped";
123
+ case "aborted": return "aborted";
124
+ case "steered": return "wrapped up (turn limit)";
125
+ default: return sanitizeHeaderText(String(status)) || "unknown";
126
+ }
152
127
  }
153
128
 
154
- /** Human-readable status label for agent completion. */
155
- function getStatusLabel(status: string, error?: string): string {
129
+ /** Completion-report glyph: success only for terminal success statuses. */
130
+ function getStatusGlyph(status: string): string {
156
131
  switch (status) {
157
- case "error": return `Error: ${error ?? "unknown"}`;
158
- case "aborted": return "Aborted (max turns exceeded)";
159
- case "steered": return "Wrapped up (turn limit)";
160
- case "stopped": return "Stopped";
161
- default: return "Done";
132
+ case "completed":
133
+ case "steered": return "✓";
134
+ case "error":
135
+ case "stopped":
136
+ case "aborted": return "✗";
137
+ default: return "○";
162
138
  }
163
139
  }
164
140
 
165
- /** Escape XML special characters to prevent injection in structured notifications. */
166
- function escapeXml(s: string): string {
167
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
141
+ /**
142
+ * Collapse newlines/CRs/tabs and strip control/invisible characters from text
143
+ * that is composed into the report header or metadata lines.
144
+ */
145
+ function sanitizeHeaderText(s: string): string {
146
+ return toSingleLine(s);
168
147
  }
169
148
 
170
- // Collapsed preview line limit - matches pi's read/write tool precedent
171
- const COLLAPSED_PREVIEW_LINES = 10;
172
- const PLAIN_MODE_EXPANDED_LINE_CAP = 30; // Preserves upstream renderer's expanded-mode line cap. Plain mode is the backward-compatibility path; markdown mode is uncapped per spec.
173
- const PLAIN_MODE_COLLAPSED_CHAR_CAP = 80; // Preserves upstream renderer's first-line preview char cap. Plain mode is the backward-compatibility path.
174
149
  const DEFAULT_FAILURE_PREVIEW_MAX_CHARS = 65536; // 64 KiB at ASCII.
150
+ const HEADER_PREVIEW_MAX_CHARS = 300; // ~one wrapped line; description/error are untrusted input.
175
151
 
176
- /** Truncate to maxChars UTF-16 code units, never splitting a surrogate pair. */
177
- function safeTruncate(s: string, maxChars: number): string {
178
- if (s.length <= maxChars) return s;
179
- const high = s.charCodeAt(maxChars - 1);
180
- return high >= 0xD800 && high <= 0xDBFF ? s.slice(0, maxChars - 1) : s.slice(0, maxChars);
152
+ /** Bound a sanitized header field to the shared preview cap, marking truncation. */
153
+ function headerPreview(s: string): string {
154
+ return s.length > HEADER_PREVIEW_MAX_CHARS ? `${safeTruncate(s, HEADER_PREVIEW_MAX_CHARS)}…` : s;
181
155
  }
182
156
 
183
- /** Build the preview body shared by XML payload + UI details. Caps failure-mode bodies; success/aborted/steered uncapped. */
184
- function buildResultPreview(record: AgentRecord, settings: SubagentsSettings): string {
185
- const body = record.result ?? record.error ?? "";
186
- if (!body) return "No output.";
187
- const isFailure = record.status === "error" || record.status === "stopped";
188
- if (isFailure && typeof settings.failurePreviewMaxChars !== "number") {
189
- throw new Error("buildResultPreview: failurePreviewMaxChars must be a number on failure status");
190
- }
191
- const cap = isFailure ? (settings.failurePreviewMaxChars as number) : Number.POSITIVE_INFINITY;
192
- return body.length > cap
193
- ? safeTruncate(body, cap) + "\n…(truncated, see transcript)"
194
- : body;
157
+ const INSTRUCTION_PREVIEW_SCAN_CHARS = 512; // per-render sanitizer window for the steer preview
158
+ const INSTRUCTION_PREVIEW_COLS = 80; // column budget for the steer preview
159
+
160
+ /** Expanded body copy of a parent→child instruction — full text, no caps. */
161
+ function instructionBody(raw: string, theme: Pick<Theme, "fg">): string {
162
+ const text = stripControlChars(raw).trimEnd();
163
+ if (!text.trim()) return ""; // whitespace-only → no stray blank line
164
+ return text
165
+ .split("\n")
166
+ .map((l) => theme.fg("dim", ` ${l}`))
167
+ .join("\n");
195
168
  }
196
169
 
197
- /** @internal Format a structured task notification matching Claude Code's <task-notification> XML. */
198
- export function formatTaskNotification(record: AgentRecord, settings: SubagentsSettings): string {
199
- const status = getStatusLabel(record.status, record.error);
200
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
201
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
202
- const contextPercent = getSessionContextPercent(record.session);
203
- const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
204
- const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
205
-
206
- const resultPreview = buildResultPreview(record, settings);
207
-
208
- return [
209
- `<task-notification>`,
210
- `<task-id>${record.id}</task-id>`,
211
- record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
212
- record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
213
- `<status>${escapeXml(status)}</status>`,
214
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
215
- `<result>${escapeXml(resultPreview)}</result>`,
216
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
217
- `</task-notification>`,
218
- ].filter(Boolean).join('\n');
170
+ /** Pure hint formatter — no global keybinding dependency, exported for tests. */
171
+ export function formatInstructionHint(key: string, theme: Pick<Theme, "fg">): string {
172
+ return key ? theme.fg("dim", ` (${key} to expand)`) : "";
219
173
  }
220
174
 
221
- /** Build AgentDetails from a base + record-specific fields. */
222
- function buildDetails(
223
- base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
224
- record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; lifetimeUsage: LifetimeUsage },
225
- activity?: AgentActivity,
226
- overrides?: Partial<AgentDetails>,
227
- ): AgentDetails {
228
- return {
229
- ...base,
230
- toolUses: record.toolUses,
231
- tokens: formatLifetimeTokens(record),
232
- turnCount: activity?.turnCount,
233
- maxTurns: activity?.maxTurns,
234
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
235
- status: record.status as AgentDetails["status"],
236
- agentId: record.id,
237
- error: record.error,
238
- ...overrides,
239
- };
175
+ /** ` (ctrl+o to expand)` for the user's current binding — omitted when unbound/headless. */
176
+ function instructionHint(theme: Pick<Theme, "fg">): string {
177
+ return formatInstructionHint(keyText("app.tools.expand"), theme);
240
178
  }
241
179
 
242
- /** @internal Build notification details for the custom message renderer. */
243
- export function buildNotificationDetails(record: AgentRecord, settings: SubagentsSettings, activity?: AgentActivity): NotificationDetails {
244
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
245
-
246
- const resultPreview = buildResultPreview(record, settings);
247
-
248
- return {
249
- id: record.id,
250
- description: record.description,
251
- status: record.status,
252
- toolUses: record.toolUses,
253
- turnCount: activity?.turnCount ?? 0,
254
- maxTurns: activity?.maxTurns,
255
- totalTokens,
256
- durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
257
- outputFile: record.outputFile,
258
- error: record.error,
259
- resultPreview,
260
- };
180
+ /** The settings contract for the failure preview cap: a positive integer within the settings ceiling. */
181
+ function isValidFailurePreviewCap(value: number): boolean {
182
+ return Number.isInteger(value) && value >= 1 && value <= FAILURE_PREVIEW_MAX_CHARS_CEILING;
261
183
  }
262
184
 
263
- /** @internal Render notification header with icon, description, status, and stats. */
264
- export function subagentNotificationRenderHeader(d: NotificationDetails, theme: any): any {
265
- const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
266
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
267
- const statusText = isError ? d.status
268
- : d.status === "steered" ? "completed (steered)"
269
- : "completed";
270
-
271
- // Line 1: icon + agent description + status
272
- let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
273
-
274
- // Line 2: stats
275
- const parts: string[] = [];
276
- if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
277
- if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
278
- if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
279
- if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
280
- if (parts.length) {
281
- line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
185
+ /**
186
+ * @internal Coerce the in-memory failure preview cap for the send path:
187
+ * an out-of-contract value (a settings regression) falls back to the default
188
+ * instead of throwing away the completion. Warns once per invalid episode —
189
+ * a valid value re-arms the latch so a later distinct regression warns again,
190
+ * while a still-broken value does not log on every completion.
191
+ */
192
+ let warnedInvalidFailurePreviewCap = false;
193
+ export function effectiveFailurePreviewCap(value: number): number {
194
+ if (isValidFailurePreviewCap(value)) {
195
+ warnedInvalidFailurePreviewCap = false;
196
+ return value;
282
197
  }
283
-
284
- return new Text(line, 0, 0);
198
+ if (!warnedInvalidFailurePreviewCap) {
199
+ warnedInvalidFailurePreviewCap = true;
200
+ console.warn(
201
+ `[pi-subagents] ignoring out-of-contract failurePreviewMaxChars (${String(value)}); using the default ${DEFAULT_FAILURE_PREVIEW_MAX_CHARS} for this notification`,
202
+ );
203
+ }
204
+ return DEFAULT_FAILURE_PREVIEW_MAX_CHARS;
285
205
  }
286
206
 
287
- /** @internal Render notification body with markdown or plain mode dispatch. */
288
- export function subagentNotificationRenderBody(d: NotificationDetails, expanded: boolean, mode: ResultPreviewMode, theme: any): any {
289
- if (mode === "markdown") {
290
- let body = d.resultPreview;
291
- if (!expanded) {
292
- const lines = body.split("\n");
293
- if (lines.length > COLLAPSED_PREVIEW_LINES) {
294
- const remaining = lines.length - COLLAPSED_PREVIEW_LINES;
295
- body = lines.slice(0, COLLAPSED_PREVIEW_LINES).join("\n") + `\n… (${remaining} more lines, ctrl+O to expand)`;
296
- }
297
- }
298
-
299
- const container = new Container();
300
- if (body.trim()) {
301
- container.addChild(new Markdown(body, 2, 0, getMarkdownTheme()));
302
- }
303
- if (d.outputFile) {
304
- container.addChild(new Text(theme.fg("muted", ` transcript: ${d.outputFile}`), 0, 0));
305
- }
306
- return container;
307
- } else {
308
- let bodyText = "";
309
- if (expanded) {
310
- const lines = d.resultPreview.split("\n").slice(0, PLAIN_MODE_EXPANDED_LINE_CAP);
311
- for (const l of lines) bodyText += (bodyText ? "\n" : "") + theme.fg("dim", ` ${l}`);
312
- } else {
313
- const preview = d.resultPreview.split("\n")[0]?.slice(0, PLAIN_MODE_COLLAPSED_CHAR_CAP) ?? "";
314
- bodyText = theme.fg("dim", ` ⎿ ${preview}`);
315
- }
207
+ /** Grace window between an agent settling and its completion notification being
208
+ * sent, so a parent that pulls the result first can still cancel the nudge. */
209
+ export const NUDGE_HOLD_MS = 200;
316
210
 
317
- if (d.outputFile) {
318
- bodyText += (bodyText ? "\n" : "") + theme.fg("muted", ` transcript: ${d.outputFile}`);
319
- }
211
+ /** Max turns for the AI-generation wizard run. Exported for the wizard tests,
212
+ * which tell the generator run apart from other spawns by this value. */
213
+ export const WIZARD_MAX_TURNS = 5;
320
214
 
321
- return new Text(bodyText, 0, 0);
215
+ /** Validate and return `failurePreviewMaxChars` — user-set input, untrusted until checked. */
216
+ function failurePreviewCap(settings: SubagentsSettings): number {
217
+ const cap = settings.failurePreviewMaxChars;
218
+ if (typeof cap !== "number" || !isValidFailurePreviewCap(cap)) {
219
+ throw new Error("failurePreviewMaxChars must be a positive integer within the settings ceiling on failure status");
322
220
  }
221
+ return cap;
323
222
  }
324
223
 
325
- /** @internal Main subagent notification renderer. */
326
- export function subagentNotificationRenderer(message: { details?: NotificationDetails }, options: { expanded: boolean }, theme: any, resultPreviewMode: ResultPreviewMode, resultPreviewExpanded: boolean): any {
327
- const d = message.details;
328
- if (!d) return undefined;
224
+ /** Build the `Result:` body. Caps failure-mode bodies; success/aborted/steered uncapped. */
225
+ function buildResultPreview(record: AgentRecord, settings: SubagentsSettings): string {
226
+ const isFailure = record.status === "error" || record.status === "stopped";
227
+ // Terminal-safety strip only: markdown structure and XML-ish text pass
228
+ // through untouched, while escape/control bytes (OSC clipboard writes and
229
+ // links, CSI screen clears, C0/C1, invisible format characters) never reach
230
+ // the terminal.
231
+ const body = stripControlChars(String(record.result ?? record.error ?? ""));
232
+ // Validate before the empty-body return: the metadata `Error:` line uses the
233
+ // same cap and must see the same validated value.
234
+ const cap = isFailure ? failurePreviewCap(settings) : Number.POSITIVE_INFINITY;
235
+ if (!body) return "No output.";
236
+ return body.length > cap
237
+ ? safeTruncate(body, cap) + "\n…(truncated, see transcript)"
238
+ : body;
239
+ }
329
240
 
330
- const effectiveExpanded = resultPreviewExpanded ? true : options.expanded;
241
+ /**
242
+ * @internal Format a background completion as a markdown report. The report is
243
+ * both the parent model's context and the text pi renders in its default
244
+ * custom-message box: terminal control sequences are stripped from every field
245
+ * while markdown stays intact, and the body goes last (an unbalanced fence in
246
+ * it cannot swallow the metadata above).
247
+ *
248
+ * `showCost` is the user's setting, passed in rather than read here so the
249
+ * formatter stays a pure function of its arguments. It gates this report only:
250
+ * a figure the orchestrator was not asked to track is one it may start reporting
251
+ * unprompted, so the setting covers LLM context too, not just what a human sees.
252
+ */
253
+ export function formatTaskNotification(record: AgentRecord, settings: SubagentsSettings, showCost = false): string {
254
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
255
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
256
+ const context = formatSessionContext(record.session);
331
257
 
332
- function renderOne(d: NotificationDetails): any {
333
- const header = subagentNotificationRenderHeader(d, theme);
334
- const body = subagentNotificationRenderBody(d, effectiveExpanded, resultPreviewMode, theme);
335
- const container = new Container();
336
- container.addChild(header);
337
- container.addChild(body);
338
- return container;
258
+ const stats: string[] = [];
259
+ const turnCount = record.turnCount ?? 0;
260
+ if (Number.isFinite(turnCount) && turnCount > 0) {
261
+ stats.push(formatTurns(turnCount, Number.isFinite(record.effectiveMaxTurns) ? record.effectiveMaxTurns : undefined));
339
262
  }
340
-
341
- const all = [d, ...(d.others ?? [])];
342
- if (all.length === 1) {
343
- return renderOne(all[0]);
344
- } else {
345
- const container = new Container();
346
- for (let i = 0; i < all.length; i++) {
347
- if (i > 0) container.addChild(new Spacer());
348
- container.addChild(renderOne(all[i]));
349
- }
350
- return container;
263
+ if (Number.isFinite(record.toolUses) && record.toolUses > 0) stats.push(`${record.toolUses} tool use${record.toolUses === 1 ? "" : "s"}`);
264
+ if (Number.isFinite(totalTokens) && totalTokens > 0) stats.push(formatTokens(totalTokens));
265
+ // Omitted when there is no cost to show, so an unpriced model reports tokens
266
+ // and no dollars rather than a `$0.00` that reads as measured-and-free.
267
+ const cost = showCost ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
268
+ if (cost) stats.push(cost);
269
+ if (context !== null) stats.push(`ctx ${context}`);
270
+ if (Number.isFinite(record.compactionCount) && record.compactionCount > 0) stats.push(`⇊${record.compactionCount}`);
271
+ if (Number.isFinite(durationMs) && durationMs > 0) stats.push(formatMs(durationMs));
272
+
273
+ const descriptionText = sanitizeHeaderText(String(record.description ?? ""));
274
+ const description = headerPreview(descriptionText) || "(no description)";
275
+ let header = `**${getStatusGlyph(String(record.status))} Subagent ${getStatusWord(record.status)}: ${description}**`;
276
+ const errorText = record.error && (record.status === "error" || record.status === "stopped")
277
+ ? sanitizeHeaderText(String(record.error))
278
+ : "";
279
+ const errorPreview = headerPreview(errorText);
280
+ if (errorPreview) header += ` — ${errorPreview}`;
281
+ header += getStatusNote(record.status);
282
+ if (stats.length > 0) header += ` · ${stats.join(" · ")}`;
283
+
284
+ const metadata = [`Agent: ${sanitizeHeaderText(String(record.id))}`];
285
+ if (record.outputFile) metadata.push(`Transcript: ${sanitizeHeaderText(String(record.outputFile))}`);
286
+ const body = buildResultPreview(record, settings);
287
+ // The header only previews the error. When a partial result replaces the error
288
+ // in the body, carry the full sanitized error here so its tail is not lost.
289
+ if (errorText.length > HEADER_PREVIEW_MAX_CHARS && record.result != null) {
290
+ const cap = failurePreviewCap(settings); // same validated cap as the body
291
+ const fullError = errorText.length > cap ? `${safeTruncate(errorText, cap)}\n…(truncated, see transcript)` : errorText;
292
+ metadata.push(`Error: ${fullError}`);
351
293
  }
294
+
295
+ return [header, "", metadata.join("\n"), "", "Result:", "", body].join("\n");
352
296
  }
353
297
 
354
298
  export default function (pi: ExtensionAPI) {
355
- // ---- Register custom notification renderer ----
356
- pi.registerMessageRenderer<NotificationDetails>(
357
- "subagent-notification",
358
- (message, { expanded }, theme) => {
359
- return subagentNotificationRenderer(message, { expanded }, theme, resultPreviewMode, resultPreviewExpanded);
360
- }
361
- );
299
+ // Read directly rather than waiting for applyAndEmitLoaded below: this decides
300
+ // the initial load, which happens hundreds of lines before settings are applied.
301
+ let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
362
302
 
363
303
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
364
- const reloadCustomAgents = () => {
365
- const userAgents = loadCustomAgents(process.cwd());
304
+ const reloadCustomAgents = (strict = false) => {
305
+ const userAgents = loadCustomAgents(process.cwd(), strict);
366
306
  registerAgents(userAgents);
367
307
  };
368
308
 
369
- // Initial load
370
- reloadCustomAgents();
309
+ // Initial load — the only strict one. A bad edit mid-session must not kill the
310
+ // session on the next unrelated spawn, so every later reload keeps warning.
311
+ reloadCustomAgents(strictAgentFiles);
371
312
 
372
313
  // ---- Agent activity tracking + widget ----
373
314
  const agentActivity = new Map<string, AgentActivity>();
374
315
 
316
+ // ---- Usage reporting (`reportUsage`/`showCost` default on, `showModel` off; see SubagentsSettings) ----
317
+ /** Attach subagent spend to tool results, so the parent session counts it. */
318
+ let reportUsage = true;
319
+ function isReportUsageEnabled(): boolean { return reportUsage; }
320
+ function setReportUsage(b: boolean): void {
321
+ reportUsage = b;
322
+ // Whatever accumulated while it was on is stale the moment it goes off:
323
+ // draining it later would bill the parent for a window the user opted out
324
+ // of, in one lump, on some unrelated later tool call.
325
+ if (!b) pendingUsage.drain();
326
+ }
327
+ /** Show `~$X` next to token counts in the subagent surfaces. */
328
+ let showCost = DEFAULT_SHOW_COST;
329
+ function isShowCostEnabled(): boolean { return showCost; }
330
+ function setShowCost(b: boolean): void {
331
+ showCost = b;
332
+ // The report renderer keeps its own copy of this flag because both report
333
+ // callers must agree (see `setReportShowCost`). That copy is module state
334
+ // shared with every activation in this process, so a CHILD session's
335
+ // settings load must not clobber the root's decision — the nested
336
+ // `get_subagent_result` renders for the same user, in the same session.
337
+ if (!inChildSessionContext()) setReportShowCost(b);
338
+ widget.update();
339
+ fleet.update();
340
+ }
341
+ /** Name the model and thinking level on the widget's running rows. */
342
+ let showModel = false;
343
+ function isShowModelEnabled(): boolean { return showModel; }
344
+ function setShowModel(b: boolean): void { showModel = b; widget.update(); }
345
+ /**
346
+ * Subagent spend the parent session has not been told about yet. Fed from the
347
+ * manager's per-message hook (the one place each message is seen exactly once)
348
+ * and drained onto the next tool result this extension returns.
349
+ */
350
+ const pendingUsage = new PendingUsagePool();
351
+
375
352
  // ---- Cancellable pending notifications ----
376
353
  // Holds notifications briefly so get_subagent_result can cancel them before
377
354
  // they reach pi.sendMessage (fire-and-forget — once sent, a custom message
@@ -389,7 +366,10 @@ export default function (pi: ExtensionAPI) {
389
366
  // firing; the send closures re-check it at fire time.
390
367
  const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
391
368
  const parkedNudges = new Map<string, () => void>();
392
- const NUDGE_HOLD_MS = 200;
369
+ /** Poll interval while the wizard waits for a queued generator to start. The
370
+ * wizard is interactive, so it re-checks the record until it leaves `queued`
371
+ * (or is stopped) instead of blocking on a promise that does not exist yet. */
372
+ const WIZARD_QUEUE_POLL_MS = 100;
393
373
  let mainTurnActive = false;
394
374
 
395
375
  function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
@@ -447,19 +427,20 @@ export default function (pi: ExtensionAPI) {
447
427
  for (const [key, send] of parked) scheduleNudge(key, send);
448
428
  });
449
429
 
450
- // ---- Individual nudge helper (async join mode) ----
430
+ // ---- Completion notification ----
451
431
  function emitIndividualNudge(record: AgentRecord) {
452
432
  if (record.resultConsumed) return; // re-check at send time
453
433
 
454
- const notification = formatTaskNotification(record, { failurePreviewMaxChars });
455
- const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
456
-
457
- pi.sendMessage<NotificationDetails>({
458
- customType: "subagent-notification",
459
- content: notification + footer,
460
- display: true,
461
- details: buildNotificationDetails(record, { failurePreviewMaxChars }, agentActivity.get(record.id)),
462
- }, { deliverAs: "steer", triggerTurn: true });
434
+ try {
435
+ pi.sendMessage({
436
+ customType: "subagent-notification",
437
+ content: formatTaskNotification(record, { failurePreviewMaxChars: effectiveFailurePreviewCap(failurePreviewMaxChars) }, showCost),
438
+ display: true,
439
+ }, { deliverAs: "steer", triggerTurn: true });
440
+ } catch (err) {
441
+ // Nudge release paths swallow throws; surface the drop with the agent id.
442
+ console.warn(`[pi-subagents] failed to send completion notification for ${record.id}: ${err instanceof Error ? err.message : String(err)}`);
443
+ }
463
444
  }
464
445
 
465
446
  function sendIndividualNudge(record: AgentRecord) {
@@ -470,40 +451,6 @@ export default function (pi: ExtensionAPI) {
470
451
  widget.update();
471
452
  }
472
453
 
473
- // ---- Group join manager ----
474
- const groupJoin = new GroupJoinManager(
475
- (records, partial) => {
476
- for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); }
477
-
478
- const groupKey = `group:${records.map(r => r.id).join(",")}`;
479
- scheduleNudge(groupKey, () => {
480
- // Re-check at send time
481
- const unconsumed = records.filter(r => !r.resultConsumed);
482
- if (unconsumed.length === 0) { widget.update(); return; }
483
-
484
- const notifications = unconsumed.map(r => formatTaskNotification(r, { failurePreviewMaxChars })).join('\n\n');
485
- const label = partial
486
- ? `${unconsumed.length} agent(s) finished (partial — others still running)`
487
- : `${unconsumed.length} agent(s) finished`;
488
-
489
- const [first, ...rest] = unconsumed;
490
- const details = buildNotificationDetails(first, { failurePreviewMaxChars }, agentActivity.get(first.id));
491
- if (rest.length > 0) {
492
- details.others = rest.map(r => buildNotificationDetails(r, { failurePreviewMaxChars }, agentActivity.get(r.id)));
493
- }
494
-
495
- pi.sendMessage<NotificationDetails>({
496
- customType: "subagent-notification",
497
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
498
- display: true,
499
- details,
500
- }, { deliverAs: "steer", triggerTurn: true });
501
- });
502
- widget.update();
503
- },
504
- 30_000,
505
- );
506
-
507
454
  /** Helper: build event data for lifecycle events from an AgentRecord. */
508
455
  function buildEventData(record: AgentRecord) {
509
456
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
@@ -516,6 +463,19 @@ export default function (pi: ExtensionAPI) {
516
463
  const tokens = total > 0
517
464
  ? { input: u.input, output: u.output, total }
518
465
  : undefined;
466
+ // The whole run's spend as a pi `Usage` — pi's convention for handing spend
467
+ // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
468
+ // listener already expects them and anything pi adds to `Usage` arrives
469
+ // without a change here. Omitted when nothing was spent, so "spent nothing"
470
+ // and "never ran" stay distinguishable. Ungated by either setting: those
471
+ // govern what a human is shown and what the parent session counts, not what
472
+ // a subscriber asked for.
473
+ //
474
+ // `tokens` above is the other convention, kept as it shipped: a flat view
475
+ // model like pi's own `SessionStats`, carrying the DISPLAY total, which
476
+ // excludes cacheRead (#38). The two answer different questions and neither
477
+ // derives from the other.
478
+ const usage = toReportedUsage(u);
519
479
  return {
520
480
  id: record.id,
521
481
  type: record.type,
@@ -526,11 +486,16 @@ export default function (pi: ExtensionAPI) {
526
486
  toolUses: record.toolUses,
527
487
  durationMs,
528
488
  tokens,
489
+ usage,
529
490
  };
530
491
  }
531
492
 
532
- // Background completion: route through group join or send individual nudge
493
+ // Background completion: send one notification per agent
533
494
  const manager = new AgentManager((record) => {
495
+ // Nested children report only through their owning parent's scoped tools.
496
+ // Keep them out of top-level lifecycle, transcript, notification, and UI
497
+ // channels — nothing in the main session can act on them.
498
+ if (record.parentAgentId) return;
534
499
  // Emit lifecycle event based on terminal status
535
500
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
536
501
  const eventData = buildEventData(record);
@@ -556,21 +521,10 @@ export default function (pi: ExtensionAPI) {
556
521
  return;
557
522
  }
558
523
 
559
- // If this agent is pending batch finalization (debounce window still open),
560
- // don't send an individual nudge — finalizeBatch will pick it up retroactively.
561
- if (currentBatchAgents.some(a => a.id === record.id)) {
562
- widget.update();
563
- return;
564
- }
565
-
566
- const result = groupJoin.onAgentComplete(record);
567
- if (result === 'pass') {
568
- sendIndividualNudge(record);
569
- }
570
- // 'held' → do nothing, group will fire later
571
- // 'delivered' → group callback already fired
524
+ sendIndividualNudge(record);
572
525
  widget.update();
573
526
  }, undefined, (record) => {
527
+ if (record.parentAgentId) return;
574
528
  // Emit started event when agent transitions to running (including from queue)
575
529
  pi.events.emit("subagents:started", {
576
530
  id: record.id,
@@ -578,6 +532,7 @@ export default function (pi: ExtensionAPI) {
578
532
  description: record.description,
579
533
  });
580
534
  }, (record, info) => {
535
+ if (record.parentAgentId) return;
581
536
  // Emit compacted event when agent's session compacts (preserves count on record).
582
537
  pi.events.emit("subagents:compacted", {
583
538
  id: record.id,
@@ -587,6 +542,48 @@ export default function (pi: ExtensionAPI) {
587
542
  tokensBefore: info.tokensBefore,
588
543
  compactionCount: record.compactionCount,
589
544
  });
545
+ }, (record, usage) => {
546
+ // Every assistant message from every agent — nested included, exactly once.
547
+ // Parked here until a tool result can carry it back to the parent session;
548
+ // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
549
+ // pool grows in a session that will never drain it.
550
+ if (reportUsage) pendingUsage.add(usage);
551
+ // The same message, live, for anyone keeping their own ledger — a footer
552
+ // that breaks spend down by agent type and thinking level, say. This is the
553
+ // one place every agent's spend passes through, which is why it is emitted
554
+ // here and not from the lifecycle events: those fire for top-level agents
555
+ // only, once, at the end, so a nested child would never be counted and a
556
+ // running agent would read as free until it stopped. Deliberately ungated by
557
+ // `reportUsage` — that decides whether the parent session's own totals
558
+ // include this spend, not whether it happened.
559
+ //
560
+ // `model` and `thinking` are read off the record per message, not captured
561
+ // at spawn: the record is authoritative only once the session exists and pi
562
+ // has clamped the level to the model (see `onSessionCreated` in
563
+ // agent-manager.ts).
564
+ const reported = toReportedUsage(usage);
565
+ if (!reported) return;
566
+ // A key whose value is unknown is omitted, never `undefined`: `model` and
567
+ // `thinking` until the session has reported them, `parentAgentId` on a
568
+ // top-level agent.
569
+ const invocation = record.invocation;
570
+ pi.events.emit("subagents:usage", {
571
+ id: record.id,
572
+ type: record.type,
573
+ description: record.description,
574
+ ...(invocation?.modelId !== undefined ? { model: invocation.modelId } : {}),
575
+ ...(invocation?.thinking !== undefined ? { thinking: invocation.thinking } : {}),
576
+ // Set only when the caller did not get the level it asked for (pi clamped
577
+ // it to the model), so a ledger can show `low (asked minimal)` instead of
578
+ // silently relabelling.
579
+ ...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
580
+ depth: record.depth ?? 1,
581
+ ...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
582
+ usage: reported,
583
+ });
584
+ }, (record, message) => {
585
+ if (record.parentAgentId) return;
586
+ pi.events.emit("subagents:steered", { id: record.id, message });
590
587
  });
591
588
 
592
589
  // Expose manager via Symbol.for() global registry for cross-package access.
@@ -599,12 +596,40 @@ export default function (pi: ExtensionAPI) {
599
596
  // session's entry. The first activation (the root session) wins; child
600
597
  // activations leave it alone.
601
598
  const MANAGER_KEY = Symbol.for("pi-subagents:manager");
599
+ // Every caller-supplied spawn that reaches the manager from outside the Agent
600
+ // tool goes through here, so `fallbackSubagent` gates cross-extension callers
601
+ // too (#183). The RPC layer already throws for an unresolvable model rather
602
+ // than falling back silently; a bad agent type should not be quieter. Throws
603
+ // become error envelopes at the RPC boundary. Reload first so an agent file
604
+ // added mid-session is spawnable here too, not only through the Agent tool.
605
+ //
606
+ // Cross-package callers may supply arbitrary options. Nested ownership and
607
+ // config-root metadata are internal capabilities issued only by the scoped
608
+ // nested tools — a forged `parentAgentId` would hide a top-level agent from
609
+ // every surface, and `rootSessionId` names a transcript directory, so a
610
+ // forged value is a path-traversal primitive.
611
+ const spawnTopLevel = (piRef: ExtensionAPI, ctxRef: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions) => {
612
+ reloadCustomAgents();
613
+ const dispatch = resolveSpawnType(type);
614
+ if (!dispatch.ok) throw new Error(dispatch.message);
615
+ const safeOptions: SpawnOptions = { ...options };
616
+ delete safeOptions.parentAgentId;
617
+ delete safeOptions.depth;
618
+ delete safeOptions.maxSubagentDepth;
619
+ delete safeOptions.configCwd;
620
+ delete safeOptions.rootSessionId;
621
+ return manager.spawn(piRef, ctxRef, dispatch.type, prompt, safeOptions);
622
+ };
602
623
  const registryEntry = {
603
624
  waitForAll: () => manager.waitForAll(),
604
625
  hasRunning: () => manager.hasRunning(),
605
- spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
606
- manager.spawn(piRef, ctx, type, prompt, options),
607
- getRecord: (id: string) => manager.getRecord(id),
626
+ spawn: spawnTopLevel,
627
+ // `listAgents` stays unfiltered on purpose: the subagent-facing `checkpoint`
628
+ // tool resolves its own nested record through it by session id.
629
+ getRecord: (id: string) => {
630
+ const record = manager.getRecord(id);
631
+ return record?.parentAgentId ? undefined : record;
632
+ },
608
633
  listAgents: () => manager.listAgents(),
609
634
  };
610
635
  const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined;
@@ -612,6 +637,52 @@ export default function (pi: ExtensionAPI) {
612
637
  (globalThis as any)[MANAGER_KEY] = registryEntry;
613
638
  }
614
639
 
640
+ /**
641
+ * Wrap a tool so its results carry back whatever subagent spend the parent
642
+ * session has not been told about yet (see `PendingUsagePool`).
643
+ *
644
+ * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
645
+ * folds it into `getSessionStats()`, which is what the footer, the statusline
646
+ * and `/cost` read — so this is the whole of "report usage to the parent".
647
+ *
648
+ * No tool-call-id guard: `ToolDefinition.execute` takes a `string` here, and
649
+ * every caller of a registered tool in this fork is pi itself, which always
650
+ * has one. There is no in-process clone that invokes a tool from a discarded
651
+ * fork of the conversation, so a result this extension produces always becomes
652
+ * a real message and its usage is always counted.
653
+ */
654
+ function withUsageReporting(tool: ToolDefinition): ToolDefinition {
655
+ const execute = tool.execute;
656
+ return {
657
+ ...tool,
658
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
659
+ const result = await execute(toolCallId, params, signal, onUpdate, ctx);
660
+ if (!reportUsage) return result;
661
+ const usage = pendingUsage.drain();
662
+ return usage ? { ...result, usage } : result;
663
+ },
664
+ };
665
+ }
666
+
667
+ /**
668
+ * Register a parent-facing tool — never in a subagent session.
669
+ *
670
+ * A subagent session re-activates this extension in the same process, and its
671
+ * local manager is empty: an `Agent` registered there could only spawn into a
672
+ * manager nobody reads. Worse, an opted-in child receives the nested `Agent`
673
+ * as an SDK custom tool, so registering ours too would put two definitions of
674
+ * the same name in one registry and leave the survivor to pi's precedence.
675
+ * Skipping the child activation makes the child's single `Agent` a
676
+ * construction-time guarantee. Every other child has these names stripped from
677
+ * its allowlist (EXCLUDED_TOOL_NAMES), so nothing changes for them.
678
+ *
679
+ * Every parent-facing tool is registered through here, so this is also the one
680
+ * place that attaches pending subagent spend to a tool result.
681
+ */
682
+ const registerParentTool = (tool: ToolDefinition): void => {
683
+ if (!inChildSessionContext()) pi.registerTool(withUsageReporting(tool));
684
+ };
685
+
615
686
  // --- Cross-extension RPC via pi.events ---
616
687
  let currentCtx: ExtensionContext | undefined;
617
688
  // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
@@ -657,7 +728,34 @@ export default function (pi: ExtensionAPI) {
657
728
  events: pi.events,
658
729
  pi,
659
730
  getCtx: () => currentCtx,
660
- manager,
731
+ // Same capability boundary as the manager registry: a cross-extension
732
+ // caller cannot forge nested ownership, and cannot stop, steer or
733
+ // consume a nested child it does not own.
734
+ manager: {
735
+ spawn: spawnTopLevel,
736
+ getRecord: (id) => manager.getRecord(id),
737
+ abort: (id) => {
738
+ const record = manager.getRecord(id);
739
+ return !record?.parentAgentId && manager.abort(id);
740
+ },
741
+ // What `get_subagent_result` does when it returns a settled result:
742
+ // mark it read and cancel the parked completion notification, so the
743
+ // caller that already showed the model this result does not pay for a
744
+ // second copy of it.
745
+ consumeResult: (id) => {
746
+ // Ownership is the RPC layer's job (assertOwned); settledness is not —
747
+ // it never looks at status. Consuming a running agent would set
748
+ // resultConsumed and suppress the completion notification it is still
749
+ // owed, so this stays the same gate the local get_subagent_result has.
750
+ const record = manager.getRecord(id);
751
+ if (!record) return false;
752
+ if (record.status === "running" || record.status === "queued") return false;
753
+ record.resultConsumed = true;
754
+ cancelNudge(record.id);
755
+ return true;
756
+ },
757
+ steer: (id, message) => manager.steer(id, message),
758
+ },
661
759
  });
662
760
  // Broadcast readiness so extensions loaded alongside us can discover us.
663
761
  // Emitting after all factories have run (rather than at factory time)
@@ -674,10 +772,12 @@ export default function (pi: ExtensionAPI) {
674
772
 
675
773
  // On shutdown, abort all agents immediately and clean up.
676
774
  // If the session is going down, there's nothing left to consume agent results.
677
- pi.on("session_shutdown", async () => {
775
+ pi.on("session_shutdown", async (event) => {
678
776
  rpcHandle?.unsubSpawn();
679
777
  rpcHandle?.unsubStop();
680
778
  rpcHandle?.unsubPing();
779
+ rpcHandle?.unsubConsume();
780
+ rpcHandle?.unsubSteer();
681
781
  rpcHandle = undefined;
682
782
  currentCtx = undefined;
683
783
  // Only release the global slot if this activation claimed it — a child
@@ -693,35 +793,37 @@ export default function (pi: ExtensionAPI) {
693
793
  mainTurnActive = false;
694
794
  fleet.dispose();
695
795
  manager.dispose();
796
+ // Every agent is stopped and every child session has shut down, so no more
797
+ // `subagents:usage` can arrive from THIS session. A ledger flushing in its
798
+ // own `session_shutdown` may have run before this handler aborted the agents
799
+ // (handlers run in load order) and missed their last messages; this is the
800
+ // point after which a synchronous final flush is complete.
801
+ //
802
+ // Not only an exit: pi also emits `session_shutdown` for `resume`, `reload`,
803
+ // `/new` and `/fork`, where the process lives on and the next session starts
804
+ // emitting `subagents:usage` again. The reason is carried so a listener can
805
+ // tell a final flush from a mid-process one.
806
+ pi.events.emit("subagents:disposed", { reason: event.reason });
696
807
  });
697
808
 
698
809
  // Live widget: show running agents above editor.
699
- // widgetMode (default "background") selects what the widget shows: "all" =
700
- // every agent; "background" = hide foreground (they already render inline as
701
- // the Agent tool result, so showing them here too is a duplicate, #118), keep
702
- // everything else; "off" = hide the widget entirely. Read live at render time.
703
- let widgetMode: WidgetMode = "background";
810
+ // widgetMode (default "all") selects what the widget shows: "all" = every
811
+ // agent; "off" = hide the widget entirely. Read live at render time.
812
+ let widgetMode: WidgetMode = "all";
704
813
  function getWidgetMode(): WidgetMode { return widgetMode; }
705
- const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
814
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled);
706
815
  function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
707
816
 
708
817
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
709
- const fleet = new FleetList(manager, agentActivity);
818
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled);
710
819
  let fleetViewEnabled = true;
711
820
  function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
712
821
  function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
713
822
 
714
- // Project/global default for writing the subagent .output transcript. A custom
715
- // agent's `output_transcript` frontmatter overrides this per spawn; when the
716
- // frontmatter is silent, this default applies. Read live at spawn time.
717
- let outputTranscriptDefault = true;
718
- function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
719
- function setOutputTranscript(b: boolean): void { outputTranscriptDefault = b; }
720
-
721
- // ---- Join mode configuration ----
722
- let defaultJoinMode: JoinMode = 'smart';
723
- function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
724
- function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
823
+ // Project/global default for writing the subagent .output transcript lives in
824
+ // output-file.ts (both spawn paths read it). A custom agent's
825
+ // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
826
+ // is silent, that default applies. Read live at spawn time.
725
827
 
726
828
  // Master switch for the schedule subagent feature. Defaults to enabled.
727
829
  // Read once at extension init (before tool registration) so the Agent tool's
@@ -734,22 +836,13 @@ export default function (pi: ExtensionAPI) {
734
836
  function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
735
837
 
736
838
  // ---- Scope models configuration ----
737
- // When enabled, subagent model choices are validated against `enabledModels`
738
- // from pi's settings — both global `<agentDir>/settings.json` and
739
- // project-local `<cwd>/.pi/settings.json` (project overrides global).
740
- // Off by default; opt-in via `/agents → Settings`. See docstring on
741
- // SubagentsSettings.scopeModels for the hard-error vs warn-and-proceed
742
- // policy and its rationale.
743
- let scopeModelsEnabled = false;
744
- function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
745
- function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
746
-
747
- // ---- Result preview configuration ----
748
- let resultPreviewMode: ResultPreviewMode = "markdown";
749
- let resultPreviewExpanded = true;
839
+ // The enable flag and the validation policy live in model-scope.ts, so the
840
+ // nested delegation tools enforce the same allowlist the top-level Agent tool
841
+ // does. Off by default; opt-in via `/agents → Settings`. See the
842
+ // SubagentsSettings.scopeModels docstring for the warn-and-proceed policy.
843
+
844
+ // ---- Failure preview configuration ----
750
845
  let failurePreviewMaxChars = DEFAULT_FAILURE_PREVIEW_MAX_CHARS;
751
- function setResultPreviewMode(mode: ResultPreviewMode): void { resultPreviewMode = mode; }
752
- function setResultPreviewExpanded(expanded: boolean): void { resultPreviewExpanded = expanded; }
753
846
  function setFailurePreviewMaxChars(chars: number): void { failurePreviewMaxChars = chars; }
754
847
 
755
848
  // ---- Disable default agents configuration ----
@@ -772,49 +865,6 @@ export default function (pi: ExtensionAPI) {
772
865
  function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
773
866
  function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
774
867
 
775
- // ---- Batch tracking for smart join mode ----
776
- // Collects background agent IDs spawned in the current turn for smart grouping.
777
- // Uses a debounced timer: each new agent resets the 100ms window so that all
778
- // parallel tool calls (which may be dispatched across multiple microtasks by the
779
- // framework) are captured in the same batch.
780
- let currentBatchAgents: { id: string; joinMode: JoinMode }[] = [];
781
- let batchFinalizeTimer: ReturnType<typeof setTimeout> | undefined;
782
- let batchCounter = 0;
783
-
784
- /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
785
- function finalizeBatch() {
786
- batchFinalizeTimer = undefined;
787
- const batchAgents = [...currentBatchAgents];
788
- currentBatchAgents = [];
789
-
790
- const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
791
- if (smartAgents.length >= 2) {
792
- const groupId = `batch-${++batchCounter}`;
793
- const ids = smartAgents.map(a => a.id);
794
- groupJoin.registerGroup(groupId, ids);
795
- // Retroactively process agents that already completed during the debounce window.
796
- // Their onComplete fired but was deferred (agent was in currentBatchAgents),
797
- // so we feed them into the group now.
798
- for (const id of ids) {
799
- const record = manager.getRecord(id);
800
- if (!record) continue;
801
- record.groupId = groupId;
802
- if (record.completedAt != null && !record.resultConsumed) {
803
- groupJoin.onAgentComplete(record);
804
- }
805
- }
806
- } else {
807
- // No group formed — send individual nudges for any agents that completed
808
- // during the debounce window and had their notification deferred.
809
- for (const { id } of batchAgents) {
810
- const record = manager.getRecord(id);
811
- if (record?.completedAt != null && !record.resultConsumed) {
812
- sendIndividualNudge(record);
813
- }
814
- }
815
- }
816
- }
817
-
818
868
  // Grab UI context from first tool execution + clear lingering widget on new turn
819
869
  pi.on("tool_execution_start", async (_event, ctx) => {
820
870
  widget.setUICtx(ctx.ui as UICtx);
@@ -873,17 +923,21 @@ export default function (pi: ExtensionAPI) {
873
923
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
874
924
  setDefaultMaxTurns,
875
925
  setGraceTurns,
876
- setDefaultJoinMode,
877
926
  setSchedulingEnabled,
878
927
  setScopeModels: setScopeModelsEnabled,
879
- setResultPreviewMode,
880
- setResultPreviewExpanded,
928
+ setStrictAgentFiles: (b) => { strictAgentFiles = b; },
881
929
  setFailurePreviewMaxChars,
882
930
  setDisableDefaultAgents: setDisableDefaultAgents,
883
931
  setToolDescriptionMode: setToolDescriptionMode,
884
932
  setFleetView: setFleetViewEnabled,
885
933
  setWidgetMode: setWidgetMode,
886
- setOutputTranscript: setOutputTranscript,
934
+ setOutputTranscript: setOutputTranscriptDefault,
935
+ setMaxSubagentDepth: setMaxSubagentDepth,
936
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
937
+ setFallbackSubagent: setFallbackSubagent,
938
+ setReportUsage,
939
+ setShowCost,
940
+ setShowModel,
887
941
  },
888
942
  (event, payload) => pi.events.emit(event, payload),
889
943
  );
@@ -901,7 +955,8 @@ export default function (pi: ExtensionAPI) {
901
955
  description:
902
956
  'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
903
957
  'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
904
- 'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.',
958
+ 'Interval delays are capped by the JS timer ceiling (~24.8 days); one-shot dates further out stay scheduled and arm at a later session start once closer. ' +
959
+ 'Incompatible with inherit_context and resume. Returns job ID.',
905
960
  }),
906
961
  ),
907
962
  };
@@ -912,6 +967,21 @@ export default function (pi: ExtensionAPI) {
912
967
  ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
913
968
  : "";
914
969
 
970
+ // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
971
+ // the field from the schema when the project set `worktreeIsolation: false`,
972
+ // so the prose has to go with it. Left in, it would teach the model to pass a
973
+ // parameter that isn't declared — accepted (TypeBox sets no
974
+ // `additionalProperties: false`) and then silently dropped by the resolver.
975
+ // With no per-result note by design, the model would have every reason to go
976
+ // on reporting a `pi-agent-*` branch that was never created.
977
+ const isolationGuideline = isWorktreeIsolationEnabled()
978
+ ? `\n- Use isolation: "worktree" to give the agent its own git worktree (safe parallel file modifications); leave it unset, or pass "off", for none. The worktree is removed when the agent finishes; if it made changes, they are committed to a branch and the branch is named in the result.`
979
+ : "";
980
+
981
+ const isolationCompactGuideline = isWorktreeIsolationEnabled()
982
+ ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
983
+ : "";
984
+
915
985
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
916
986
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
917
987
  // small/local models. Per-option details live in the param descriptions.
@@ -922,10 +992,9 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
922
992
 
923
993
  Notes:
924
994
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
925
- - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
926
- - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
927
- - resume continues a previous agent by ID; steer_subagent messages a running one.
928
- - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
995
+ - You will be notified on subagent completion/failure — never poll or sleep.
996
+ - Verify an agent's claimed code changes before reporting work done.
997
+ - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
929
998
 
930
999
  const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
931
1000
 
@@ -943,18 +1012,12 @@ If the target is already known, use a direct tool — \`read\` for a known path,
943
1012
  ## Usage notes
944
1013
 
945
1014
  - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
946
- - When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
947
- - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
1015
+ - When an agent finishes or fails, you will be notified on subagent completion/failure — do NOT poll or sleep waiting for it.
948
1016
  - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
949
- - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
950
- - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
951
1017
  - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
952
- - Use steer_subagent to send mid-run messages to a running background agent.
1018
+ - Use steer_subagent to send mid-run messages to a running subagent.
953
1019
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
954
- - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
955
- - Use thinking to control extended thinking level.
956
- - Use inherit_context if the agent needs the parent conversation history.
957
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
1020
+ - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
958
1021
 
959
1022
  ## Writing the prompt
960
1023
 
@@ -978,6 +1041,7 @@ Terse command-style prompts produce shallow, generic work.
978
1041
  typeList: buildTypeListText,
979
1042
  compactTypeList: buildCompactTypeListText,
980
1043
  agentDir: getAgentDir,
1044
+ isolationGuideline: () => isolationGuideline,
981
1045
  scheduleGuideline: () => scheduleGuideline,
982
1046
  };
983
1047
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
@@ -1016,7 +1080,7 @@ Terse command-style prompts produce shallow, generic work.
1016
1080
  return fullAgentToolDescription;
1017
1081
  })();
1018
1082
 
1019
- pi.registerTool(defineTool({
1083
+ registerParentTool(defineTool({
1020
1084
  name: SUBAGENT_TOOL_NAMES.AGENT,
1021
1085
  label: "Agent",
1022
1086
  description: agentToolDescription,
@@ -1024,7 +1088,7 @@ Terse command-style prompts produce shallow, generic work.
1024
1088
  promptGuidelines: [
1025
1089
  "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
1026
1090
  "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
1027
- "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
1091
+ "You will be notified on subagent completion/failure — do not poll or sleep waiting for it. Continue with other work instead.",
1028
1092
  "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
1029
1093
  ],
1030
1094
  parameters: Type.Object({
@@ -1037,22 +1101,12 @@ Terse command-style prompts produce shallow, generic work.
1037
1101
  subagent_type: Type.String({
1038
1102
  description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
1039
1103
  }),
1040
- thinking: Type.Optional(
1041
- Type.String({
1042
- description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1043
- }),
1044
- ),
1045
1104
  max_turns: Type.Optional(
1046
1105
  Type.Number({
1047
1106
  description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
1048
1107
  minimum: 1,
1049
1108
  }),
1050
1109
  ),
1051
- run_in_background: Type.Optional(
1052
- Type.Boolean({
1053
- description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
1054
- }),
1055
- ),
1056
1110
  resume: Type.Optional(
1057
1111
  Type.String({
1058
1112
  description: "Optional agent ID to resume from. Continues from previous context.",
@@ -1068,34 +1122,40 @@ Terse command-style prompts produce shallow, generic work.
1068
1122
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
1069
1123
  }),
1070
1124
  ),
1071
- isolation: Type.Optional(
1072
- Type.Literal("worktree", {
1073
- description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
1074
- }),
1075
- ),
1125
+ ...isolationParam(isWorktreeIsolationEnabled()),
1076
1126
  ...scheduleParam,
1077
1127
  }),
1078
1128
 
1079
1129
  // ---- Custom rendering: Claude Code style ----
1080
1130
 
1081
- renderCall(args, theme) {
1082
- const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
1083
- const desc = args.description ?? "";
1084
- return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1131
+ renderCall(args, theme, context) {
1132
+ const displayName = typeof args.subagent_type === "string" ? toSingleLine(getDisplayName(args.subagent_type)) : "Agent";
1133
+ const desc = typeof args.description === "string" ? headerPreview(toSingleLine(args.description)) : "";
1134
+ const marker = context.expanded ? "▾" : "▸";
1135
+ let head = marker + " " + theme.fg("toolTitle", theme.bold(displayName));
1136
+ if (desc) head += " " + theme.fg("muted", desc);
1137
+
1138
+ if (context.expanded) {
1139
+ const raw = typeof (args as { prompt?: unknown })?.prompt === "string" ? (args as { prompt: string }).prompt : "";
1140
+ const body = instructionBody(raw, theme);
1141
+ if (body) return new Text(head + "\n" + body, 0, 0);
1142
+ }
1143
+ return new Text(head, 0, 0);
1085
1144
  },
1086
1145
 
1087
- renderResult(result, { expanded, isPartial }, theme) {
1146
+ renderResult(result, { expanded }, theme, context) {
1088
1147
  const details = result.details as AgentDetails | undefined;
1148
+ // Display copies only: `execute` hands the model the raw child text.
1089
1149
  if (!details) {
1090
1150
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1091
- return new Text(text, 0, 0);
1151
+ return new Text(typeof text === "string" ? stripControlChars(text) : "", 0, 0);
1092
1152
  }
1093
1153
 
1094
1154
  // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
1095
1155
  const stats = (d: AgentDetails) => {
1096
1156
  const parts: string[] = [];
1097
- if (d.modelName) parts.push(d.modelName);
1098
- if (d.tags) parts.push(...d.tags);
1157
+ if (d.modelName) parts.push(toSingleLine(d.modelName));
1158
+ if (d.tags) parts.push(...d.tags.map(t => toSingleLine(t)));
1099
1159
  if (d.turnCount != null && d.turnCount > 0) {
1100
1160
  parts.push(formatTurns(d.turnCount, d.maxTurns));
1101
1161
  }
@@ -1104,59 +1164,22 @@ Terse command-style prompts produce shallow, generic work.
1104
1164
  return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
1105
1165
  };
1106
1166
 
1107
- // ---- While running (streaming) ----
1108
- if (isPartial || details.status === "running") {
1109
- const frame = SPINNER[details.spinnerFrame ?? 0];
1110
- const s = stats(details);
1111
- return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
1112
- }
1113
-
1114
1167
  // ---- Background agent launched ----
1115
1168
  if (details.status === "background") {
1116
- return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
1117
- }
1118
-
1119
- // ---- Completed / Steered ----
1120
- if (details.status === "completed" || details.status === "steered") {
1121
- const duration = formatMs(details.durationMs);
1122
- const isSteered = details.status === "steered";
1123
- const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓");
1124
- const s = stats(details);
1125
- let line = icon + (s ? " " + s : "");
1126
- line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
1127
-
1128
- if (expanded) {
1129
- const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
1130
- if (resultText) {
1131
- const lines = resultText.split("\n").slice(0, 50);
1132
- for (const l of lines) {
1133
- line += "\n" + theme.fg("dim", ` ${l}`);
1134
- }
1135
- if (resultText.split("\n").length > 50) {
1136
- line += "\n" + theme.fg("muted", " ... (read the .output transcript file for full detail)");
1137
- }
1138
- }
1139
- } else {
1140
- const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
1141
- line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
1142
- }
1143
- return new Text(line, 0, 0);
1144
- }
1145
-
1146
- // ---- Stopped (user-initiated abort) ----
1147
- if (details.status === "stopped") {
1148
- const s = stats(details);
1149
- let line = theme.fg("dim", "■") + (s ? " " + s : "");
1150
- line += "\n" + theme.fg("dim", " ⎿ Stopped");
1151
- return new Text(line, 0, 0);
1169
+ const hint = !expanded && typeof (context.args as { prompt?: unknown })?.prompt === "string"
1170
+ ? instructionHint(theme)
1171
+ : "";
1172
+ return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})${hint}`), 0, 0);
1152
1173
  }
1153
1174
 
1154
1175
  // ---- Error / Aborted (hard max_turns) ----
1176
+ // Legacy fallthrough: a replayed pre-change row carrying a retired inline
1177
+ // status (running/completed/steered/stopped) lands here.
1155
1178
  const s = stats(details);
1156
1179
  let line = theme.fg("error", "✗") + (s ? " " + s : "");
1157
1180
 
1158
1181
  if (details.status === "error") {
1159
- line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
1182
+ line += "\n" + theme.fg("error", ` ⎿ Error: ${toSingleLine(String(details.error ?? "")) || "unknown"}`);
1160
1183
  } else {
1161
1184
  line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
1162
1185
  }
@@ -1166,7 +1189,7 @@ Terse command-style prompts produce shallow, generic work.
1166
1189
 
1167
1190
  // ---- Execute ----
1168
1191
 
1169
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1192
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
1170
1193
  // Ensure we have UI context for widget rendering
1171
1194
  widget.setUICtx(ctx.ui as UICtx);
1172
1195
 
@@ -1174,50 +1197,40 @@ Terse command-style prompts produce shallow, generic work.
1174
1197
  reloadCustomAgents();
1175
1198
 
1176
1199
  const rawType = params.subagent_type as SubagentType;
1177
- const resolved = resolveType(rawType);
1178
- const subagentType = resolved ?? "general-purpose";
1179
- const fellBack = resolved === undefined;
1180
-
1181
- const displayName = getDisplayName(subagentType);
1200
+ // Single decision point for dispatch (#183): unknown, disabled and
1201
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
1202
+ // background or scheduled call can't start running the wrong agent while
1203
+ // the caller is still unaware. `fallbackSubagent` decides whether an
1204
+ // unresolvable type falls back or fails closed.
1205
+ const dispatch = resolveSpawnType(rawType);
1206
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
1207
+ // but the parameter is required by the schema — so gating it here would
1208
+ // make a live agent unresumable the moment its type is deleted, disabled,
1209
+ // or gains a case-clashing sibling. Only a real spawn is gated.
1210
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
1211
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
1212
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
1213
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1214
+ // would persist an empty type into a scheduled job.
1215
+ const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
1216
+ // Computed at resolution rather than after the run, so the schedule and
1217
+ // background branches carry it too. Resume deliberately doesn't: it
1218
+ // replays the stored session and ignores `subagent_type` entirely, so a
1219
+ // note about type substitution would be describing something that didn't
1220
+ // happen.
1221
+ const fallbackNote = dispatch.ok && dispatch.fellBackFrom
1222
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1223
+ : "";
1182
1224
 
1183
1225
  // Get agent config (if any)
1184
1226
  const customConfig = getAgentConfig(subagentType);
1185
1227
 
1186
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
1187
-
1188
- // Resolve model from agent frontmatter only (no LLM-authored override).
1189
- let model = ctx.model;
1190
- if (resolvedConfig.modelInput) {
1191
- const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
1192
- if (typeof resolved !== "string") {
1193
- model = resolved;
1194
- }
1195
- // config-specified but unresolvable: silent fallback to parent (existing behavior)
1196
- }
1197
-
1198
- // Scope validation: the effective resolved model is checked against the
1199
- // user's enabledModels list (read in `enabled-models.ts`).
1200
- //
1201
- // Design: scopeModels guards against frontmatter-pinned or parent-inherited
1202
- // models drifting out of the user's allowlist. Both warn and proceed —
1203
- // frontmatter is authoritative (the agent's author/installer chose it),
1204
- // and the parent's model was chosen by the user when starting the session.
1205
- // See SubagentsSettings.scopeModels docstring for the full policy.
1206
- if (isScopeModelsEnabled() && model) {
1207
- const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
1208
- if (allowed && !isModelInScope(model, allowed)) {
1209
- const agentLabel = customConfig?.displayName ?? subagentType;
1210
- const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
1211
- ctx.ui.notify(
1212
- `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
1213
- "warning",
1214
- );
1215
- }
1216
- }
1228
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
1229
+ worktreeAllowed: isWorktreeIsolationEnabled(),
1230
+ });
1217
1231
 
1218
1232
  const thinking = resolvedConfig.thinking;
1219
1233
  const inheritContext = resolvedConfig.inheritContext;
1220
- const runInBackground = resolvedConfig.runInBackground;
1221
1234
  const isolated = resolvedConfig.isolated;
1222
1235
  const isolation = resolvedConfig.isolation;
1223
1236
  // Whether this spawn writes its .output transcript. Per-agent
@@ -1232,35 +1245,54 @@ Terse command-style prompts produce shallow, generic work.
1232
1245
  writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
1233
1246
  };
1234
1247
 
1235
- const parentModelId = ctx.model?.id;
1236
- const effectiveModelId = model?.id;
1237
- const modelName = effectiveModelId && effectiveModelId !== parentModelId
1238
- ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
1239
- : undefined;
1240
1248
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
1241
- const agentInvocation: AgentInvocation = {
1242
- modelName,
1243
- thinking,
1244
- // Explicit value only — the default fallback would just add noise.
1245
- // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1246
- maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
1247
- isolated,
1248
- inheritContext,
1249
- runInBackground,
1250
- isolation,
1251
- };
1252
- // Tool-result render shows the mode label too; viewer's header already does.
1253
- const modeLabel = getPromptModeLabel(subagentType);
1254
- const { tags: invocationTags } = buildInvocationTags(agentInvocation);
1255
- const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
1256
- const detailBase = {
1257
- displayName,
1258
- description: params.description,
1259
- subagentType,
1260
- modelName,
1261
- tags: agentTags.length > 0 ? agentTags : undefined,
1249
+
1250
+ // Resolve the call's model from agent frontmatter only (no LLM-authored
1251
+ // override). Shared by fresh spawns and scheduled registrations: a
1252
+ // schedule registers a job here and the pinned model resolves at fire
1253
+ // time, but the scope warning still belongs to registration. A resume
1254
+ // continues the record's session and model, so it does not warn, and its
1255
+ // row never uses the call-resolved model.
1256
+ let model = ctx.model;
1257
+ if (resolvedConfig.modelInput) {
1258
+ const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
1259
+ if (typeof resolved !== "string") {
1260
+ model = resolved;
1261
+ }
1262
+ // config-specified but unresolvable: silent fallback to parent (existing behavior)
1263
+ }
1264
+
1265
+ // Scope validation: the effective resolved model is checked against the
1266
+ // user's enabledModels list. Policy lives in model-scope.ts so the nested
1267
+ // delegation tools apply the same allowlist to a nested spawn.
1268
+ //
1269
+ // Call this only where the model is accepted for use — after a schedule
1270
+ // job is registered, or once a spawn is admitted — so a refused call
1271
+ // never warns for work that never happens. The resume path never calls it.
1272
+ // A throwing notify is swallowed: the warning is advisory and cannot
1273
+ // fail the accepted call.
1274
+ const warnOnOutOfScopeModel = (): void => {
1275
+ const warning = modelScopeWarning({
1276
+ model,
1277
+ cwd: ctx.cwd,
1278
+ modelRegistry: ctx.modelRegistry,
1279
+ agentLabel: toSingleLine(customConfig?.displayName ?? subagentType),
1280
+ modelInput: resolvedConfig.modelInput,
1281
+ });
1282
+ if (!warning) return;
1283
+ try {
1284
+ ctx.ui.notify(warning, "warning");
1285
+ } catch { /* ignore notification errors */ }
1262
1286
  };
1263
1287
 
1288
+ // Display name for the resolved model, captured in the fresh-spawn
1289
+ // invocation snapshot. Unconditional, not "only when it differs from the
1290
+ // parent": a thinking level reads as a property of a model, and an agent
1291
+ // that inherited the parent's model used to show the level with nothing to
1292
+ // attach it to. This is the pre-session snapshot — agent-manager overwrites
1293
+ // it with the effective values the moment a session reports them.
1294
+ const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
1295
+
1264
1296
  // ---- Schedule: register a job, don't spawn now ----
1265
1297
  if (params.schedule) {
1266
1298
  if (!isSchedulingEnabled()) {
@@ -1272,9 +1304,6 @@ Terse command-style prompts produce shallow, generic work.
1272
1304
  if (params.inherit_context) {
1273
1305
  return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
1274
1306
  }
1275
- if (params.run_in_background === false) {
1276
- return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.");
1277
- }
1278
1307
  if (!scheduler.isActive()) {
1279
1308
  return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
1280
1309
  }
@@ -1283,16 +1312,19 @@ Terse command-style prompts produce shallow, generic work.
1283
1312
  name: params.description as string,
1284
1313
  description: params.description as string,
1285
1314
  schedule: params.schedule as string,
1286
- subagent_type: subagentType,
1315
+ // The caller's own name, not the substitute — the scheduler re-resolves
1316
+ // at fire time, and the original is what a user edits.
1317
+ subagent_type: requestedType,
1287
1318
  prompt: params.prompt as string,
1288
1319
  thinking: thinking,
1289
1320
  max_turns: effectiveMaxTurns,
1290
1321
  isolated: isolated,
1291
1322
  isolation: isolation,
1292
1323
  });
1324
+ warnOnOutOfScopeModel();
1293
1325
  const next = scheduler.getNextRun(job.id);
1294
1326
  return textResult(
1295
- `Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1327
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1296
1328
  `Next run: ${next ?? "(unknown)"}. ` +
1297
1329
  `Manage via /agents → Scheduled jobs.`,
1298
1330
  );
@@ -1301,32 +1333,103 @@ Terse command-style prompts produce shallow, generic work.
1301
1333
  }
1302
1334
  }
1303
1335
 
1304
- // Resume existing agent
1336
+ // Resume existing agent — kick off a background resume and return an envelope.
1305
1337
  if (params.resume) {
1306
1338
  const existing = manager.getRecord(params.resume);
1307
- if (!existing) {
1339
+ // Nested records are reachable only through their spawning agent's
1340
+ // scoped tools; the top-level tool treats them as absent.
1341
+ if (!existing || existing.parentAgentId) {
1308
1342
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
1309
1343
  }
1310
- if (!existing.session) {
1311
- return textResult(`Agent "${params.resume}" has no active session to resume.`);
1312
- }
1313
- const record = await manager.resume(params.resume, params.prompt, signal);
1314
- if (!record) {
1315
- return textResult(`Failed to resume agent "${params.resume}".`);
1344
+ // Running (including the start prologue, where `settled` may still hold
1345
+ // the prior run's `true`), queued, or winding down — starting now would
1346
+ // race the previous run's settle handler. A queued record has no session
1347
+ // yet, so the active check precedes the session check.
1348
+ if (existing.status === "running" || existing.status === "queued" || existing.settled === false) {
1349
+ return textResult(`Agent "${params.resume}" is still active (running, queued, or winding down) — wait for it to finish before resuming.`);
1316
1350
  }
1317
- // A failed resume surfaces the error, plus any partial output THIS
1318
- // resume produced (never the previous turn's answer, #144).
1319
- if (record.status === "error") {
1320
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record));
1351
+ if (!existing.session) return textResult(`Agent "${params.resume}" has no active session to resume.`);
1352
+ const { state: resumeState, callbacks: resumeCallbacks } = createActivityTracker(existing.effectiveMaxTurns);
1353
+ const resumeStart = existing.session.messages.length;
1354
+ const record = manager.resume(params.resume, params.prompt, resumeCallbacks);
1355
+ if (!record) return textResult(`Failed to resume agent "${params.resume}".`);
1356
+ // Only once the resume is accepted — a refused resume must not drop the
1357
+ // prior run's still-pending completion nudge.
1358
+ cancelNudge(params.resume);
1359
+ // The session already exists, so onSessionCreated never fires for a resume;
1360
+ // seed it here so the widget/viewer can render the context-fill (NN%).
1361
+ resumeState.session = existing.session;
1362
+ agentActivity.set(record.id, resumeState);
1363
+ if (record.outputFile && record.session) {
1364
+ record.outputCleanup = streamToOutputFile(record.session, record.outputFile, record.id, ctx.cwd, resumeStart);
1321
1365
  }
1366
+ widget.markRunning(record.id);
1367
+ widget.ensureTimer(); widget.update(); fleet.ensureTimer(); fleet.update();
1368
+ // The row's model fields come from the resumed record's resolved
1369
+ // invocation: a resume continues the session's own model, so deriving
1370
+ // them from the call's subagent_type would mix identities. Records
1371
+ // spawned outside the Agent tool (RPC, scheduler, wizard) capture no
1372
+ // invocation — the row then carries only record-owned values (the
1373
+ // effective max turns), never the call's model/strategy settings,
1374
+ // which this resume does not apply. The conversation viewer reads
1375
+ // `record.invocation` directly and renders nothing for those records;
1376
+ // the widget renders from the activity tracker and the record's
1377
+ // type/description.
1378
+ const displayInvocation: AgentInvocation = existing.invocation ?? {
1379
+ maxTurns: existing.effectiveMaxTurns,
1380
+ };
1381
+ const { modelName: resumeModelName, tags: resumeInvocationTags } = buildInvocationTags(displayInvocation);
1382
+ const resumeModeLabel = getPromptModeLabel(existing.type);
1383
+ const resumeTags = resumeModeLabel ? [resumeModeLabel, ...resumeInvocationTags] : resumeInvocationTags;
1322
1384
  return textResult(
1323
- record.result?.trim() || "No output.",
1324
- buildDetails(detailBase, record),
1385
+ `Agent resumed in background.\nAgent ID: ${record.id}\nType: ${getDisplayName(existing.type)}\nDescription: ${record.description}\n` +
1386
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1387
+ `\nYou will be notified on subagent completion/failure.\n` +
1388
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1389
+ `Do not duplicate this agent's work.`,
1390
+ {
1391
+ // Row identity mirrors the envelope text above: the record's type
1392
+ // and description, not the resume call's subagent_type/description.
1393
+ displayName: getDisplayName(existing.type),
1394
+ description: record.description,
1395
+ subagentType: existing.type,
1396
+ modelName: resumeModelName,
1397
+ tags: resumeTags.length > 0 ? resumeTags : undefined,
1398
+ toolUses: record.toolUses,
1399
+ tokens: "",
1400
+ durationMs: 0,
1401
+ status: "background" as const,
1402
+ agentId: record.id,
1403
+ },
1325
1404
  );
1326
1405
  }
1327
1406
 
1328
1407
  // Background execution
1329
- if (runInBackground) {
1408
+ {
1409
+ const displayName = getDisplayName(subagentType);
1410
+ const agentInvocation: AgentInvocation = {
1411
+ modelName,
1412
+ modelId,
1413
+ thinking,
1414
+ // Explicit value only — the default fallback would just add noise.
1415
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1416
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
1417
+ isolated,
1418
+ inheritContext,
1419
+ isolation,
1420
+ };
1421
+ // Tool-result render shows the mode label too; viewer's header already does.
1422
+ const modeLabel = getPromptModeLabel(subagentType);
1423
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
1424
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
1425
+ const detailBase = {
1426
+ displayName,
1427
+ description: params.description,
1428
+ subagentType,
1429
+ modelName,
1430
+ tags: agentTags.length > 0 ? agentTags : undefined,
1431
+ };
1432
+
1330
1433
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1331
1434
 
1332
1435
  // Wrap onSessionCreated to wire output file streaming.
@@ -1350,192 +1453,68 @@ Terse command-style prompts produce shallow, generic work.
1350
1453
  isolated,
1351
1454
  inheritContext,
1352
1455
  thinkingLevel: thinking,
1353
- isBackground: true,
1354
1456
  isolation,
1355
1457
  invocation: agentInvocation,
1458
+ rootSessionId: ctx.sessionManager.getSessionId(),
1356
1459
  ...bgCallbacks,
1357
1460
  });
1358
1461
  } catch (err) {
1359
1462
  return textResult(err instanceof Error ? err.message : String(err));
1360
1463
  }
1464
+ warnOnOutOfScopeModel();
1361
1465
 
1362
- // Set output file + join mode synchronously after spawn, before the
1363
- // event loop yields — onSessionCreated is async so this is safe.
1364
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1466
+ // Set the output file synchronously after spawn, before the event loop
1467
+ // yields — onSessionCreated is async so this is safe.
1365
1468
  const record = manager.getRecord(id);
1366
- if (record && joinMode) {
1367
- record.joinMode = joinMode;
1368
- record.toolCallId = toolCallId;
1369
- attachTranscript(record, id);
1469
+ if (record) attachTranscript(record, id);
1470
+
1471
+ // A spawned record can settle before spawn returns: a `subagents:started`
1472
+ // listener stopping it, or an already-aborted parent signal. The
1473
+ // completion callback already removed its activity entry, so re-adding
1474
+ // one would render a `↻1` finished row for a run that never executed a
1475
+ // turn, and the widget timers have no live run to update. The stop
1476
+ // notification remains the delivery for these runs.
1477
+ if (record && record.settled !== true) {
1478
+ agentActivity.set(id, bgState);
1479
+ widget.ensureTimer();
1480
+ widget.update();
1481
+ fleet.ensureTimer();
1482
+ fleet.update();
1370
1483
  }
1371
1484
 
1372
- if (joinMode == null || joinMode === 'async') {
1373
- // Foreground/no join mode or explicit async — not part of any batch
1374
- } else {
1375
- // smart or group — add to current batch
1376
- currentBatchAgents.push({ id, joinMode });
1377
- // Debounce: reset timer on each new agent so parallel tool calls
1378
- // dispatched across multiple event loop ticks are captured together
1379
- if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
1380
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1381
- }
1382
-
1383
- agentActivity.set(id, bgState);
1384
- widget.ensureTimer();
1385
- widget.update();
1386
- fleet.ensureTimer();
1387
- fleet.update();
1388
-
1389
1485
  // Emit created event
1390
1486
  pi.events.emit("subagents:created", {
1391
1487
  id,
1392
1488
  type: subagentType,
1393
1489
  description: params.description,
1394
- isBackground: true,
1395
1490
  });
1396
1491
 
1397
1492
  const isQueued = record?.status === "queued";
1398
1493
  return textResult(
1399
- `Agent ${isQueued ? "queued" : "started"} in background.\n` +
1494
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
1400
1495
  `Agent ID: ${id}\n` +
1401
1496
  `Type: ${displayName}\n` +
1402
1497
  `Description: ${params.description}\n` +
1403
1498
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1404
1499
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1405
- `\nYou will be notified when this agent completes.\n` +
1500
+ `\nYou will be notified on subagent completion/failure.\n` +
1406
1501
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1407
1502
  `Do not duplicate this agent's work.`,
1408
1503
  { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
1409
1504
  );
1410
1505
  }
1411
1506
 
1412
- // Foreground (synchronous) execution — stream progress via onUpdate
1413
- let spinnerFrame = 0;
1414
- const startedAt = Date.now();
1415
- let fgId: string | undefined;
1416
-
1417
- const streamUpdate = () => {
1418
- const details: AgentDetails = {
1419
- ...detailBase,
1420
- toolUses: fgState.toolUses,
1421
- tokens: formatLifetimeTokens(fgState),
1422
- turnCount: fgState.turnCount,
1423
- maxTurns: fgState.maxTurns,
1424
- durationMs: Date.now() - startedAt,
1425
- status: "running",
1426
- activity: describeActivity(fgState.activeTools, fgState.responseText),
1427
- spinnerFrame: spinnerFrame % SPINNER.length,
1428
- };
1429
- onUpdate?.({
1430
- content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
1431
- details: details as any,
1432
- });
1433
- };
1434
-
1435
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1436
-
1437
- // Wire session creation: register in widget + stream to output file.
1438
- // The output file path is set synchronously after spawn (below),
1439
- // before onSessionCreated fires — same pattern as background agents.
1440
- const origOnSession = fgCallbacks.onSessionCreated;
1441
- fgCallbacks.onSessionCreated = (session: any) => {
1442
- origOnSession(session);
1443
- for (const a of manager.listAgents()) {
1444
- if (a.session === session) {
1445
- fgId = a.id;
1446
- agentActivity.set(a.id, fgState);
1447
- widget.ensureTimer();
1448
- fleet.ensureTimer();
1449
- fleet.update();
1450
- break;
1451
- }
1452
- }
1453
- // Stream conversation to output file (foreground agent logging)
1454
- if (fgId) {
1455
- const rec = manager.getRecord(fgId);
1456
- if (rec?.outputFile) {
1457
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1458
- }
1459
- }
1460
- };
1461
-
1462
- // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
1463
- const spinnerInterval = setInterval(() => {
1464
- spinnerFrame++;
1465
- streamUpdate();
1466
- }, 80);
1467
-
1468
- streamUpdate();
1469
-
1470
- let record: AgentRecord;
1471
- try {
1472
- const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1473
- description: params.description,
1474
- model,
1475
- maxTurns: effectiveMaxTurns,
1476
- isolated,
1477
- inheritContext,
1478
- thinkingLevel: thinking,
1479
- isolation,
1480
- invocation: agentInvocation,
1481
- signal,
1482
- ...fgCallbacks,
1483
- }, (fgAgentId) => {
1484
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
1485
- // Set up the output file so streamToOutputFile can pick it up.
1486
- const fgRec = manager.getRecord(fgAgentId);
1487
- attachTranscript(fgRec, fgAgentId);
1488
- });
1489
- record = fgResult.record;
1490
- } catch (err) {
1491
- clearInterval(spinnerInterval);
1492
- return textResult(err instanceof Error ? err.message : String(err));
1493
- }
1494
-
1495
- clearInterval(spinnerInterval);
1496
-
1497
- // Clean up foreground agent from widget
1498
- if (fgId) {
1499
- agentActivity.delete(fgId);
1500
- widget.markFinished(fgId);
1501
- fleet.onAgentFinished(fgId);
1502
- }
1503
-
1504
- // Get final token count
1505
- const tokenText = formatLifetimeTokens(fgState);
1506
-
1507
- const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
1508
-
1509
- // "general-purpose" may itself be unregistered (defaults disabled, no
1510
- // user override) — getConfig then uses the hardcoded fallback config.
1511
- const fallbackNote = fellBack
1512
- ? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
1513
- : "";
1514
-
1515
- if (record.status === "error") {
1516
- // Error headline + any partial output the run produced before failing.
1517
- return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
1518
- }
1519
-
1520
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
1521
- const statsParts = [`${record.toolUses} tool uses`];
1522
- if (tokenText) statsParts.push(tokenText);
1523
- return textResult(
1524
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
1525
- (record.result?.trim() || "No output."),
1526
- details,
1527
- );
1528
1507
  },
1529
1508
  }));
1530
1509
 
1531
1510
  // ---- get_subagent_result tool ----
1532
1511
 
1533
- pi.registerTool(defineTool({
1512
+ registerParentTool(defineTool({
1534
1513
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1535
1514
  label: "Get Agent Result",
1536
1515
  description:
1537
- "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
1538
- promptSnippet: "Check status and retrieve results from a background agent",
1516
+ "Check a subagent's status and retrieve its result. Use the agent ID returned by the Agent tool.",
1517
+ promptSnippet: "Check a subagent's status and retrieve its result",
1539
1518
  parameters: Type.Object({
1540
1519
  agent_id: Type.String({
1541
1520
  description: "The agent ID to check.",
@@ -1543,201 +1522,124 @@ Terse command-style prompts produce shallow, generic work.
1543
1522
  }),
1544
1523
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1545
1524
  const record = manager.getRecord(params.agent_id);
1546
- if (!record) {
1525
+ // Nested records belong to their spawning agent's scoped tools only.
1526
+ if (!record || record.parentAgentId) {
1547
1527
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1548
1528
  }
1549
1529
 
1550
- // existsSync gates cover out-of-band file deletion (checkpointsFileOk is latching-true).
1551
- const checkpointsCandidate = checkpointsFilePath(record.outputFile);
1552
- const checkpointsPath =
1553
- record.checkpointsFileOk && checkpointsCandidate && existsSync(checkpointsCandidate)
1554
- ? checkpointsCandidate
1555
- : undefined;
1556
- const transcriptPath =
1557
- record.outputFile && existsSync(record.outputFile) ? record.outputFile : undefined;
1558
-
1559
- if (record.status === "running") {
1560
- return textResult(renderRunning(record, checkpointsPath, transcriptPath));
1530
+ // Mark result as consumed — suppresses the completion notification. Safe
1531
+ // for every status: only a settled record has a nudge parked, and a
1532
+ // running/queued one has none to cancel.
1533
+ if (record.status !== "running" && record.status !== "queued") {
1534
+ record.resultConsumed = true;
1535
+ cancelNudge(params.agent_id);
1561
1536
  }
1562
1537
 
1563
- // Queued agent: has not started running yet. Return a short shape with no
1564
- // file paths and no result body, mirroring the `running` early return.
1565
- if (record.status === "queued") {
1566
- return textResult(renderQueued(record));
1567
- }
1568
-
1569
- // Mark result as consumed — suppresses the completion notification.
1570
- // Queued agents return above; this block only runs for terminal statuses.
1571
- // Note: for group-batched agents the nudge is keyed `group:<ids>`, so
1572
- // cancelNudge(agent_id) is a no-op there — the group send closure's
1573
- // resultConsumed re-check is what filters this agent out instead.
1574
- record.resultConsumed = true;
1575
- cancelNudge(params.agent_id);
1576
-
1577
- // Attach notification details so renderResult can render the report as
1578
- // markdown, mirroring the completion notification the parent just
1579
- // suppressed by consuming synchronously.
1580
- const details = buildNotificationDetails(record, { failurePreviewMaxChars }, agentActivity.get(record.id));
1581
-
1582
- if (record.status === "error") {
1583
- return textResult(renderTerminal(record, checkpointsPath, transcriptPath, true), details);
1584
- }
1585
- return textResult(renderTerminal(record, checkpointsPath, transcriptPath, false), details);
1538
+ return textResult(renderAgentReport(record));
1586
1539
  },
1587
1540
 
1588
- renderResult(result, { expanded }, theme) {
1589
- const details = result.details as NotificationDetails | undefined;
1590
- if (!details) {
1591
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1592
- return new Text(text, 0, 0);
1593
- }
1594
- const effectiveExpanded = resultPreviewExpanded ? true : expanded;
1595
- const container = new Container();
1596
- container.addChild(subagentNotificationRenderHeader(details, theme));
1597
- container.addChild(subagentNotificationRenderBody(details, effectiveExpanded, resultPreviewMode, theme));
1598
- return container;
1541
+ renderResult(result, _options, theme) {
1542
+ const report = result.content[0]?.type === "text" ? result.content[0].text : "";
1543
+ // Display copy only: the tool text returned to the parent model keeps its
1544
+ // raw result/error bodies; the builders collapse its single-line metadata.
1545
+ const display = typeof report === "string" ? stripControlChars(report) : "";
1546
+ return display
1547
+ ? new Markdown(display, 0, 0, getMarkdownTheme(), { color: (t) => theme.fg("toolOutput", t) })
1548
+ : new Text("", 0, 0);
1599
1549
  },
1600
1550
  }));
1601
1551
 
1602
- /** Queued subagent: no file paths, result body, or footer — nothing produced yet. */
1603
- function renderQueued(record: AgentRecord): string {
1604
- const displayName = getDisplayName(record.type);
1605
- return (
1606
- `Agent: ${record.id} (queued — not started yet)\n` +
1607
- `Type: ${displayName} | Description: ${record.description}\n\n` +
1608
- `This agent is waiting to start. It will begin running when a concurrent-agent slot frees up.`
1609
- );
1610
- }
1611
-
1612
- /** Running subagent: header (turn/elapsed), latest checkpoint, file paths, polling-discouragement footer. */
1613
- function renderRunning(
1614
- record: AgentRecord,
1615
- checkpointsPath: string | undefined,
1616
- transcriptPath: string | undefined,
1617
- ): string {
1618
- const displayName = getDisplayName(record.type);
1619
- const turn = record.turnCount ?? 0;
1620
- const maxTurns = record.effectiveMaxTurns;
1621
- const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
1622
- const turnLabel = maxTurns != null ? `turn ${turn}/${maxTurns}` : `turn ${turn}`;
1623
-
1624
- let output =
1625
- `Agent: ${record.id} (still running — ${turnLabel}, ${elapsedSeconds}s elapsed)\n` +
1626
- `Type: ${displayName} | Description: ${record.description}\n\n`;
1627
-
1628
- if (record.lastCheckpoint) {
1629
- output +=
1630
- `Latest checkpoint (turn ${record.lastCheckpoint.turn}):\n` +
1631
- ` ${record.lastCheckpoint.summary}\n\n`;
1632
- output += fileSection(checkpointsPath, transcriptPath, footerForPaths(checkpointsPath, transcriptPath, true));
1633
- } else {
1634
- output += `No checkpoint yet — the subagent hasn't called the checkpoint tool.\n\n`;
1635
- output += fileSection(checkpointsPath, transcriptPath,
1636
- " grep or read the transcript for detail on what it's doing. Do not poll repeatedly.");
1637
- }
1638
- return output;
1639
- }
1640
-
1641
- /** Completed/errored subagent: completed header, result or error block, latest checkpoint, file paths. */
1642
- function renderTerminal(
1643
- record: AgentRecord,
1644
- checkpointsPath: string | undefined,
1645
- transcriptPath: string | undefined,
1646
- errored: boolean,
1647
- ): string {
1648
- const header = completedHeader(record);
1649
- const body = errored
1650
- ? `Error: ${record.error}${partialOutputSuffix(record)}`
1651
- : record.result?.trim() || "No output.";
1652
-
1653
- let output = `${header}\n\n${body}`;
1654
- if (record.lastCheckpoint) {
1655
- output +=
1656
- `\n\nLatest checkpoint (turn ${record.lastCheckpoint.turn}):\n` +
1657
- ` ${record.lastCheckpoint.summary}`;
1658
- }
1659
- output += "\n\n" + fileSection(checkpointsPath, transcriptPath, footerForPaths(checkpointsPath, transcriptPath, false));
1660
- return output;
1661
- }
1662
-
1663
- /** Shared header for completed/errored shapes (Status + stats + status note). */
1664
- function completedHeader(record: AgentRecord): string {
1665
- const displayName = getDisplayName(record.type);
1666
- const duration = formatDuration(record.startedAt, record.completedAt);
1667
- const tokens = formatLifetimeTokens(record);
1668
- const contextPercent = getSessionContextPercent(record.session);
1669
- const statsParts = [`Tool uses: ${record.toolUses}`];
1670
- if (tokens) statsParts.push(tokens);
1671
- if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
1672
- if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
1673
- statsParts.push(`Duration: ${duration}`);
1674
- return (
1675
- `Agent: ${record.id}\n` +
1676
- `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1677
- `Description: ${record.description}`
1678
- );
1679
- }
1680
-
1681
- /** The checkpoint-history / full-transcript path block, aligned to a shared column, plus the footer. */
1682
- function fileSection(
1683
- checkpointsPath: string | undefined,
1684
- transcriptPath: string | undefined,
1685
- footer: string,
1686
- ): string {
1687
- let s = "";
1688
- if (checkpointsPath) s += `Checkpoint history: ${checkpointsPath}\n`;
1689
- if (transcriptPath) s += `Full transcript: ${transcriptPath}\n`;
1690
- s += footer;
1691
- return s;
1692
- }
1693
-
1694
- /** Footer matching the paths actually rendered (existsSync can suppress either independently). */
1695
- function footerForPaths(
1696
- checkpointsPath: string | undefined,
1697
- transcriptPath: string | undefined,
1698
- running: boolean,
1699
- ): string {
1700
- const suffix = running ? " Do not poll repeatedly." : "";
1701
- if (checkpointsPath && transcriptPath) {
1702
- return ` grep or read the checkpoints / transcript for more detail.${suffix}`;
1703
- }
1704
- if (checkpointsPath) {
1705
- return ` grep or read the checkpoints file for more detail.${suffix}`;
1706
- }
1707
- return ` grep or read the transcript for more detail.${suffix}`;
1708
- }
1709
-
1710
1552
  // ---- steer_subagent tool ----
1711
1553
 
1712
- pi.registerTool(defineTool({
1554
+ type SteerOutcome = "sent" | "queued" | "not-found" | "not-running" | "failed";
1555
+ const steerResult = (msg: string, steerOutcome: SteerOutcome) => textResult(msg, { steerOutcome });
1556
+
1557
+ registerParentTool(defineTool({
1713
1558
  name: SUBAGENT_TOOL_NAMES.STEER,
1714
1559
  label: "Steer Agent",
1715
1560
  description:
1716
- "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
1717
- "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
1718
- promptSnippet: "Send a steering message to redirect a running background agent",
1561
+ "Send a steering message to a subagent. The message will interrupt the agent after its current tool execution " +
1562
+ "and be injected into its conversation, allowing you to redirect its work mid-run.",
1563
+ promptSnippet: "Send a steering message to redirect a subagent",
1719
1564
  parameters: Type.Object({
1720
1565
  agent_id: Type.String({
1721
- description: "The agent ID to steer (must be currently running).",
1566
+ description: "The agent ID to steer (must be running or queued).",
1722
1567
  }),
1723
1568
  message: Type.String({
1724
1569
  description: "The steering message to send. This will appear as a user message in the agent's conversation.",
1725
1570
  }),
1726
1571
  }),
1572
+
1573
+ renderCall(args, theme, context) {
1574
+ const a = (args ?? {}) as { agent_id?: unknown; message?: unknown };
1575
+ const agentId = typeof a.agent_id === "string" ? a.agent_id : "";
1576
+ const raw = typeof a.message === "string" ? a.message : "";
1577
+ const state = context.state as { steerDesc?: string };
1578
+
1579
+ // Freeze the first resolved description so record eviction cannot flip the row.
1580
+ if (!state.steerDesc && agentId) {
1581
+ const desc = toSingleLine(manager.getRecord(agentId)?.description);
1582
+ if (desc) state.steerDesc = desc;
1583
+ }
1584
+ // Both header parts are display copies: sanitize + cap.
1585
+ const target = headerPreview(state.steerDesc ?? "") || headerPreview(toSingleLine(agentId));
1586
+
1587
+ const marker = context.expanded ? "▾" : "▸";
1588
+ let head = marker + " " + theme.fg("toolTitle", theme.bold("Steer"));
1589
+ if (target) head += " " + theme.fg("muted", target);
1590
+
1591
+ // Expanded: the result renderer owns the body once a result exists; while the
1592
+ // call is still pending (no result applied, e.g. replay after an abort) the
1593
+ // call shows it so the instruction is never invisible. The body is
1594
+ // built only when it can be shown, so collapsed renders stay O(preview window).
1595
+ if (context.expanded) {
1596
+ const body = context.isPartial ? instructionBody(raw, theme) : "";
1597
+ return new Text(body ? head + "\n" + body : head, 0, 0);
1598
+ }
1599
+
1600
+ const plain = toSingleLine(safeTruncate(raw, INSTRUCTION_PREVIEW_SCAN_CHARS));
1601
+ const shown = truncateToWidth(plain, INSTRUCTION_PREVIEW_COLS, "…");
1602
+ // A blind 512-unit cut hides content without changing `plain`.
1603
+ const hidden = shown !== plain || raw.length > INSTRUCTION_PREVIEW_SCAN_CHARS || /[\n\r\t]/.test(raw);
1604
+ if (!shown) return new Text(head + (hidden ? instructionHint(theme) : ""), 0, 0);
1605
+ const hint = hidden ? instructionHint(theme) : "";
1606
+ return new Text(head + " " + theme.fg("muted", shown) + hint, 0, 0);
1607
+ },
1608
+
1609
+ renderResult(result, { expanded }, theme, context) {
1610
+ const outcome = (result.details as { steerOutcome?: SteerOutcome } | undefined)?.steerOutcome;
1611
+ const failure = outcome === "not-found" || outcome === "not-running" || outcome === "failed";
1612
+ // A synthetic abort/error result carries no details.
1613
+ const color = context.isError || failure ? "error" : "dim";
1614
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1615
+ const cleaned = typeof text === "string" ? stripControlChars(text).trimEnd() : "";
1616
+ const status = cleaned.trim()
1617
+ ? cleaned.split("\n").map((l) => theme.fg(color, ` ⎿ ${l}`)).join("\n")
1618
+ : "";
1619
+ const raw = typeof (context.args as { message?: unknown })?.message === "string"
1620
+ ? (context.args as { message: string }).message
1621
+ : "";
1622
+ const body = instructionBody(raw, theme);
1623
+ if (expanded && body) {
1624
+ return new Text(body + (status ? "\n" + status : ""), 0, 0);
1625
+ }
1626
+ return new Text(status, 0, 0);
1627
+ },
1628
+
1727
1629
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1728
1630
  const record = manager.getRecord(params.agent_id);
1729
- if (!record) {
1730
- return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1631
+ if (!record || record.parentAgentId) {
1632
+ return steerResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`, "not-found");
1731
1633
  }
1732
- if (record.status !== "running") {
1733
- return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
1634
+ if (record.status !== "running" && record.status !== "queued") {
1635
+ return steerResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`, "not-running");
1734
1636
  }
1735
1637
  if (!record.session) {
1736
1638
  // Session not ready yet — queue the steer for delivery once initialized
1737
1639
  if (!record.pendingSteers) record.pendingSteers = [];
1738
1640
  record.pendingSteers.push(params.message);
1739
1641
  pi.events.emit("subagents:steered", { id: record.id, message: params.message });
1740
- return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
1642
+ return steerResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`, "queued");
1741
1643
  }
1742
1644
 
1743
1645
  try {
@@ -1750,12 +1652,13 @@ Terse command-style prompts produce shallow, generic work.
1750
1652
  stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
1751
1653
  if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
1752
1654
  if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
1753
- return textResult(
1655
+ return steerResult(
1754
1656
  `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
1755
1657
  `Current state: ${stateParts.join(" · ")}`,
1658
+ "sent",
1756
1659
  );
1757
1660
  } catch (err) {
1758
- return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
1661
+ return steerResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`, "failed");
1759
1662
  }
1760
1663
  },
1761
1664
  }));
@@ -1788,31 +1691,7 @@ Terse command-style prompts produce shallow, generic work.
1788
1691
  if (!record) {
1789
1692
  return textResult("Checkpoint failed: agent record not found.");
1790
1693
  }
1791
-
1792
- const turn = record.turnCount ?? 0;
1793
- const maxTurns = record.effectiveMaxTurns;
1794
- const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
1795
- record.lastCheckpoint = { turn, summary: params.summary };
1796
-
1797
- // Best-effort: a write failure (disk full, missing parent dir) returns a soft
1798
- // warning instead of crashing the call. checkpointsFileOk is latching-true —
1799
- // see AgentRecord.checkpointsFileOk for the suppression contract.
1800
- const checkpointsPath = checkpointsFilePath(record.outputFile);
1801
- const maxTurnsLabel = maxTurns != null ? `/${maxTurns}` : "";
1802
- if (checkpointsPath) {
1803
- try {
1804
- appendFileSync(
1805
- checkpointsPath,
1806
- `## Turn ${turn}${maxTurnsLabel} — ${elapsedSeconds}s elapsed\n${params.summary}\n\n`,
1807
- "utf-8",
1808
- );
1809
- record.checkpointsFileOk = true;
1810
- } catch (err) {
1811
- return textResult(`Checkpoint saved in memory, but file write failed: ${err instanceof Error ? err.message : String(err)}.`);
1812
- }
1813
- }
1814
-
1815
- return textResult(`Checkpoint saved (turn ${turn}${maxTurnsLabel}, ${elapsedSeconds}s).`);
1694
+ return textResult(saveCheckpoint(record, params.summary));
1816
1695
  },
1817
1696
  }));
1818
1697
  }
@@ -1860,7 +1739,7 @@ Terse command-style prompts produce shallow, generic work.
1860
1739
  const options: string[] = [];
1861
1740
 
1862
1741
  // Running agents entry (only if there are active agents)
1863
- const agents = manager.listAgents();
1742
+ const agents = manager.listAgents().filter(a => !a.parentAgentId);
1864
1743
  if (agents.length > 0) {
1865
1744
  const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
1866
1745
  const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
@@ -1936,14 +1815,17 @@ Terse command-style prompts produce shallow, generic work.
1936
1815
  const cfg = getAgentConfig(name);
1937
1816
  const disabled = cfg?.enabled === false;
1938
1817
  const model = getModelLabel(name, ctx.modelRegistry);
1818
+ const displayName = toSingleLine(name);
1939
1819
  return {
1940
1820
  id: name,
1941
- label: `${sourceIndicator(cfg)}${name}`,
1942
- currentValue: model,
1943
- description: disabled ? "(disabled)" : (cfg?.description ?? name),
1821
+ label: `${sourceIndicator(cfg)}${displayName}`,
1822
+ currentValue: toSingleLine(model),
1823
+ description: disabled ? "(disabled)" : toSingleLine(cfg?.description ?? name),
1944
1824
  // Single-value list so Enter "activates" the row (fires onChange with the
1945
- // agent's id) without offering anything to actually cycle.
1946
- values: [model],
1825
+ // agent's id) without offering anything to actually cycle. The value is
1826
+ // the sanitized display copy: SettingsList copies values[0] back into
1827
+ // currentValue on activation, so a raw label would reappear.
1828
+ values: [toSingleLine(model)],
1947
1829
  };
1948
1830
  });
1949
1831
 
@@ -1981,16 +1863,16 @@ Terse command-style prompts produce shallow, generic work.
1981
1863
  }
1982
1864
 
1983
1865
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1984
- const agents = manager.listAgents();
1866
+ const agents = manager.listAgents().filter(a => !a.parentAgentId);
1985
1867
  if (agents.length === 0) {
1986
1868
  ctx.ui.notify("No agents.", "info");
1987
1869
  return;
1988
1870
  }
1989
1871
 
1990
1872
  const options = agents.map(a => {
1991
- const dn = getDisplayName(a.type);
1873
+ const dn = toSingleLine(getDisplayName(a.type));
1992
1874
  const dur = formatDuration(a.startedAt, a.completedAt);
1993
- return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
1875
+ return `${dn} (${toSingleLine(a.description)}) · ${a.toolUses} tools · ${toSingleLine(a.status)} · ${dur}`;
1994
1876
  });
1995
1877
 
1996
1878
  const choice = await ctx.ui.select("Running agents", options);
@@ -2020,9 +1902,9 @@ Terse command-style prompts produce shallow, generic work.
2020
1902
  (tui, theme, keybindings, done) => {
2021
1903
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
2022
1904
  if (manager.abort(record.id)) {
2023
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
1905
+ ctx.ui.notify(`Stopped "${toSingleLine(record.description)}".`, "info");
2024
1906
  }
2025
- }, keybindings, (message: string) => manager.steer(record.id, message));
1907
+ }, keybindings, (message: string) => manager.steer(record.id, message), showCost);
2026
1908
  },
2027
1909
  {
2028
1910
  overlay: true,
@@ -2034,7 +1916,7 @@ Terse command-style prompts produce shallow, generic work.
2034
1916
  async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
2035
1917
  const cfg = getAgentConfig(name);
2036
1918
  if (!cfg) {
2037
- ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
1919
+ ctx.ui.notify(`Agent config not found for "${toSingleLine(name)}".`, "warning");
2038
1920
  return;
2039
1921
  }
2040
1922
 
@@ -2059,33 +1941,33 @@ Terse command-style prompts produce shallow, generic work.
2059
1941
  menuOptions = ["Edit", "Disable", "Delete", "Back"];
2060
1942
  }
2061
1943
 
2062
- const choice = await ctx.ui.select(name, menuOptions);
1944
+ const choice = await ctx.ui.select(toSingleLine(name), menuOptions);
2063
1945
  if (!choice || choice === "Back") return;
2064
1946
 
2065
1947
  if (choice === "Edit" && file) {
2066
1948
  const content = readFileSync(file.path, "utf-8");
2067
- const edited = await ctx.ui.editor(`Edit ${name}`, content);
1949
+ const edited = await ctx.ui.editor(`Edit ${toSingleLine(name)}`, content);
2068
1950
  if (edited !== undefined && edited !== content) {
2069
1951
  const { writeFileSync } = await import("node:fs");
2070
1952
  writeFileSync(file.path, edited, "utf-8");
2071
1953
  reloadCustomAgents();
2072
- ctx.ui.notify(`Updated ${file.path}`, "info");
1954
+ ctx.ui.notify(`Updated ${toSingleLine(file.path)}`, "info");
2073
1955
  }
2074
1956
  } else if (choice === "Delete") {
2075
1957
  if (file) {
2076
- const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
1958
+ const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${toSingleLine(name)} from ${file.location} (${toSingleLine(file.path)})?`);
2077
1959
  if (confirmed) {
2078
1960
  unlinkSync(file.path);
2079
1961
  reloadCustomAgents();
2080
- ctx.ui.notify(`Deleted ${file.path}`, "info");
1962
+ ctx.ui.notify(`Deleted ${toSingleLine(file.path)}`, "info");
2081
1963
  }
2082
1964
  }
2083
1965
  } else if (choice === "Reset to default" && file) {
2084
- const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
1966
+ const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${toSingleLine(file.path)} and restore embedded default?`);
2085
1967
  if (confirmed) {
2086
1968
  unlinkSync(file.path);
2087
1969
  reloadCustomAgents();
2088
- ctx.ui.notify(`Restored default ${name}`, "info");
1970
+ ctx.ui.notify(`Restored default ${toSingleLine(name)}`, "info");
2089
1971
  }
2090
1972
  } else if (choice.startsWith("Eject")) {
2091
1973
  await ejectAgent(ctx, name, cfg);
@@ -2109,7 +1991,7 @@ Terse command-style prompts produce shallow, generic work.
2109
1991
 
2110
1992
  const targetPath = join(targetDir, `${name}.md`);
2111
1993
  if (existsSync(targetPath)) {
2112
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
1994
+ const overwrite = await ctx.ui.confirm("Overwrite", `${toSingleLine(targetPath)} already exists. Overwrite?`);
2113
1995
  if (!overwrite) return;
2114
1996
  }
2115
1997
 
@@ -2129,18 +2011,21 @@ Terse command-style prompts produce shallow, generic work.
2129
2011
  else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
2130
2012
  if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
2131
2013
  if (cfg.inheritContext) fmFields.push("inherit_context: true");
2132
- if (cfg.runInBackground) fmFields.push("run_in_background: true");
2133
2014
  if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");
2134
2015
  if (cfg.isolated) fmFields.push("isolated: true");
2135
2016
  if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
2136
2017
  if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
2018
+ if (cfg.allowedSubagents !== undefined) {
2019
+ fmFields.push(`allowed_subagents: ${cfg.allowedSubagents === "all" ? "all" : cfg.allowedSubagents.join(", ")}`);
2020
+ }
2021
+ if (cfg.maxSubagentDepth !== undefined) fmFields.push(`max_subagent_depth: ${cfg.maxSubagentDepth}`);
2137
2022
 
2138
2023
  const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
2139
2024
 
2140
2025
  const { writeFileSync } = await import("node:fs");
2141
2026
  writeFileSync(targetPath, content, "utf-8");
2142
2027
  reloadCustomAgents();
2143
- ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
2028
+ ctx.ui.notify(`Ejected ${toSingleLine(name)} to ${toSingleLine(targetPath)}`, "info");
2144
2029
  }
2145
2030
 
2146
2031
  /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
@@ -2150,14 +2035,14 @@ Terse command-style prompts produce shallow, generic work.
2150
2035
  // Existing file — set enabled: false in frontmatter (idempotent)
2151
2036
  const content = readFileSync(file.path, "utf-8");
2152
2037
  if (content.includes("\nenabled: false\n")) {
2153
- ctx.ui.notify(`${name} is already disabled.`, "info");
2038
+ ctx.ui.notify(`${toSingleLine(name)} is already disabled.`, "info");
2154
2039
  return;
2155
2040
  }
2156
2041
  const updated = content.replace(/^---\n/, "---\nenabled: false\n");
2157
2042
  const { writeFileSync } = await import("node:fs");
2158
2043
  writeFileSync(file.path, updated, "utf-8");
2159
2044
  reloadCustomAgents();
2160
- ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
2045
+ ctx.ui.notify(`Disabled ${toSingleLine(name)} (${toSingleLine(file.path)})`, "info");
2161
2046
  return;
2162
2047
  }
2163
2048
 
@@ -2175,7 +2060,7 @@ Terse command-style prompts produce shallow, generic work.
2175
2060
  const { writeFileSync } = await import("node:fs");
2176
2061
  writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
2177
2062
  reloadCustomAgents();
2178
- ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
2063
+ ctx.ui.notify(`Disabled ${toSingleLine(name)} (${toSingleLine(targetPath)})`, "info");
2179
2064
  }
2180
2065
 
2181
2066
  /** Enable a disabled agent by removing enabled: false from its frontmatter. */
@@ -2191,11 +2076,11 @@ Terse command-style prompts produce shallow, generic work.
2191
2076
  if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
2192
2077
  unlinkSync(file.path);
2193
2078
  reloadCustomAgents();
2194
- ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
2079
+ ctx.ui.notify(`Enabled ${toSingleLine(name)} (removed ${toSingleLine(file.path)})`, "info");
2195
2080
  } else {
2196
2081
  writeFileSync(file.path, updated, "utf-8");
2197
2082
  reloadCustomAgents();
2198
- ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
2083
+ ctx.ui.notify(`Enabled ${toSingleLine(name)} (${toSingleLine(file.path)})`, "info");
2199
2084
  }
2200
2085
  }
2201
2086
 
@@ -2232,7 +2117,7 @@ Terse command-style prompts produce shallow, generic work.
2232
2117
 
2233
2118
  const targetPath = join(targetDir, `${name}.md`);
2234
2119
  if (existsSync(targetPath)) {
2235
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
2120
+ const overwrite = await ctx.ui.confirm("Overwrite", `${toSingleLine(targetPath)} already exists. Overwrite?`);
2236
2121
  if (!overwrite) return;
2237
2122
  }
2238
2123
 
@@ -2256,11 +2141,18 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
2256
2141
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
2257
2142
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
2258
2143
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
2259
- run_in_background: <true to run in background by default. Default: false>
2260
2144
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
2261
2145
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
2262
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
2263
- isolation: <"worktree" to run in isolated git worktree. Omit for normal>
2146
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
2147
+ // Offering the field on a project that turned worktrees off would bake a
2148
+ // request that is refused at spawn time into a file that outlives the
2149
+ // session. Built per invocation, so this read is live.
2150
+ isWorktreeIsolationEnabled()
2151
+ ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
2152
+ : ""
2153
+ }
2154
+ allowed_subagents: <"all", a comma-separated list of agent types this agent may delegate to, or omit for no nested delegation>
2155
+ max_subagent_depth: <optional per-agent nesting cap; can only lower the project's maxSubagentDepth>
2264
2156
  ---
2265
2157
 
2266
2158
  <system prompt body — instructions for the agent>
@@ -2273,25 +2165,49 @@ Guidelines for choosing settings:
2273
2165
  - Use prompt_mode: replace for fully custom agents with their own personality/instructions
2274
2166
  - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
2275
2167
  - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
2276
- - Set output_transcript: false to skip writing this agent's transcript; this alone doesn't keep the run off disk (persist_session, isolation: worktree commits, and memory still write) — set those too if that's the goal
2168
+ - Set output_transcript: false to skip writing this agent's transcript; it doesn't keep the run off disk — every run writes a pi session file while it runs (kept after it settles only with persist_session: true), and isolation: worktree commits and memory still write
2277
2169
  - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
2278
2170
 
2279
2171
  Write the file using the write tool. Only write the file, nothing else.`;
2280
2172
 
2281
- const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
2282
- description: `Generate ${name} agent`,
2283
- maxTurns: 5,
2284
- });
2285
-
2286
- if (record.status === "error") {
2287
- ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
2173
+ let id: string;
2174
+ try {
2175
+ // Deliberately outside `resolveSpawnType`: this is the extension's own
2176
+ // generator, not a caller-supplied spawn, and it wants the all-tools tier.
2177
+ // Routing it through `fallbackSubagent: none` would break the wizard for
2178
+ // users who opted into strict dispatch.
2179
+ id = manager.spawn(pi, ctx, "general-purpose", generatePrompt, {
2180
+ description: `Generate ${name} agent`,
2181
+ maxTurns: WIZARD_MAX_TURNS,
2182
+ });
2183
+ } catch (err) {
2184
+ ctx.ui.notify(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
2288
2185
  return;
2289
2186
  }
2187
+ const record = manager.getRecord(id)!;
2188
+ // Stop polling when the record settles (stopped/error) or leaves the
2189
+ // manager entirely — dispose() clears the map without touching the
2190
+ // status, so the identity check keeps this from spinning forever.
2191
+ while (manager.getRecord(id) === record && record.status === "queued") {
2192
+ await new Promise((r) => setTimeout(r, WIZARD_QUEUE_POLL_MS));
2193
+ }
2194
+ // The record left the manager without settling (a dispose() that clears
2195
+ // the map without aborting first): the run never started, so report the
2196
+ // cancellation instead of toasting a completed-then-missing file. Real
2197
+ // teardown aborts before it disposes — this keeps a dispose-without-abort
2198
+ // eviction from falling through to the created/not-created toast.
2199
+ if (manager.getRecord(id) !== record) {
2200
+ ctx.ui.notify("Generation cancelled.", "info");
2201
+ return;
2202
+ }
2203
+ await record.promise; // undefined when stopped-while-queued → resolves
2204
+ if (record.status === "error") { ctx.ui.notify(`Generation failed: ${toSingleLine(record.error)}`, "warning"); return; }
2205
+ if (record.status === "stopped") { ctx.ui.notify("Generation cancelled.", "info"); return; }
2290
2206
 
2291
2207
  reloadCustomAgents();
2292
2208
 
2293
2209
  if (existsSync(targetPath)) {
2294
- ctx.ui.notify(`Created ${targetPath}`, "info");
2210
+ ctx.ui.notify(`Created ${toSingleLine(targetPath)}`, "info");
2295
2211
  } else {
2296
2212
  ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
2297
2213
  }
@@ -2368,14 +2284,14 @@ ${systemPrompt}
2368
2284
  const targetPath = join(targetDir, `${name}.md`);
2369
2285
 
2370
2286
  if (existsSync(targetPath)) {
2371
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
2287
+ const overwrite = await ctx.ui.confirm("Overwrite", `${toSingleLine(targetPath)} already exists. Overwrite?`);
2372
2288
  if (!overwrite) return;
2373
2289
  }
2374
2290
 
2375
2291
  const { writeFileSync } = await import("node:fs");
2376
2292
  writeFileSync(targetPath, content, "utf-8");
2377
2293
  reloadCustomAgents();
2378
- ctx.ui.notify(`Created ${targetPath}`, "info");
2294
+ ctx.ui.notify(`Created ${toSingleLine(targetPath)}`, "info");
2379
2295
  }
2380
2296
 
2381
2297
  function snapshotSettings(): SubagentsSettings {
@@ -2385,27 +2301,51 @@ ${systemPrompt}
2385
2301
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
2386
2302
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
2387
2303
  graceTurns: getGraceTurns(),
2388
- defaultJoinMode: getDefaultJoinMode(),
2304
+ maxSubagentDepth: getMaxSubagentDepth(),
2389
2305
  schedulingEnabled: isSchedulingEnabled(),
2390
2306
  scopeModels: isScopeModelsEnabled(),
2391
- resultPreviewMode,
2392
- resultPreviewExpanded,
2307
+ strictAgentFiles,
2393
2308
  failurePreviewMaxChars,
2394
2309
  disableDefaultAgents: isDefaultsDisabled(),
2395
2310
  toolDescriptionMode: getToolDescriptionMode(),
2396
2311
  fleetView: isFleetViewEnabled(),
2397
2312
  widgetMode: getWidgetMode(),
2398
2313
  outputTranscript: getOutputTranscriptDefault(),
2314
+ worktreeIsolation: isWorktreeIsolationEnabled(),
2315
+ // Deliberately NOT `?? "general-purpose"`: every settings change writes the
2316
+ // whole snapshot, and materializing the implicit default would turn it into
2317
+ // explicit configuration — which then fails loudly if general-purpose later
2318
+ // goes away. undefined is dropped by JSON.stringify.
2319
+ fallbackSubagent: getFallbackSubagent(),
2320
+ reportUsage: isReportUsageEnabled(),
2321
+ showCost: isShowCostEnabled(),
2322
+ showModel: isShowModelEnabled(),
2399
2323
  };
2400
2324
  }
2401
2325
 
2402
- const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "failurePreviewMaxChars"]);
2326
+ const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "maxSubagentDepth", "failurePreviewMaxChars"]);
2403
2327
 
2404
2328
  async function showSettings(ctx: ExtensionCommandContext) {
2405
2329
  function buildItems(): SettingItem[] {
2406
2330
  const mc = manager.getMaxConcurrent();
2407
2331
  const dmt = getDefaultMaxTurns() ?? 0;
2408
2332
  const gt = getGraceTurns();
2333
+ const msd = getMaxSubagentDepth();
2334
+ // Label what unset actually does — it targets general-purpose even when
2335
+ // that is unregistered (the permissive hardcoded tier), so showing "none"
2336
+ // there would advertise strict dispatch for the most permissive state.
2337
+ // `values` still offers only resolvable targets, so the user cannot
2338
+ // persist a fallback that would hard-error on every dispatch.
2339
+ // The row must never show a value that is absent from `values`: pi-tui's
2340
+ // cycle handler computes `values.indexOf(currentValue)` and writes
2341
+ // `values[0]` on -1, so a missing current value turns one Enter into a
2342
+ // silent rewrite to whatever happens to be first — a custom agent, once the
2343
+ // defaults are disabled. Including the current value makes Enter mean
2344
+ // "cycle from what is shown", which is what every other row means.
2345
+ const fallback = fallbackRow(getFallbackSubagent(), getAvailableTypes(), NO_FALLBACK, isFallbackResolvable);
2346
+ const fallbackValue = fallback.value;
2347
+ const fallbackValues = fallback.values;
2348
+ const fallbackUnresolvable = fallback.unresolvable;
2409
2349
 
2410
2350
  return [
2411
2351
  {
@@ -2430,11 +2370,11 @@ ${systemPrompt}
2430
2370
  values: [String(gt)],
2431
2371
  },
2432
2372
  {
2433
- id: "joinMode",
2434
- label: "Join mode",
2435
- description: "Default join mode for background agents",
2436
- currentValue: getDefaultJoinMode(),
2437
- values: ["smart", "async", "group"],
2373
+ id: "maxSubagentDepth",
2374
+ label: "Nested depth",
2375
+ description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
2376
+ currentValue: String(msd),
2377
+ values: [String(msd)],
2438
2378
  },
2439
2379
  {
2440
2380
  id: "schedulingEnabled",
@@ -2451,17 +2391,10 @@ ${systemPrompt}
2451
2391
  values: ["on", "off"],
2452
2392
  },
2453
2393
  {
2454
- id: "resultPreviewMode",
2455
- label: "Result preview mode",
2456
- description: "Render result body as markdown or plain text",
2457
- currentValue: resultPreviewMode,
2458
- values: ["markdown", "plain"],
2459
- },
2460
- {
2461
- id: "resultPreviewExpanded",
2462
- label: "Result preview expanded by default",
2463
- description: "Always show expanded result preview, ignoring pi's expanded flag",
2464
- currentValue: resultPreviewExpanded ? "on" : "off",
2394
+ id: "strictAgentFiles",
2395
+ label: "Strict agent files",
2396
+ description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
2397
+ currentValue: strictAgentFiles ? "on" : "off",
2465
2398
  values: ["on", "off"],
2466
2399
  },
2467
2400
  {
@@ -2485,6 +2418,49 @@ ${systemPrompt}
2485
2418
  currentValue: getOutputTranscriptDefault() ? "on" : "off",
2486
2419
  values: ["on", "off"],
2487
2420
  },
2421
+ {
2422
+ id: "worktreeIsolation",
2423
+ label: "Worktree isolation",
2424
+ description:
2425
+ "Allow isolation: worktree to copy the repo. Off refuses worktrees on every path immediately — for repos where a copy costs too much time or disk — and drops the `isolation` param from the Agent tool spec on next pi session.",
2426
+ currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
2427
+ values: ["on", "off"],
2428
+ },
2429
+ {
2430
+ id: "fallbackSubagent",
2431
+ label: "Fallback agent",
2432
+ description:
2433
+ `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)` +
2434
+ (fallbackUnresolvable
2435
+ ? " — currently unresolvable: that agent is deleted, disabled, or a default with Disable defaults on, so unknown types are refused"
2436
+ : ""),
2437
+ currentValue: fallbackValue,
2438
+ values: fallbackValues,
2439
+ },
2440
+ {
2441
+ id: "reportUsage",
2442
+ label: "Report usage to session",
2443
+ description:
2444
+ "Add subagent tokens and cost to this session's own totals, so pi's footer and /cost stop reading a delegating session as nearly free. Reported on the next tool result (agents that finish in the background are counted on the one after). Context-window % is unaffected.",
2445
+ currentValue: isReportUsageEnabled() ? "on" : "off",
2446
+ values: ["on", "off"],
2447
+ },
2448
+ {
2449
+ id: "showCost",
2450
+ label: "Show cost",
2451
+ description:
2452
+ "Show an estimated `~$0.0042` beside subagent token counts in the widget, fleet view, conversation viewer, results and notifications. Priced by pi from the model's rates — omitted entirely for a model it has no rates for.",
2453
+ currentValue: isShowCostEnabled() ? "on" : "off",
2454
+ values: ["on", "off"],
2455
+ },
2456
+ {
2457
+ id: "showModel",
2458
+ label: "Show model",
2459
+ description:
2460
+ "Name the model driving each agent, and the thinking level it is running at, on the widget's running rows. The conversation viewer shows the pair either way — this adds it to the widget, where the row is already dense.",
2461
+ currentValue: isShowModelEnabled() ? "on" : "off",
2462
+ values: ["on", "off"],
2463
+ },
2488
2464
  {
2489
2465
  id: "fleetView",
2490
2466
  label: "Fleet view",
@@ -2495,9 +2471,9 @@ ${systemPrompt}
2495
2471
  {
2496
2472
  id: "widgetMode",
2497
2473
  label: "Widget",
2498
- description: "Above-editor agent widget: all = every agent; background = hide foreground (they already render inline); off = hide the widget.",
2474
+ description: "Above-editor agent widget: all = every agent; off = hide the widget.",
2499
2475
  currentValue: getWidgetMode(),
2500
- values: ["all", "background", "off"],
2476
+ values: ["all", "off"],
2501
2477
  },
2502
2478
  {
2503
2479
  id: "toolDescriptionMode",
@@ -2531,9 +2507,17 @@ ${systemPrompt}
2531
2507
  setGraceTurns(n);
2532
2508
  notifyApplied(ctx, `Grace turns set to ${n}`);
2533
2509
  }
2534
- } else if (id === "joinMode") {
2535
- setDefaultJoinMode(value as JoinMode);
2536
- notifyApplied(ctx, `Default join mode set to ${value}`);
2510
+ } else if (id === "maxSubagentDepth") {
2511
+ const n = parseInt(value, 10);
2512
+ if (n >= 0) {
2513
+ setMaxSubagentDepth(n);
2514
+ notifyApplied(
2515
+ ctx,
2516
+ n <= 1
2517
+ ? "Nested delegation disabled"
2518
+ : `Nested depth set to ${n}. Applies to agents started from now on.`,
2519
+ );
2520
+ }
2537
2521
  } else if (id === "schedulingEnabled") {
2538
2522
  const enabled = value === "on";
2539
2523
  if (enabled === isSchedulingEnabled()) {
@@ -2550,30 +2534,83 @@ ${systemPrompt}
2550
2534
  const enabled = value === "on";
2551
2535
  setScopeModelsEnabled(enabled);
2552
2536
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
2553
- } else if (id === "resultPreviewMode") {
2554
- setResultPreviewMode(value as ResultPreviewMode);
2555
- notifyApplied(ctx, `Result preview mode set to ${value}`);
2556
- } else if (id === "resultPreviewExpanded") {
2557
- const expanded = value === "on";
2558
- setResultPreviewExpanded(expanded);
2559
- notifyApplied(ctx, `Result preview expanded by default ${expanded ? "enabled" : "disabled"}`);
2537
+ } else if (id === "strictAgentFiles") {
2538
+ const enabled = value === "on";
2539
+ strictAgentFiles = enabled;
2540
+ notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
2541
+ } else if (id === "worktreeIsolation") {
2542
+ const enabled = value === "on";
2543
+ setWorktreeIsolationEnabled(enabled);
2544
+ // The refusal is live, but the tool schema is built at registration, so
2545
+ // the isolation parameter only appears/disappears next session.
2546
+ notifyApplied(
2547
+ ctx,
2548
+ `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
2549
+ );
2550
+ } else if (id === "fallbackSubagent") {
2551
+ // The row's value list is built when the menu opens, so a toggle in the
2552
+ // same menu (Disable defaults) can leave it stale. Persisting a fallback
2553
+ // that dispatch would then refuse is the one outcome to avoid: keep the
2554
+ // current value and say why, rather than writing a broken one.
2555
+ if (fallbackRow(value, getAvailableTypes(), NO_FALLBACK, isFallbackResolvable).unresolvable) {
2556
+ // pi-tui has already written the refused value into the row, so put the
2557
+ // real one back or the next Enter cycles past a value the user can no
2558
+ // longer select.
2559
+ list?.updateValue("fallbackSubagent", getFallbackSubagent() ?? IMPLICIT_FALLBACK);
2560
+ ctx.ui.notify(`Agent "${value}" is not available — fallback unchanged.`, "warning");
2561
+ return;
2562
+ }
2563
+ setFallbackSubagent(value);
2564
+ notifyApplied(
2565
+ ctx,
2566
+ value === NO_FALLBACK
2567
+ ? "Unknown or disabled agent types will now be rejected"
2568
+ : `Unknown agent types will fall back to ${value}`,
2569
+ );
2560
2570
  } else if (id === "failurePreviewMaxChars") {
2561
2571
  const n = parseInt(value, 10);
2562
- if (n >= 1 && n <= RESULT_PREVIEW_MAX_CHARS_CEILING) {
2572
+ if (n >= 1 && n <= FAILURE_PREVIEW_MAX_CHARS_CEILING) {
2563
2573
  setFailurePreviewMaxChars(n);
2564
2574
  notifyApplied(ctx, `Failure preview max chars set to ${n}`);
2565
2575
  }
2566
2576
  } else if (id === "disableDefaultAgents") {
2567
2577
  const enabled = value === "on";
2578
+ // The setter re-registers the registry itself, so the next spawn already
2579
+ // sees the toggle; this applier only has to report what it orphaned.
2568
2580
  setDisableDefaultAgents(enabled);
2569
- notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
2581
+ const orphaned = fallbackRow(getFallbackSubagent(), getAvailableTypes(), NO_FALLBACK, isFallbackResolvable)
2582
+ .unresolvable;
2583
+ notifyApplied(
2584
+ ctx,
2585
+ `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.` +
2586
+ (orphaned
2587
+ ? ` Fallback agent "${getFallbackSubagent()}" no longer resolves — unknown agent types will be refused until it is changed.`
2588
+ : ""),
2589
+ );
2570
2590
  } else if (id === "outputTranscript") {
2571
2591
  const enabled = value === "on";
2572
- setOutputTranscript(enabled);
2592
+ setOutputTranscriptDefault(enabled);
2573
2593
  notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
2574
2594
  } else if (id === "toolDescriptionMode") {
2575
2595
  setToolDescriptionMode(value as ToolDescriptionMode);
2576
2596
  notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
2597
+ } else if (id === "reportUsage") {
2598
+ const enabled = value === "on";
2599
+ setReportUsage(enabled);
2600
+ notifyApplied(
2601
+ ctx,
2602
+ enabled
2603
+ ? "Subagent usage now counted in this session's totals"
2604
+ : "Subagent usage no longer counted in this session's totals",
2605
+ );
2606
+ } else if (id === "showCost") {
2607
+ const enabled = value === "on";
2608
+ setShowCost(enabled);
2609
+ notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
2610
+ } else if (id === "showModel") {
2611
+ const enabled = value === "on";
2612
+ setShowModel(enabled);
2613
+ notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
2577
2614
  } else if (id === "fleetView") {
2578
2615
  const enabled = value === "on";
2579
2616
  setFleetViewEnabled(enabled);
@@ -2584,7 +2621,7 @@ ${systemPrompt}
2584
2621
  }
2585
2622
  }
2586
2623
 
2587
- let list: SettingsList;
2624
+ let list: SettingsList | undefined;
2588
2625
  // Track current selection index directly (SettingsList doesn't expose it).
2589
2626
  // Updated on arrow keys so Enter knows which field is selected immediately.
2590
2627
  let currentIndex = 0;
@@ -2605,7 +2642,7 @@ ${systemPrompt}
2605
2642
  const container = new Container();
2606
2643
  container.addChild(new Text("⚙ Subagent Settings", 0, 0));
2607
2644
  container.addChild(new Spacer(1));
2608
- container.addChild(list);
2645
+ if (list) container.addChild(list);
2609
2646
 
2610
2647
  return {
2611
2648
  render: (w: number) => container.render(w),
@@ -2623,24 +2660,21 @@ ${systemPrompt}
2623
2660
  done(items[currentIndex].id);
2624
2661
  return;
2625
2662
  }
2626
- list.handleInput?.(data);
2663
+ list?.handleInput?.(data);
2627
2664
  },
2628
2665
  };
2629
2666
  });
2630
2667
 
2631
2668
  // If a numeric field ID was returned, prompt for typed input
2632
2669
  if (result && NUMERIC_IDS.has(result)) {
2633
- const current = result === "maxConcurrent"
2634
- ? String(manager.getMaxConcurrent())
2635
- : result === "defaultMaxTurns"
2636
- ? String(getDefaultMaxTurns() ?? 0)
2637
- : String(getGraceTurns());
2638
-
2639
- const label = result === "maxConcurrent"
2640
- ? "Max concurrency (1+)"
2641
- : result === "defaultMaxTurns"
2642
- ? "Default max turns (0 = unlimited)"
2643
- : "Grace turns (1+)";
2670
+ const numericPrompt: Record<string, { current: string; label: string }> = {
2671
+ maxConcurrent: { current: String(manager.getMaxConcurrent()), label: "Max concurrency (1+)" },
2672
+ defaultMaxTurns: { current: String(getDefaultMaxTurns() ?? 0), label: "Default max turns (0 = unlimited)" },
2673
+ graceTurns: { current: String(getGraceTurns()), label: "Grace turns (1+)" },
2674
+ maxSubagentDepth: { current: String(getMaxSubagentDepth()), label: "Nested depth (0/1 = nesting off)" },
2675
+ failurePreviewMaxChars: { current: String(failurePreviewMaxChars), label: "Failure preview max chars (1+)" },
2676
+ };
2677
+ const { current, label } = numericPrompt[result];
2644
2678
 
2645
2679
  // Loop until user enters a valid integer or cancels (Esc / null).
2646
2680
  // Silently trims whitespace; rejects non-numeric input by re-prompting.