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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
package/src/index.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Tools:
5
5
  * Agent — LLM-callable: spawn a sub-agent
6
6
  * get_subagent_result — LLM-callable: check background agent status/result
7
- * steer_subagent — LLM-callable: send a steering message to a running agent
7
+ * steer_subagent — LLM-callable: steer a running agent, or resume a finished one
8
+ * stop_subagent — LLM-callable: stop a running or queued agent, or a workflow run
8
9
  *
9
10
  * Commands:
10
11
  * /agents — Interactive agent management menu
@@ -12,14 +13,14 @@
12
13
 
13
14
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
15
  import { isAbsolute, join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
17
  import { Container, isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
18
  import { nanoid } from "nanoid";
18
19
  import { Type } from "typebox";
19
20
  import { abortable } from "./abortable.js";
20
21
  import { hasAgentBadge, renderAgentName } from "./agent-color.js";
21
- import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
22
- import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.js";
22
+ import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile as locateAgentFileAtHome, personalAgentsDir as personalAgentsDirAtHome, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
23
+ import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed, type OwnedPending } from "./agent-manager.js";
23
24
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
24
25
  import { rowDescription, sourceIndicator, sourceLegend } from "./agent-source-badge.js";
25
26
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
@@ -31,6 +32,7 @@ import { loadCustomAgents } from "./custom-agents.js";
31
32
  import {
32
33
  type DetachedResult,
33
34
  drainDetached,
35
+ listAllRunningDetached,
34
36
  listRunningDetached,
35
37
  registerRunningDetached,
36
38
  spoolDetached,
@@ -38,24 +40,30 @@ import {
38
40
  watchDetached,
39
41
  } from "./detached-results.js";
40
42
  import { readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
43
+ import { errorMessage } from "./error-message.js";
41
44
  import { GroupJoinManager } from "./group-join.js";
42
- import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam, resolveJoinMode } from "./invocation-config.js";
45
+ import { resolveAgentDir } from "./home-paths.js";
46
+ import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam } from "./invocation-config.js";
43
47
  import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
44
48
  import { runMentionClone } from "./mention-clone.js";
45
49
  import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
46
- import { describeModel, describeVirtualModel, isVirtualModel, type ModelRegistry, resolveModel, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
47
- import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
50
+ import { type ModelFallback, type ModelRegistry, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
51
+ import { isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
52
+ import { admitModelSelection, describeSelection, modelIdentityNote, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
53
+ import { configuredTierNames, tierDisclosure } from "./model-tiers.js";
48
54
  import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
49
- import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
55
+ import { clipNotice, DEFAULT_NOTIFICATION_RESULT_CHARS, fairShares, GROUP_PREVIEW_RESULT_CHARS, NOTIFICATION_RESULT_CHARS_CEILING, type NoticeFrame, type NoticeParts, PREVIEW_RESULT_CHARS, previewResult, renderNotice } from "./notification-result.js";
56
+ import { appendModelFallbackEntry, appendModelTierEntry, createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
50
57
  import { getPackageAgentsGate, getPackageWorkflowsGate, invalidatePackageCache, isProjectTrusted, packageNameForPath, setPackageAgentsGate, setPackageWorkflowsGate, setProjectTrusted } from "./package-resources.js";
58
+ import { answerPlanGateQueries, PLAN_PARK_REFUSAL, PLAN_SCHEDULE_REFUSAL, planningNow, planSteerBlocked, planSteerRefusal } from "./plan-mode-children.js";
51
59
  import { getBudgetPolicy, setBudgetPolicy } from "./process-budget.js";
52
60
  import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
53
61
  import { SubagentScheduler } from "./schedule.js";
54
62
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
55
63
  import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
56
64
  import { hostStartupErrorClass } from "./startup-error.js";
57
- import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
58
- import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
65
+ import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix, resumeHint } from "./status-note.js";
66
+ import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type AgentTombstone, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
59
67
  import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
60
68
  import { AgentCallView, AgentResultView, type AgentRowLive, keepRowTicking, keepTicking } from "./ui/agent-row.js";
61
69
  import {
@@ -75,6 +83,7 @@ import {
75
83
  } from "./ui/agent-widget.js";
76
84
  import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./ui/conversation-viewer.js";
77
85
  import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
86
+ import { FLEET_STATE_ENTRY, FLEET_STATE_VERSION } from "./ui/fleet-state.js";
78
87
  import { renderNotification } from "./ui/notification-view.js";
79
88
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
80
89
  import { selectItem } from "./ui/select-item.js";
@@ -111,7 +120,7 @@ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
111
120
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
112
121
  * Used by both foreground and background paths to avoid duplication.
113
122
  */
114
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, clock: Clock = realClock) {
123
+ function createActivityTracker(maxTurns: number | undefined, onStreamUpdate: (() => void) | undefined, clock: Clock) {
115
124
  const state: AgentActivity = {
116
125
  activeTools: new Map(),
117
126
  toolUses: 0,
@@ -167,6 +176,11 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, c
167
176
  */
168
177
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
169
178
 
179
+ /** Run time of an agent: frozen at `completedAt` once settled, else measured to `now`. */
180
+ function recordElapsedMs(record: { startedAt: number; completedAt?: number }, now: number): number {
181
+ return (record.completedAt ?? now) - record.startedAt;
182
+ }
183
+
170
184
  /** Human-readable status label for agent completion. */
171
185
  function getStatusLabel(status: string, error?: string): string {
172
186
  switch (status) {
@@ -178,10 +192,13 @@ function getStatusLabel(status: string, error?: string): string {
178
192
  }
179
193
  }
180
194
 
181
- /** Format a structured task notification matching Claude Code's <task-notification> XML. */
182
- function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showCost = false): string {
195
+ /**
196
+ * Format a structured task notification matching Claude Code's <task-notification> XML.
197
+ * Everything but the `<result>` is read now; `renderNotice` fills that in.
198
+ */
199
+ function formatTaskNotification(record: AgentRecord, showCost: boolean): NoticeFrame {
183
200
  const status = getStatusLabel(record.status, record.error);
184
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
201
+ const durationMs = recordElapsedMs(record, Date.now());
185
202
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
186
203
  const contextPercent = getSessionContextPercent(record.session);
187
204
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
@@ -191,28 +208,34 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
191
208
  const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
192
209
  const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
193
210
 
194
- const resultPreview = record.result
195
- ? record.result.length > resultMaxLen
196
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
197
- : record.result
198
- : "No output.";
211
+ const modelNote = [modelSelectionNote(record), modelIdentityNote(record)].filter(Boolean).join("\n");
212
+ const hint = resumeHint(record);
199
213
 
200
- return [
214
+ const head = [
201
215
  `<task-notification>`,
202
216
  `<task-id>${record.id}</task-id>`,
203
217
  record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
204
218
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
205
219
  `<status>${escapeXml(status)}</status>`,
206
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
207
- `<result>${escapeXml(resultPreview)}</result>`,
220
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status, record.stoppedBy)}${hint ? ` (${hint})` : ""}</summary>`,
221
+ modelNote ? `<model-note>${escapeXml(modelNote)}</model-note>` : null,
222
+ ].filter(Boolean).join('\n');
223
+ const tail = [
208
224
  `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
209
225
  `</task-notification>`,
210
- ].filter(Boolean).join('\n');
226
+ ].join('\n');
227
+ return { head, tail };
228
+ }
229
+
230
+ /** What a full-result notice carries: the same text `get_subagent_result` returns for a finished agent. */
231
+ function noticeBody(record: AgentRecord): string {
232
+ if (record.status === "error") return `Error: ${record.error ?? "unknown"}${partialOutputSuffix(record)}`;
233
+ return record.result?.trim() || "No output.";
211
234
  }
212
235
 
213
236
  /** Build AgentDetails from a base + record-specific fields. */
214
237
  function buildDetails(
215
- base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
238
+ base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "modelNote" | "modelFallback" | "modelTier" | "tags">,
216
239
  record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; outputFile?: string; lifetimeUsage: LifetimeUsage },
217
240
  activity?: AgentActivity,
218
241
  overrides?: Partial<AgentDetails>,
@@ -227,7 +250,7 @@ function buildDetails(
227
250
  cost: getLifetimeCost(record.lifetimeUsage),
228
251
  turnCount: activity?.turnCount,
229
252
  maxTurns: activity?.maxTurns,
230
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
253
+ durationMs: recordElapsedMs(record, Date.now()),
231
254
  status: record.status as AgentDetails["status"],
232
255
  agentId: record.id,
233
256
  error: record.error,
@@ -237,6 +260,31 @@ function buildDetails(
237
260
  }
238
261
 
239
262
  /** Build notification details for the custom message renderer. */
263
+
264
+ /**
265
+ * The settle report for owned agents still pending, or `undefined` when none
266
+ * are. Every package's settle notice shares one shape,
267
+ * `Run ended with N <work>, armed: <what wakes the parent>`, and names only
268
+ * its own work, so one package saying nothing of its own is armed cannot read
269
+ * as "nothing at all is armed" beside another's armed monitor.
270
+ *
271
+ * A background agent's completion notice starts a parent turn in every join
272
+ * mode. Two edges qualify that: after a session switch the notice is spooled
273
+ * for the session it belongs to and lands when that session is live again, and
274
+ * a held send that outlives a tearing-down `pi -p` is dropped. Foreground
275
+ * agents and RPC spawns without `isBackground` are marked consumed at settle
276
+ * and notify nobody, so they are named as not armed.
277
+ */
278
+ function formatOwnedPendingNotice({ notifying, silent }: OwnedPending): string | undefined {
279
+ const total = notifying + silent;
280
+ if (total === 0) return undefined;
281
+ const plural = (n: number) => (n === 1 ? "" : "s");
282
+ const armed = notifying > 0
283
+ ? `${notifying} agent completion notice${plural(notifying)} ${notifying === 1 ? "wakes" : "wake"} the parent`
284
+ : "no agent completion notice wakes the parent";
285
+ const unarmed = silent > 0 ? `; ${silent} foreground or RPC-spawned agent${plural(silent)} will not` : "";
286
+ return `Run ended with ${total} agent${plural(total)} running or queued, armed: ${armed}${unarmed}`;
287
+ }
240
288
  function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
241
289
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
242
290
 
@@ -252,9 +300,12 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
252
300
  // data, and a notification rendered before a mid-session toggle should not
253
301
  // be stuck with the old answer.
254
302
  totalCost: getLifetimeCost(record.lifetimeUsage),
255
- durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
303
+ durationMs: recordElapsedMs(record, Date.now()),
256
304
  outputFile: record.outputFile,
257
305
  error: record.error,
306
+ resumeHint: resumeHint(record) || undefined,
307
+ modelNote: modelSelectionNote(record) || undefined,
308
+ modelTier: record.modelTier,
258
309
  resultPreview: record.result
259
310
  ? record.result.length > resultMaxLen
260
311
  ? record.result.slice(0, resultMaxLen) + "…"
@@ -287,12 +338,18 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
287
338
  * agents and those with `extensions: false`.
288
339
  */
289
340
  export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
290
- const tools = cfg?.builtinToolNames;
341
+ const denied = cfg?.disallowedTools ?? [];
342
+ const tools = denied.length > 0
343
+ ? (cfg?.builtinToolNames ?? BUILTIN_TOOL_NAMES).filter((tool) => !denied.includes(tool))
344
+ : cfg?.builtinToolNames;
345
+ // Only claim what is known at registration: extensions may supply command
346
+ // tools (including ssh_exec), so missing bash is not proof of no remote access.
347
+ const shellNote = tools && !tools.includes("bash") ? "; no built-in shell: cannot run bash commands" : "";
291
348
  // `isolated` overrides extensions to false in the runner, so both mean the
292
349
  // agent has no extension tools.
293
350
  const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
294
351
  if (tools && tools.length === 0) {
295
- return noExtensionTools ? "none" : "no built-ins, extension tools only";
352
+ return (noExtensionTools ? "none" : "no built-ins, extension tools only") + shellNote;
296
353
  }
297
354
  const isFullSet = !tools
298
355
  || (tools.length === BUILTIN_TOOL_NAMES.length && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t)));
@@ -300,7 +357,7 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
300
357
  // Extension tools resolve at run time, so they cannot be enumerated here, but
301
358
  // omitting the fact reads as "built-ins only": an agent that can search the
302
359
  // web through an extension then looks web-less, and the work gets routed away.
303
- return noExtensionTools ? builtins : `${builtins} + extension tools`;
360
+ return (noExtensionTools ? builtins : `${builtins} + extension tools`) + shellNote;
304
361
  }
305
362
 
306
363
  /** CLI flag that runs a workflow script at session start. */
@@ -320,6 +377,8 @@ export type { Clock, TimerHandle } from "./clock.js";
320
377
 
321
378
  /** Optional host dependencies, shared by the scheduler and completion delivery. */
322
379
  export interface SubagentsExtensionOptions {
380
+ /** Home for every user-scoped read and child run. Omitted uses host defaults at call time. */
381
+ homeDir?: string;
323
382
  clock?: Clock;
324
383
  }
325
384
 
@@ -332,6 +391,10 @@ export default createSubagentsExtension();
332
391
 
333
392
  function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions) {
334
393
  const clock = options.clock ?? realClock;
394
+ const getAgentDir = () => resolveAgentDir(options.homeDir);
395
+ const personalAgentsDir = () => personalAgentsDirAtHome(options.homeDir);
396
+ const locateAgentFile: typeof locateAgentFileAtHome = (name, path, cwd, source) =>
397
+ locateAgentFileAtHome(name, path, cwd, source, options.homeDir);
335
398
  // Child AgentSessions load normal extensions. Re-entering this extension there
336
399
  // would create another manager and leak handlers. Nested orchestration is
337
400
  // injected as scoped custom tools by the existing manager instead.
@@ -345,7 +408,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
345
408
  try {
346
409
  getBudgetPolicy();
347
410
  } catch (error) {
348
- const message = error instanceof Error ? error.message : String(error);
411
+ const message = errorMessage(error);
349
412
  if (!StartupError) throw new Error(message + " This host also lacks ExtensionStartupError; upgrade pi before retrying.", { cause: error });
350
413
  pi.on("session_start", async () => { throw new StartupError(message, { cause: error }); });
351
414
  return;
@@ -383,11 +446,15 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
383
446
  });
384
447
 
385
448
  // Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
386
- setModelScopeProvider(registry => resolveEnabledModels(readEnabledModels(process.cwd()), registry, process.cwd()));
449
+ setModelScopeProvider((registry, context) => {
450
+ const cwd = context?.cwd ?? process.cwd();
451
+ const homeDir = context?.homeDir ?? options.homeDir;
452
+ return resolveEnabledModels(readEnabledModels(cwd, homeDir), registry, cwd, homeDir);
453
+ });
387
454
 
388
455
  // Read directly rather than waiting for applyAndEmitLoaded below: these decide
389
456
  // the initial load, which happens hundreds of lines before settings are applied.
390
- const bootSettings = loadSettings(process.cwd());
457
+ const bootSettings = loadSettings(process.cwd(), options.homeDir);
391
458
  let strictAgentFiles = bootSettings.strictAgentFiles === true;
392
459
  // Strict agent files fail a bind by throwing the host's `ExtensionStartupError`
393
460
  // from `session_start`. A host without that class would silently degrade to
@@ -412,7 +479,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
412
479
 
413
480
  /** Reload agents from package/global/project agent dirs and merge with defaults (called on init and each Agent invocation). */
414
481
  const reloadCustomAgents = (strict = false) => {
415
- const userAgents = loadCustomAgents(process.cwd(), strict);
482
+ const userAgents = loadCustomAgents(process.cwd(), strict, { homeDir: options.homeDir });
416
483
  registerAgents(userAgents);
417
484
  };
418
485
 
@@ -464,6 +531,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
464
531
  persistSettings(ctx, `Viewer markdown set to ${mode}`);
465
532
  }
466
533
  const pendingUsage = new PendingUsagePool();
534
+ /** Result characters a completion notice carries per agent; `0` keeps the preview (see notification-result.ts). */
535
+ let notificationResultChars = DEFAULT_NOTIFICATION_RESULT_CHARS;
467
536
 
468
537
  // ---- Completion delivery ----
469
538
  // Every completion notification goes through the outbox, which sends what is
@@ -471,15 +540,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
471
540
  // completion-outbox.ts). Each is built when the agent finishes, and reading
472
541
  // the result (get_subagent_result, rpc consume) cancels it before it is sent.
473
542
  const outbox = new CompletionOutbox(
474
- (message) => pi.sendMessage<NotificationDetails>({
475
- customType: "subagent-notification",
476
- content: message.content,
477
- display: true,
478
- details: message.details,
479
- }, { deliverAs: "steer", triggerTurn: true }),
543
+ (message) => {
544
+ pi.sendMessage<NotificationDetails>({
545
+ customType: "subagent-notification",
546
+ content: message.content,
547
+ display: true,
548
+ details: message.details,
549
+ }, { deliverAs: "steer", triggerTurn: true });
550
+ for (const id of message.noticed ?? []) {
551
+ const record = manager.getRecord(id);
552
+ // A resume started since: that run's notice says nothing about this one.
553
+ if (record && record.status !== "running" && record.status !== "queued") record.noticeDelivered = true;
554
+ }
555
+ // The whole result reached the parent, as get_subagent_result would hand it over.
556
+ for (const id of message.delivered ?? []) {
557
+ const record = manager.getRecord(id);
558
+ // A resume started since: that run's result is still unread.
559
+ if (!record || record.status === "running" || record.status === "queued") continue;
560
+ markConsumed(record, clock.now());
561
+ }
562
+ },
480
563
  () => currentCtx?.signal !== undefined,
481
564
  options.clock,
565
+ (chars) => {
566
+ if (notificationResultChars === 0) return {};
567
+ const ids = Object.keys(chars);
568
+ const shares = fairShares(ids.map(id => chars[id]), notificationResultChars);
569
+ return Object.fromEntries(ids.map((id, i) => [id, shares[i]]));
570
+ },
482
571
  );
572
+
573
+ /**
574
+ * `record`'s notice, read now so a resume before the send cannot rewrite it.
575
+ * `render` fills in the result clipped to the share the batch allots it, or
576
+ * the `notificationResultChars: 0` preview of `previewChars`.
577
+ */
578
+ function snapshotNotice(record: AgentRecord, previewChars: number) {
579
+ const frame = formatTaskNotification(record, showCost);
580
+ const details = buildNotificationDetails(record, previewChars, agentActivity.get(record.id));
581
+ if (notificationResultChars === 0) {
582
+ // A preview never counts as the whole result, even a short one: reap notices stay on.
583
+ const text = renderNotice(frame, record.result ? previewResult(record.result, previewChars) : "No output.");
584
+ return { id: record.id, details, chars: undefined, parts: undefined, render: (_share?: number) => ({ text, delivered: false, complete: false }) };
585
+ }
586
+ const parts: NoticeParts = {
587
+ ...frame, body: noticeBody(record), cap: notificationResultChars, outputFile: record.outputFile,
588
+ // A failure stays unread: delivering its notice must not acknowledge it in FleetView (README, FleetView).
589
+ failed: record.status === "error" || record.status === "aborted",
590
+ };
591
+ return { id: record.id, details, chars: parts.body.length, parts, render: (share?: number) => clipNotice(parts, share) };
592
+ }
593
+
594
+ /** Full result lengths of the notices that have one, for the batch budget. */
595
+ function resultCharsOf(notices: readonly ReturnType<typeof snapshotNotice>[]): Record<string, number> | undefined {
596
+ const withChars = notices.filter(n => n.chars !== undefined);
597
+ return withChars.length > 0 ? Object.fromEntries(withChars.map(n => [n.id, n.chars])) : undefined;
598
+ }
483
599
  // A queued result wait must observe completion before its held notification
484
600
  // can fire, so successful waits can still suppress that redundant nudge.
485
601
  const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
@@ -492,11 +608,19 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
492
608
  // cannot be formatted is skipped, as a failed send always was.
493
609
  try {
494
610
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
495
- const message = {
496
- content: formatTaskNotification(record, 500, showCost) + footer,
497
- details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
498
- };
499
- outbox.add({ ids: [record.id], build: () => message });
611
+ const notice = snapshotNotice(record, PREVIEW_RESULT_CHARS);
612
+ outbox.add({
613
+ ids: [record.id],
614
+ resultChars: resultCharsOf([notice]),
615
+ build: (_ids, caps) => {
616
+ const { text, delivered, complete } = notice.render(caps[notice.id]);
617
+ return {
618
+ content: text + footer, details: notice.details,
619
+ ...(delivered ? { delivered: [notice.id] } : {}),
620
+ ...(complete ? { noticed: [notice.id] } : {}),
621
+ };
622
+ },
623
+ });
500
624
  } catch { /* ignore stale completion side-effect errors */ }
501
625
  widget.update();
502
626
  }
@@ -508,15 +632,21 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
508
632
 
509
633
  const members = records.filter(r => !r.resultConsumed).flatMap(r => {
510
634
  try {
511
- return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
635
+ return [snapshotNotice(r, GROUP_PREVIEW_RESULT_CHARS)];
512
636
  } catch { return []; /* as in sendIndividualNudge */ }
513
637
  });
638
+ // Only the preview needs the pointer; a clipped full result carries its own.
639
+ const trailer = notificationResultChars === 0 ? "\n\nUse get_subagent_result for full output." : "";
514
640
  if (members.length > 0) {
515
641
  outbox.add({
516
642
  ids: members.map(m => m.id),
517
- build: (ids) => {
643
+ resultChars: resultCharsOf(members),
644
+ build: (ids, caps) => {
518
645
  const unread = members.filter(m => ids.includes(m.id));
519
- const notifications = unread.map(m => m.text).join('\n\n');
646
+ const rendered = unread.map(m => ({ id: m.id, ...m.render(caps[m.id]) }));
647
+ const notifications = rendered.map(m => m.text).join('\n\n');
648
+ const delivered = rendered.filter(m => m.delivered).map(m => m.id);
649
+ const noticed = rendered.filter(m => m.complete).map(m => m.id);
520
650
  const label = partial
521
651
  ? `${unread.length} agent(s) finished (partial — others still running)`
522
652
  : `${unread.length} agent(s) finished`;
@@ -525,8 +655,10 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
525
655
  const details = first.details;
526
656
  if (rest.length > 0) details.others = rest.map(m => m.details);
527
657
  return {
528
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
658
+ content: `Background agent group completed: ${label}\n\n${notifications}${trailer}`,
529
659
  details,
660
+ ...(delivered.length > 0 ? { delivered } : {}),
661
+ ...(noticed.length > 0 ? { noticed } : {}),
530
662
  };
531
663
  },
532
664
  });
@@ -538,7 +670,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
538
670
 
539
671
  /** Helper: build event data for lifecycle events from an AgentRecord. */
540
672
  function buildEventData(record: AgentRecord) {
541
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
673
+ const durationMs = recordElapsedMs(record, Date.now());
542
674
  // All three fields are lifetime-accumulated (Σ over every assistant message_end),
543
675
  // so they survive compaction together — input + output ≤ total always.
544
676
  // tokens is omitted when nothing was ever produced (e.g. agent errored before
@@ -564,6 +696,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
564
696
  id: record.id,
565
697
  type: record.type,
566
698
  description: record.description,
699
+ ...eventFields(record),
567
700
  result: record.result,
568
701
  error: record.error,
569
702
  status: record.status,
@@ -571,6 +704,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
571
704
  durationMs,
572
705
  tokens,
573
706
  usage,
707
+ // Stamped by the only caller, the settle path, just before this runs.
708
+ settleSeq: record.settleSeq,
574
709
  };
575
710
  }
576
711
 
@@ -580,19 +715,84 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
580
715
  */
581
716
  let detachedSessionFile: string | undefined;
582
717
 
718
+ /**
719
+ * Every lifecycle event's identity fields, then `fallbackFields`. `handle` (`explore-2`)
720
+ * and `spawnSeq` are always set: every caller announces a top-level agent, which the
721
+ * manager gives a handle, and the manager stamps `spawnSeq` on every record it creates.
722
+ * `name` (the handle drawn from the spawner's `name`, `auth-audit-2`), `nameBase` (that
723
+ * name before collision numbering, `auth-audit`) and `previousId` (the evicted id a
724
+ * reopen continues) are each omitted when absent, so a listener such as pi-todo can
725
+ * match an agent by what the parent typed, not only its id. `spawnSeq` orders the spawn
726
+ * against other spawns and `settleSeq`s, queued time included.
727
+ */
728
+ const lifecycleRuns = new WeakMap<AgentRecord, number>();
729
+ function eventFields(record: AgentRecord | undefined): Record<string, unknown> {
730
+ return {
731
+ handle: record?.handle,
732
+ ...(record?.alias ? { name: record.alias } : {}),
733
+ ...(record?.nameBase ? { nameBase: record.nameBase } : {}),
734
+ ...(record?.previousId ? { previousId: record.previousId } : {}),
735
+ spawnSeq: record?.spawnSeq,
736
+ ...(record && lifecycleRuns.has(record) ? { runSeq: lifecycleRuns.get(record) } : {}),
737
+ wakesParent: record?.isBackground === true,
738
+ ...fallbackFields(record),
739
+ };
740
+ }
741
+
742
+ /** `modelFallback` + `modelNote` event fields, or nothing. Only what is KNOWN when the event fires. */
743
+ function fallbackFields(record: AgentRecord | undefined): Record<string, unknown> {
744
+ return {
745
+ ...(record?.modelTier ? { modelTier: record.modelTier } : {}),
746
+ ...(record?.modelFallback ? { modelFallback: record.modelFallback } : {}),
747
+ ...(modelSelectionNote(record) ? { modelNote: modelSelectionNote(record) } : {}),
748
+ };
749
+ }
750
+
751
+ /** The `subagents:record` history entry; the ONE builder, shared by live completion and the detached spool. */
752
+ function historyRecord(record: AgentRecord): Record<string, unknown> {
753
+ return {
754
+ id: record.id, type: record.type, description: record.description,
755
+ status: record.status, result: record.result, error: record.error,
756
+ startedAt: record.startedAt, completedAt: record.completedAt,
757
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
758
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
759
+ };
760
+ }
761
+
762
+ /**
763
+ * A survivor's lifecycle payload for a session that never heard its spawn: the
764
+ * orphaned manager's own `spawnSeq`/`runSeq`/`settleSeq` count a different sequence
765
+ * than this one, so they are dropped rather than compared. Only background runs
766
+ * survive a switch (`AgentManager.detach`), so `wakesParent` is already true.
767
+ */
768
+ function survivorEventData(data: Record<string, unknown>): Record<string, unknown> {
769
+ const { spawnSeq: _spawn, runSeq: _run, settleSeq: _settle, ...rest } = data;
770
+ return rest;
771
+ }
772
+
773
+ /** The terminal lifecycle channel for a settled record; shared by the live and the detached path. */
774
+ function lifecycleChannel(record: AgentRecord): "subagents:failed" | "subagents:completed" {
775
+ return record.status === "error" || record.status === "stopped" || record.status === "aborted"
776
+ ? "subagents:failed" : "subagents:completed";
777
+ }
778
+
583
779
  function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
584
780
  if (record.resultConsumed) return;
585
781
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
586
782
  try {
783
+ const notice = snapshotNotice(record, PREVIEW_RESULT_CHARS);
587
784
  spoolDetached(sessionFile, {
588
785
  id: record.id,
589
- content: formatTaskNotification(record, 500, showCost) + footer,
590
- details: buildNotificationDetails(record, 500),
591
- record: {
592
- id: record.id, type: record.type, description: record.description,
593
- status: record.status, result: record.result, error: record.error,
594
- startedAt: record.startedAt, completedAt: record.completedAt,
786
+ content: notice.render().text + footer,
787
+ details: notice.details,
788
+ record: historyRecord(record),
789
+ evicted: manager.evictionNotes([record.id]),
790
+ tombstones: manager.evictionTombstones([record.id]),
791
+ lifecycle: {
792
+ channel: lifecycleChannel(record),
793
+ data: survivorEventData(buildEventData(record)),
595
794
  },
795
+ ...(notice.parts ? { notice: { ...notice.parts, footer } } : {}),
596
796
  });
597
797
  } catch { /* spool unwritable: the transcript file still has the result */ }
598
798
  }
@@ -602,18 +802,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
602
802
  for (const result of drainDetached(sessionFile)) deliverDetachedResult(result);
603
803
  }
604
804
 
805
+ /**
806
+ * Announce every agent still running in an orphaned manager, which this session never
807
+ * heard spawn, so lifecycle listeners (e.g. pi-todo) see their owners alive instead of lost.
808
+ * Runs at every `session_start` and on `subagents:announce_survivors`, for a listener that
809
+ * bound after it. Survivors registered under the current session file are announced as
810
+ * `subagents:started` (armed: their results spool here and wake this session). All others,
811
+ * whatever switch left them (`/fork`, `/resume`, `/new`, `/reload`), are announced as
812
+ * `subagents:other_session_survivor` with the `sessionFile` they wake instead, and arm nothing here.
813
+ */
814
+ function announceSurvivors() {
815
+ const current = currentCtx?.sessionManager?.getSessionFile?.();
816
+ for (const { sessionFile, agent } of listAllRunningDetached()) {
817
+ const { record } = agent;
818
+ const own = sessionFile === current;
819
+ const { wakesParent: _wakes, ...fields } = eventFields(record);
820
+ pi.events.emit(own ? "subagents:started" : "subagents:other_session_survivor", survivorEventData({
821
+ id: record.id,
822
+ type: record.type,
823
+ description: record.description,
824
+ ...fields,
825
+ ...(own ? { wakesParent: true } : { sessionFile }),
826
+ }));
827
+ }
828
+ }
829
+
605
830
  function deliverDetachedResult(result: DetachedResult) {
606
831
  try {
607
- if (result.record) pi.appendEntry("subagents:record", result.record);
832
+ // First: the notice names this id, and a read of it must not say "not found".
833
+ for (const note of result.evicted ?? []) manager.rememberEvicted(note);
834
+ for (const entry of result.tombstones ?? []) manager.rememberTombstone(entry);
835
+ // Before the notice: a listener must see the owner settle, not stay "running".
836
+ if (result.lifecycle) pi.events.emit(result.lifecycle.channel, result.lifecycle.data);
837
+ if (result.record) {
838
+ pi.appendEntry("subagents:record", result.record);
839
+ fleet.ingestFailure(result.record);
840
+ fleet.update();
841
+ }
608
842
  const details = result.details as NotificationDetails;
843
+ // Re-clipped here, so it shares the budget of whatever lands with it. Its
844
+ // record lives in the orphaned manager, so nothing is marked read.
845
+ const notice = typeof result.notice?.body === "string" ? result.notice : undefined;
609
846
  outbox.add({
610
847
  ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
611
- build: () => ({ content: result.content, details }),
848
+ ...(notice ? { resultChars: { [details.id]: notice.body.length } } : {}),
849
+ build: (_ids, caps) => notice
850
+ ? { content: clipNotice(notice, caps[details.id]).text + notice.footer, details }
851
+ : { content: result.content, details },
612
852
  });
613
853
  } catch { /* session already gone again; the transcript file still has the result */ }
614
854
  }
615
855
 
616
856
  let unwatchDetached: (() => void) | undefined;
857
+ let unwatchAnnounce: (() => void) | undefined;
858
+
859
+ // At load, before pi-plan-mode's session_start ask: Plan admits `Agent` only with this gate (#437).
860
+ answerPlanGateQueries(pi.events);
617
861
 
618
862
  // Background completion: route through group join or send individual nudge
619
863
  const manager = new AgentManager((record) => {
@@ -631,21 +875,13 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
631
875
  return;
632
876
  }
633
877
 
878
+ if (!record.resultConsumed) fleet.ingestFailure(record);
634
879
  // Emit lifecycle event based on terminal status
635
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
636
- const eventData = buildEventData(record);
637
- if (isError) {
638
- pi.events.emit("subagents:failed", eventData);
639
- } else {
640
- pi.events.emit("subagents:completed", eventData);
641
- }
880
+ record.settleSeq = manager.nextSequence();
881
+ pi.events.emit(lifecycleChannel(record), buildEventData(record));
642
882
 
643
883
  // Persist final record for cross-extension history reconstruction
644
- pi.appendEntry("subagents:record", {
645
- id: record.id, type: record.type, description: record.description,
646
- status: record.status, result: record.result, error: record.error,
647
- startedAt: record.startedAt, completedAt: record.completedAt,
648
- });
884
+ pi.appendEntry("subagents:record", historyRecord(record));
649
885
 
650
886
  // Skip notification if result was already consumed via get_subagent_result
651
887
  if (record.resultConsumed) {
@@ -680,11 +916,14 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
680
916
  fleet.ensureTimer();
681
917
  fleet.update();
682
918
  }
919
+ // Bind later handoff announcements to this exact run.
920
+ lifecycleRuns.set(record, manager.nextSequence());
683
921
  // Emit started event when agent transitions to running (including from queue)
684
922
  pi.events.emit("subagents:started", {
685
923
  id: record.id,
686
924
  type: record.type,
687
925
  description: record.description,
926
+ ...eventFields(record),
688
927
  });
689
928
  }, (record, info) => {
690
929
  if (!isTopLevelAgent(record)) return;
@@ -696,6 +935,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
696
935
  reason: info.reason,
697
936
  tokensBefore: info.tokensBefore,
698
937
  compactionCount: record.compactionCount,
938
+ ...eventFields(record),
699
939
  });
700
940
  }, (record, usage) => {
701
941
  // Every assistant message from every agent — nested included, exactly once.
@@ -731,7 +971,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
731
971
  // clamped it to the model, or an agent file outranked it), so a ledger
732
972
  // can show `low (asked minimal)` instead of silently relabelling.
733
973
  ...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
734
- depth: record.depth ?? 1,
974
+ ...fallbackFields(record),
975
+ depth: record.depth,
735
976
  ...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
736
977
  ...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
737
978
  usage: reported,
@@ -739,7 +980,26 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
739
980
  }, (record, message) => {
740
981
  if (!isTopLevelAgent(record)) return;
741
982
  pi.events.emit("subagents:steered", { id: record.id, message });
742
- }, clock);
983
+ }, clock, options.homeDir, (record) => {
984
+ // Stopped before it ever started: no result and no notice, but a listener that heard
985
+ // `subagents:created` must still see it end. Same payload as a settled stop.
986
+ if (!isTopLevelAgent(record) || detachedSessionFile !== undefined) return;
987
+ record.settleSeq = manager.nextSequence();
988
+ pi.events.emit("subagents:failed", buildEventData(record));
989
+ }, (note) => {
990
+ // The retention sweep dropped a result nobody read: say so now, where the parent sees it on
991
+ // its next turn, instead of leaving `Agent not found` to be discovered by a later lookup (#330).
992
+ pi.sendMessage({
993
+ customType: "subagent-reaped",
994
+ content: `${describeEvicted(note)}. Its result was never read and its record is gone; reopen it with steer_subagent or Agent resume if its session is still saved.`,
995
+ display: true,
996
+ }, { deliverAs: "nextTurn" });
997
+ });
998
+
999
+ // A live survivor of an orphaned manager keeps its handle: reusing it would make a lifecycle listener
1000
+ // that keys by handle read this manager's newer agent and the survivor as one.
1001
+ manager.reservedHandles = () =>
1002
+ listAllRunningDetached().flatMap(({ agent }) => [agent.record.handle, agent.record.alias].filter((h): h is string => !!h));
743
1003
 
744
1004
  // Expose manager via Symbol.for() global registry for cross-package access.
745
1005
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
@@ -810,10 +1070,19 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
810
1070
  // Bypasses handle allocation, so a forged value would duplicate a live
811
1071
  // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
812
1072
  delete safeOptions.reclaim;
1073
+ // Would make the new agent claim an evicted agent's id on lifecycle events.
1074
+ delete safeOptions.previousId;
813
1075
  // Every spawn through here is DETACHED — the caller gets an id back and
814
1076
  // awaits nothing. A forged `blocking` would charge it to the foreground
815
1077
  // pool and could defer it behind a queue whose gate nobody is holding.
816
1078
  delete safeOptions.blocking;
1079
+ // The spawn path's own disclosure; a caller-supplied one could hide a
1080
+ // fallback or invent one. The runner reports the real outcome itself.
1081
+ delete safeOptions.modelFallback;
1082
+ delete safeOptions.modelSelection;
1083
+ delete safeOptions.modelTier;
1084
+ delete safeOptions.previousModelTier;
1085
+ delete safeOptions.previousModelFallback;
817
1086
  return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
818
1087
  };
819
1088
 
@@ -828,7 +1097,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
828
1097
  const byId = manager.getRecord(ref);
829
1098
  if (byId) return byId;
830
1099
  const resolved = manager.resolveMention(ref);
831
- return resolved?.kind === "live" ? resolved.record : undefined;
1100
+ if (resolved?.kind === "live") return resolved.record;
1101
+ // Survivors of a /reload, /new or /resume live in the orphaned manager, so
1102
+ // this fresh one has never heard of them (#330). Same live records, so
1103
+ // reading and steering them work unchanged.
1104
+ const survivors = listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()).map(d => d.record);
1105
+ return survivors.find(r => r.id === ref) ?? survivors.find(r => r.handle === ref || r.alias === ref);
832
1106
  };
833
1107
 
834
1108
  const registryEntry = {
@@ -847,6 +1121,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
847
1121
 
848
1122
  // --- Cross-extension RPC via pi.events ---
849
1123
  let currentCtx: ExtensionContext | undefined;
1124
+ let unwatchFleetSchedules: (() => void) | undefined;
850
1125
  // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
851
1126
  // (a bound lifecycle event), not at factory time. pi runs every extension
852
1127
  // factory before the `extensions:` filter and only fires lifecycle events for
@@ -862,7 +1137,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
862
1137
  // Session-scoped: store is constructed inside session_start once sessionId
863
1138
  // is available. Mirrors pi-chonky-tasks's session-scoped task store —
864
1139
  // schedules reset on /new, restore on /resume.
865
- const scheduler = new SubagentScheduler({ clock: options.clock });
1140
+ const scheduler = new SubagentScheduler({ clock: options.clock, homeDir: options.homeDir });
866
1141
 
867
1142
  function startScheduler(ctx: ExtensionContext) {
868
1143
  try {
@@ -884,6 +1159,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
884
1159
  // bound session_start, so a filtered-out activation never advertises (#142).
885
1160
  pi.on("session_start", async (_event, ctx) => {
886
1161
  currentCtx = ctx;
1162
+ restoreFleetDisclosure(ctx);
887
1163
  // Pi resolves project trust before the first session starts, so this is the
888
1164
  // earliest honest answer. The activation-time load defaulted to untrusted,
889
1165
  // so a trusted project's `packages[]` only becomes visible now.
@@ -899,7 +1175,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
899
1175
  reloadCustomAgents(strictLoad);
900
1176
  } catch (err) {
901
1177
  if (strictLoad && StartupError) {
902
- throw new StartupError(err instanceof Error ? err.message : String(err), { cause: err });
1178
+ throw new StartupError(errorMessage(err), { cause: err });
903
1179
  }
904
1180
  throw err;
905
1181
  }
@@ -911,6 +1187,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
911
1187
  widget.setUICtx(ctx.ui);
912
1188
  fleet.setUICtx(ctx.ui as any);
913
1189
  watchHandoffKey(ctx.ui);
1190
+ unwatchFleetSchedules ??= pi.events.on("subagents:scheduled", () => fleet.update());
914
1191
  }
915
1192
  manager.clearCompleted(true);
916
1193
  const sessionFile = ctx.sessionManager?.getSessionFile?.();
@@ -919,12 +1196,15 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
919
1196
  unwatchDetached = watchDetached(sessionFile, () => deliverDetached(sessionFile));
920
1197
  deliverDetached(sessionFile);
921
1198
  }
1199
+ announceSurvivors();
1200
+ unwatchAnnounce ??= pi.events.on("subagents:announce_survivors", announceSurvivors);
922
1201
  // Orphans from the previous instance are already registered; show them now.
923
1202
  fleet.update();
924
1203
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
925
1204
  // fires once per activation, but a double-bind must not leak listeners.
926
1205
  if (!rpcHandle) {
927
1206
  rpcHandle = registerRpcHandlers({
1207
+ homeDir: options.homeDir,
928
1208
  events: pi.events,
929
1209
  pi,
930
1210
  getCtx: () => currentCtx,
@@ -944,6 +1224,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
944
1224
  if (!record || record.parentAgentId) return false;
945
1225
  if (record.status === "running" || record.status === "queued") return false;
946
1226
  markConsumed(record, clock.now());
1227
+ fleet.acknowledgeFailure(record.id);
947
1228
  outbox.cancel(record.id);
948
1229
  return true;
949
1230
  },
@@ -956,6 +1237,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
956
1237
  pi.events.emit("subagents:ready", {});
957
1238
  }
958
1239
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
1240
+ fleet.update(); // restored quota-parked jobs have no agent record
959
1241
  // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
960
1242
  // most once per activation: pi appends wrappers to a list it never prunes,
961
1243
  // so a second call would layer a duplicate provider on the first. TUI only
@@ -981,7 +1263,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
981
1263
 
982
1264
  /** Agent types `@` can start, in the shape the roster wants. */
983
1265
  const mentionTypes = (): TypeInfo[] =>
984
- getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
1266
+ getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)!.description }));
985
1267
 
986
1268
  /**
987
1269
  * `@handle message` typed at the prompt addresses that agent instead of the
@@ -1080,62 +1362,9 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1080
1362
  if (resolved?.kind === "tombstone") {
1081
1363
  const entry = resolved.entry;
1082
1364
  const target = `@${entry.alias ?? entry.handle}`;
1083
-
1084
- // Checked here rather than left to SessionManager.open: that runs inside
1085
- // runAgent, whose rejection lands on the record as an agent error, not in
1086
- // the catch below. A `/new` in another pi window or a manual delete makes
1087
- // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
1088
- // the entry — a row that can only ever fail is worse than none — and say
1089
- // so rather than quietly sending this message to an unrelated agent.
1090
- if (!existsSync(entry.sessionFile)) {
1091
- manager.dropTombstone(entry.handle);
1092
- ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
1093
- return { action: "handled" };
1094
- }
1095
-
1096
- // The Agent tool deliberately falls back to general-purpose for a type it
1097
- // cannot resolve (#183), which covers a deleted file AND a merely
1098
- // disabled one. A resume must not inherit that: reopening this
1099
- // conversation under a different agent's prompt and tools is not
1100
- // continuing it, and the new record would re-tombstone under the
1101
- // substitute, so the handle would never find its way back.
1102
- reloadCustomAgents();
1103
- const dispatch = resolveSpawnType(entry.type);
1104
- if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
1105
- // The tombstone stays: re-enabling the agent makes the handle work
1106
- // again, which a drop would foreclose.
1107
- ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
1108
- return { action: "handled" };
1109
- }
1110
-
1111
- try {
1112
- // spawnResolved, not spawnTopLevel: the latter strips
1113
- // `resumeSessionFile` and `reclaim` as untrusted. This path is the
1114
- // exception — both come from a tombstone this extension wrote.
1115
- const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
1116
- description: entry.description,
1117
- reclaim: { handle: entry.handle, alias: entry.alias },
1118
- resumeSessionFile: entry.sessionFile,
1119
- isBackground: true,
1120
- });
1121
- // The agent may still be starting — wait, so a startup failure lands in
1122
- // the catch below instead of being announced as a resume.
1123
- await manager.awaitStartup(id);
1124
- // The tombstone deliberately stays. `resolveMention` prefers the live
1125
- // record holding these same names, so it cannot shadow the resume — and
1126
- // if this run dies before establishing its own session, the original
1127
- // transcript is still the right thing for the next mention to reopen.
1128
- // Once the resumed record is evicted it overwrites this entry in place,
1129
- // keyed by the same handle, so nothing accumulates.
1130
- ctx.ui.notify(`Resuming ${target}`, "info");
1131
- } catch (err) {
1132
- // The type is already settled above, so what is left is a spawn-time
1133
- // failure: a strict worktree-isolation error, an unusable cwd.
1134
- ctx.ui.notify(
1135
- `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
1136
- "warning",
1137
- );
1138
- }
1365
+ const reopened = await reopenEvicted(ctx, entry, mention.message);
1366
+ if (reopened.ok) ctx.ui.notify(`Resuming ${target}`, "info");
1367
+ else ctx.ui.notify(couldNotResume(target, reopened.failure), "warning");
1139
1368
  return { action: "handled" };
1140
1369
  }
1141
1370
 
@@ -1169,7 +1398,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1169
1398
  // Not awaited: the clone runs a full model turn, and prompt() is blocked
1170
1399
  // until this hook returns. The user gets their prompt back immediately
1171
1400
  // and the agent appears in the widget when it starts.
1172
- void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
1401
+ void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool, homeDir: options.homeDir })
1173
1402
  .then(async (result) => {
1174
1403
  if (result.spawned) return;
1175
1404
  // A clone that could not run must not swallow the mention: start the
@@ -1186,7 +1415,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1186
1415
  ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
1187
1416
  } catch (err) {
1188
1417
  ctx.ui.notify(
1189
- `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
1418
+ `Could not start ${label}: ${errorMessage(err)}`,
1190
1419
  "error",
1191
1420
  );
1192
1421
  }
@@ -1195,7 +1424,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1195
1424
  }
1196
1425
 
1197
1426
  try {
1198
- // Nothing else to pass: runAgent resolves model, thinking, max turns and
1427
+ // Nothing else to pass: the manager resolves model; runAgent resolves thinking, max turns and
1199
1428
  // `isolated` from the agent's own config when the spawn omits them, and the
1200
1429
  // manager's onStart/onComplete callbacks own the widget, the fleet list
1201
1430
  // and the completion notification — the same contract the scheduler and
@@ -1210,7 +1439,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1210
1439
  await manager.awaitStartup(id);
1211
1440
  ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1212
1441
  } catch (err) {
1213
- ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
1442
+ ctx.ui.notify(`Could not start @${handleBase(type)}: ${errorMessage(err)}`, "error");
1214
1443
  }
1215
1444
  return { action: "handled" };
1216
1445
  });
@@ -1227,9 +1456,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1227
1456
  // to this session (see detached-results.ts). Only agents that cannot outlive the
1228
1457
  // session are aborted.
1229
1458
  pi.on("session_shutdown", async (event: { reason?: string }) => {
1459
+ fleet.flushState(); // appendEntry must run before this activation loses its context
1230
1460
  const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
1231
1461
  unwatchDetached?.();
1232
1462
  unwatchDetached = undefined;
1463
+ unwatchAnnounce?.();
1464
+ unwatchAnnounce = undefined;
1233
1465
  rpcHandle?.unsubSpawn();
1234
1466
  rpcHandle?.unsubStop();
1235
1467
  rpcHandle?.unsubPing();
@@ -1260,7 +1492,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1260
1492
  }
1261
1493
  const pending = outbox.takePending();
1262
1494
  if (pending) {
1263
- try { spoolDetached(sessionFile, { id: nanoid(8), ...pending }); }
1495
+ // Not sent here: the session that reads the spool is the one that sees it.
1496
+ const { delivered: _unsent, noticed: _unseen, ...spooled } = pending;
1497
+ const heldIds = [pending.details.id, ...(pending.details.others?.map(other => other.id) ?? [])];
1498
+ const evicted = manager.evictionNotes(heldIds);
1499
+ const tombstones = manager.evictionTombstones(heldIds);
1500
+ try { spoolDetached(sessionFile, { id: nanoid(8), ...spooled, evicted, tombstones }); }
1264
1501
  catch { console.error("[pi-subagents] Could not spool held completion notices; results remain in their transcripts."); }
1265
1502
  }
1266
1503
  }
@@ -1276,6 +1513,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1276
1513
  widget.dispose();
1277
1514
  outbox.dispose();
1278
1515
  fleet.dispose();
1516
+ unwatchFleetSchedules?.();
1517
+ unwatchFleetSchedules = undefined;
1279
1518
  unsubHandoffKey?.();
1280
1519
  unsubHandoffKey = undefined;
1281
1520
  handoffKeyUi = undefined;
@@ -1290,7 +1529,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1290
1529
  registerRunningDetached(sessionFile, {
1291
1530
  record,
1292
1531
  activity: agentActivity.get(record.id),
1293
- abort: () => manager.abort(record.id),
1532
+ abort: (by) => manager.abort(record.id, by),
1294
1533
  steer: (message) => manager.steer(record.id, message),
1295
1534
  });
1296
1535
  }
@@ -1340,16 +1579,17 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1340
1579
  wake();
1341
1580
  sent++;
1342
1581
  // It is a background agent from here on, so announce it as one — as a
1343
- // detached resume re-announces an existing id.
1344
- const record = manager.getRecord(id);
1345
- if (record) {
1346
- pi.events.emit("subagents:created", {
1347
- id,
1348
- type: record.type,
1349
- description: record.description,
1350
- isBackground: true,
1351
- });
1352
- }
1582
+ // detached resume re-announces an existing id. `sendToBackground` only
1583
+ // hands off a record it holds.
1584
+ const record = manager.getRecord(id)!;
1585
+ pi.events.emit("subagents:created", {
1586
+ id,
1587
+ type: record.type,
1588
+ description: record.description,
1589
+ isBackground: true,
1590
+ backgroundHandoff: true,
1591
+ ...eventFields(record),
1592
+ });
1353
1593
  }
1354
1594
  if (sent > 0) {
1355
1595
  widget.ensureTimer();
@@ -1412,6 +1652,18 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1412
1652
  getFullscreenScrollbar, backgroundActionFor, isShowModelEnabled,
1413
1653
  // Agents the previous instance of this session left running (/reload, /new, ...).
1414
1654
  () => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
1655
+ fleet.setParkedSource(() => scheduler.list().filter(job => job.enabled && job.quotaParked && job.lastStatus !== "running").length);
1656
+ fleet.setStopAll(stopAllRunning);
1657
+ /** Replay UI-only state from the active branch, like workflow/goal custom snapshots. */
1658
+ function restoreFleetDisclosure(ctx: ExtensionContext): void {
1659
+ fleet.restoreSession(() => ctx.sessionManager?.getBranch?.() ?? [], state => {
1660
+ pi.appendEntry(FLEET_STATE_ENTRY, { ...state, version: FLEET_STATE_VERSION });
1661
+ });
1662
+ }
1663
+ pi.on("session_tree", (_event, ctx) => {
1664
+ restoreFleetDisclosure(ctx);
1665
+ fleet.update();
1666
+ });
1415
1667
  /** What the Agent tool row reads at render time, so its summary stays live. */
1416
1668
  const agentRowLive: AgentRowLive = {
1417
1669
  getRecord: (id) => manager.getRecord(id),
@@ -1552,6 +1804,100 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1552
1804
  return getAgentConfig(record.type)?.maxTurns ?? explicit ?? record.invocation?.maxTurns ?? getDefaultMaxTurns();
1553
1805
  }
1554
1806
 
1807
+ /** Why `reopenEvicted` did not reopen; `type-unavailable` keeps the tombstone, `session-gone` drops it. */
1808
+ type ReopenFailure =
1809
+ | { reason: "session-gone" }
1810
+ | { reason: "type-unavailable"; type: string }
1811
+ | { reason: "spawn-failed"; message: string };
1812
+
1813
+ /** The one wording both reopen callers use, `target` being `@handle` or `agent "<id>"`. */
1814
+ function couldNotResume(target: string, failure: ReopenFailure): string {
1815
+ switch (failure.reason) {
1816
+ case "session-gone": return `Could not resume ${target} — its session is gone.`;
1817
+ case "type-unavailable": return `Could not resume ${target} — the ${failure.type} agent is no longer available.`;
1818
+ case "spawn-failed": return `Could not resume ${target}: ${failure.message}`;
1819
+ }
1820
+ }
1821
+
1822
+ /**
1823
+ * Reopen an evicted agent's conversation from its session file. An ordinary
1824
+ * spawn carrying that file, so the new record picks up the widget, fleet row,
1825
+ * transcript and completion notification unchanged, and `reclaim` hands it
1826
+ * back the names the tombstone was holding. Always detached.
1827
+ *
1828
+ * Shared by `@handle message` and the Agent tool's `resume` of an evicted id
1829
+ * (#330); they differ only in how they report the outcome, see
1830
+ * `couldNotResume`. The evicted id is linked to the new one before the first
1831
+ * await, so a second resume of the old id in the same turn finds the live
1832
+ * record instead of reopening the file twice.
1833
+ */
1834
+ async function reopenEvicted(
1835
+ ctx: ExtensionContext,
1836
+ entry: AgentTombstone,
1837
+ prompt: string,
1838
+ opts: { maxTurns?: number } = {},
1839
+ ): Promise<{ ok: true; id: string } | { ok: false; failure: ReopenFailure }> {
1840
+ // Checked here rather than left to SessionManager.open: that runs inside
1841
+ // runAgent, whose rejection lands on the record as an agent error, not in
1842
+ // the catch below. A `/new` in another pi window or a manual delete makes
1843
+ // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
1844
+ // the entry — a row that can only ever fail is worse than none — and say
1845
+ // so rather than quietly sending this message to an unrelated agent.
1846
+ if (!existsSync(entry.sessionFile)) {
1847
+ manager.dropTombstone(entry.handle);
1848
+ return { ok: false, failure: { reason: "session-gone" } };
1849
+ }
1850
+
1851
+ // The Agent tool deliberately falls back to general-purpose for a type it
1852
+ // cannot resolve (#183), which covers a deleted file AND a merely
1853
+ // disabled one. A resume must not inherit that: reopening this
1854
+ // conversation under a different agent's prompt and tools is not
1855
+ // continuing it, and the new record would re-tombstone under the
1856
+ // substitute, so the handle would never find its way back.
1857
+ reloadCustomAgents();
1858
+ const dispatch = resolveSpawnType(entry.type);
1859
+ if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
1860
+ // The tombstone stays: re-enabling the agent makes the handle work
1861
+ // again, which a drop would foreclose.
1862
+ return { ok: false, failure: { reason: "type-unavailable", type: entry.type } };
1863
+ }
1864
+
1865
+ let id: string | undefined;
1866
+ try {
1867
+ // spawnResolved, not spawnTopLevel: the latter strips
1868
+ // `resumeSessionFile` and `reclaim` as untrusted. This path is the
1869
+ // exception — both come from a tombstone this extension wrote.
1870
+ id = spawnResolved(pi, ctx, dispatch.type, prompt, {
1871
+ description: entry.description,
1872
+ maxTurns: opts.maxTurns,
1873
+ reclaim: { handle: entry.handle, alias: entry.alias },
1874
+ previousId: entry.id,
1875
+ resumeSessionFile: entry.sessionFile,
1876
+ // Historical disclosures never replace the continuation's current selection.
1877
+ ...(entry.modelFallback ? { previousModelFallback: entry.modelFallback } : {}),
1878
+ ...(entry.modelTier ? { previousModelTier: entry.modelTier } : {}),
1879
+ isBackground: true,
1880
+ });
1881
+ manager.linkSuccessor(entry.id, id);
1882
+ // The agent may still be starting — wait, so a startup failure lands in
1883
+ // the catch below instead of being announced as a resume.
1884
+ await manager.awaitStartup(id);
1885
+ // The tombstone deliberately stays. `resolveMention` prefers the live
1886
+ // record holding these same names, so it cannot shadow the resume — and
1887
+ // if this run dies before establishing its own session, the original
1888
+ // transcript is still the right thing for the next mention to reopen.
1889
+ // Once the resumed record is evicted it overwrites this entry in place,
1890
+ // keyed by the same handle, so nothing accumulates.
1891
+ return { ok: true, id };
1892
+ } catch (err) {
1893
+ // The type is already settled above, so what is left is a spawn-time
1894
+ // failure: a strict worktree-isolation error, an unusable cwd.
1895
+ // A run that never started is no successor: the old id reopens again next time.
1896
+ if (id !== undefined) manager.unlinkSuccessor(entry.id);
1897
+ return { ok: false, failure: { reason: "spawn-failed", message: errorMessage(err) } };
1898
+ }
1899
+ }
1900
+
1555
1901
  /**
1556
1902
  * Launch a detached resume of an existing agent and wire everything a
1557
1903
  * re-running agent needs: transcript anchoring, activity tracking, join-mode
@@ -1571,24 +1917,31 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1571
1917
  opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1572
1918
  ): Promise<AgentRecord | undefined> {
1573
1919
  const id = existing.id;
1574
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1920
+ const priorSettleSeq = existing.settleSeq;
1921
+ const resumeSeq = manager.nextSequence();
1922
+ // A resume always runs detached, and a detached run joins by the default mode.
1923
+ const joinMode = defaultJoinMode;
1575
1924
  // Assigned unconditionally: the completion notification carries this as
1576
1925
  // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1577
1926
  // id left by the spawn that created the record. Keeping it would point the
1578
1927
  // orchestrator's new result at a tool call that was answered runs ago.
1579
1928
  existing.toolCallId = opts.toolCallId;
1580
- if (joinMode) existing.joinMode = joinMode;
1929
+ existing.joinMode = joinMode;
1581
1930
  // Reuse the agent's transcript rather than starting a fresh one: the
1582
1931
  // path is deterministic per agent+session, so writing an initial entry
1583
1932
  // would truncate the previous run's turns (see ensureOutputFile).
1584
1933
  if (opts.outputTranscript) {
1585
1934
  existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1586
1935
  ensureOutputFile(existing.outputFile);
1936
+ // A fresh transcript for a later resume must still say what the agent ran on.
1937
+ if (existing.modelFallback) appendModelFallbackEntry(existing.outputFile, id, existing.modelFallback);
1938
+ if (existing.modelTier) appendModelTierEntry(existing.outputFile, id, existing.modelTier);
1587
1939
  }
1588
1940
  // Anchor streaming past the turns already on disk, captured BEFORE the
1589
1941
  // run starts. The resumed prompt lands as an ordinary user message at
1590
1942
  // this index, so it is written exactly once.
1591
- const transcriptAnchor = existing.session?.messages.length ?? 0;
1943
+ // Both callers resume only a record that still holds its session.
1944
+ const transcriptAnchor = existing.session!.messages.length;
1592
1945
 
1593
1946
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(normalizeMaxTurns(opts.maxTurns), undefined, clock);
1594
1947
  // resumeAgent has no onSessionCreated — the session predates this run —
@@ -1642,6 +1995,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1642
1995
  type: existing.type,
1643
1996
  description: existing.description,
1644
1997
  isBackground: true,
1998
+ ...(record.status === "queued" && priorSettleSeq !== undefined ? { queuedAfterSettleSeq: priorSettleSeq, queuedResumeSeq: resumeSeq } : {}),
1999
+ ...eventFields(existing),
1645
2000
  });
1646
2001
 
1647
2002
  return record;
@@ -1662,17 +2017,28 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1662
2017
  outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
1663
2018
  });
1664
2019
  pi.on("agent_end", () => outbox.onAgentEnd());
1665
- pi.on("agent_settled", () => outbox.onAgentEnd());
2020
+ pi.on("agent_settled", () => {
2021
+ outbox.onAgentEnd();
2022
+ // The run is over but owned agents are not: say so, and say what will
2023
+ // wake the parent, instead of leaving a quiet prompt that looks like
2024
+ // finished work. `currentCtx` is set at `session_start` and cleared only at
2025
+ // `session_shutdown`, and pi settles a run only inside a live session, so
2026
+ // it is defined here.
2027
+ const pending = manager.ownedPending();
2028
+ const message = formatOwnedPendingNotice(pending);
2029
+ if (message) currentCtx?.ui.notify(message, pending.notifying > 0 ? "info" : "warning");
2030
+ });
1666
2031
 
1667
2032
  /** Build the full type list text dynamically from available agents only. */
1668
2033
  const buildTypeListText = () => {
1669
2034
  const available = getAvailableTypes();
1670
2035
 
1671
2036
  return available.map((name) => {
1672
- const cfg = getAgentConfig(name);
1673
- const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
2037
+ // Every available type is registered, so its config exists.
2038
+ const cfg = getAgentConfig(name)!;
2039
+ const modelSuffix = cfg.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
1674
2040
  const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1675
- return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
2041
+ return `- ${name}: ${cfg.description}${modelSuffix}${toolsSuffix}`;
1676
2042
  }).join("\n");
1677
2043
  };
1678
2044
 
@@ -1685,8 +2051,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1685
2051
  /** Compact type list: one line per agent, first sentence only. */
1686
2052
  const buildCompactTypeListText = () =>
1687
2053
  getAvailableTypes().map((name) => {
1688
- const cfg = getAgentConfig(name);
1689
- return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
2054
+ const cfg = getAgentConfig(name)!;
2055
+ return `- ${name}: ${firstSentence(cfg.description)} (Tools: ${formatToolsSuffix(cfg)})`;
1690
2056
  }).join("\n");
1691
2057
 
1692
2058
  /** Derive a short model label from a model string. */
@@ -1714,6 +2080,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1714
2080
  setBudgetPolicy,
1715
2081
  setDefaultMaxTurns,
1716
2082
  setGraceTurns,
2083
+ setNotificationResultChars: (n) => { notificationResultChars = n; },
1717
2084
  setDefaultJoinMode,
1718
2085
  setBackgroundByDefault,
1719
2086
  setSchedulingEnabled,
@@ -1740,6 +2107,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1740
2107
  setPackageWorkflows: setPackageWorkflowsGate,
1741
2108
  },
1742
2109
  (event, payload) => pi.events.emit(event, payload),
2110
+ process.cwd(),
2111
+ options.homeDir,
1743
2112
  );
1744
2113
 
1745
2114
  // ---- Agent tool ----
@@ -1774,13 +2143,35 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1774
2143
  // With no per-result note by design, the model would have every reason to go
1775
2144
  // on reporting a `pi-agent-*` branch that was never created.
1776
2145
  const isolationGuideline = isWorktreeIsolationEnabled()
1777
- ? `\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. The worktree is made from the repo containing \`cwd\` (default: the session cwd); when the target repo is not the session cwd, pass its path as \`cwd\`.`
2146
+ ? `\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 unless cleanup fails (the result then names the retained path); if it made changes, they are committed to a branch and the branch is named in the result. The worktree is made from the repo containing \`cwd\` (default: the session cwd); when the target repo is not the session cwd, pass its path as \`cwd\`.`
1778
2147
  : "";
1779
2148
 
1780
2149
  const isolationCompactGuideline = isWorktreeIsolationEnabled()
1781
- ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result. Pass \`cwd\` to isolate a repo other than the session cwd.`
2150
+ ? `\n- isolation: "worktree" gives the agent its own git worktree (removed unless cleanup fails); changes land on a branch named in the result. Pass \`cwd\` to isolate a repo other than the session cwd.`
1782
2151
  : "";
1783
2152
 
2153
+ // Tier keys only: members are profile config (router or enterprise manifest),
2154
+ // and teaching fuzzy names here is how model ids leak into calls, where an
2155
+ // explicit `model` beats `agentModels` and bypasses the router.
2156
+ const tierGuidance = (): string | undefined => {
2157
+ const tiers = configuredTierNames();
2158
+ return tiers.length === 0
2159
+ ? undefined
2160
+ : `Configured tiers: ${tiers.map((tier) => `@${tier}`).join(", ")}. Prefer a tier over a model id.`;
2161
+ };
2162
+ const modelGuideline = (): string => {
2163
+ const tiers = tierGuidance();
2164
+ return tiers
2165
+ ? `Use model to select a tier. ${tiers}`
2166
+ : 'Use model to select a configured tier (e.g. "@cheap"), an exact "provider/modelId", or a fuzzy tool-call input (e.g. "haiku").';
2167
+ };
2168
+ const modelParamDescription = (): string => {
2169
+ const tiers = tierGuidance();
2170
+ return tiers
2171
+ ? `Optional model override. ${tiers} "provider/modelId" is also accepted. Explicit values win over agentModels and the agent definition.`
2172
+ : 'Optional model override. Accepts a configured tier (e.g. "@cheap"), "provider/modelId", or fuzzy name (e.g. "haiku"). Explicit values win over agentModels and the agent definition.';
2173
+ };
2174
+
1784
2175
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1785
2176
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
1786
2177
  // small/local models. Per-option details live in the param descriptions.
@@ -1794,7 +2185,7 @@ Notes:
1794
2185
  - Parallel work: one message, multiple Agent calls — they run concurrently.
1795
2186
  - Subagents run in the background by default; you'll be notified when one completes. Pass run_in_background: false only when your very next action depends on the result and nothing else could usefully happen while it runs. Never fabricate or predict a pending agent's results — if the user asks before the notification arrives, say it's still running.
1796
2187
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
1797
- - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
2188
+ - resume continues a previous agent by ID; steer_subagent messages one, steering it mid-run or resuming it once finished.${isolationCompactGuideline}`;
1798
2189
 
1799
2190
  const fullAgentToolDescription = () => `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
1800
2191
 
@@ -1819,10 +2210,11 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1819
2210
  - **Foreground vs background**: Pass \`run_in_background: false\` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own.
1820
2211
  - **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess.
1821
2212
  - 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.
1822
- - Use steer_subagent to send mid-run messages to a running background agent.
2213
+ - Use steer_subagent to message an agent: it steers a running agent mid-run and resumes a finished one in the background, like resume.
2214
+ - Use stop_subagent to end an agent or workflow you no longer need, e.g. once a sibling has answered. A steer asking it to stop is read only after its current tool call and can be ignored.
1823
2215
  - 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.
1824
2216
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1825
- - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
2217
+ - ${modelGuideline()}
1826
2218
  - Use thinking to control extended thinking level.
1827
2219
  - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1828
2220
 
@@ -1871,7 +2263,7 @@ Terse command-style prompts produce shallow, generic work.
1871
2263
  if (text) return renderToolDescriptionTemplate(text);
1872
2264
  console.warn(`[pi-subagents] ${path} is empty — ignoring`);
1873
2265
  } catch (err) {
1874
- console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
2266
+ console.warn(`[pi-subagents] failed to read ${path}: ${errorMessage(err)}`);
1875
2267
  }
1876
2268
  }
1877
2269
  return undefined;
@@ -1923,8 +2315,7 @@ Terse command-style prompts produce shallow, generic work.
1923
2315
  }),
1924
2316
  model: Type.Optional(
1925
2317
  Type.String({
1926
- description:
1927
- 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
2318
+ description: modelParamDescription(),
1928
2319
  }),
1929
2320
  ),
1930
2321
  thinking: Type.Optional(
@@ -1945,7 +2336,7 @@ Terse command-style prompts produce shallow, generic work.
1945
2336
  ),
1946
2337
  resume: Type.Optional(
1947
2338
  Type.String({
1948
- description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
2339
+ description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run. An evicted agent's conversation is reopened from disk, background only, under a NEW Agent ID: use that id from then on (the old one also resolves to it).",
1949
2340
  }),
1950
2341
  ),
1951
2342
  isolated: Type.Optional(
@@ -2040,7 +2431,7 @@ Terse command-style prompts produce shallow, generic work.
2040
2431
  // foreground path. Resume deliberately doesn't: it replays the stored
2041
2432
  // session and ignores `subagent_type` entirely, so a note about type
2042
2433
  // substitution would be describing something that didn't happen.
2043
- const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
2434
+ const typeNote = dispatch.ok && dispatch.fellBackFrom !== undefined
2044
2435
  ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2045
2436
  : "";
2046
2437
 
@@ -2050,36 +2441,22 @@ Terse command-style prompts produce shallow, generic work.
2050
2441
  const customConfig = getAgentConfig(subagentType);
2051
2442
 
2052
2443
  const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
2053
- agentType: subagentType,
2054
2444
  worktreeAllowed: isWorktreeIsolationEnabled(),
2055
2445
  defaultRunInBackground: getBackgroundByDefault(),
2056
2446
  });
2057
2447
 
2058
- // Resolve an explicit tool-call model first; agent config supplies only a default.
2059
- let model = ctx.model;
2060
- if (resolvedConfig.modelInput) {
2061
- const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
2062
- if (typeof resolved === "string") {
2063
- if (resolvedConfig.modelFromParams) return textResult(resolved);
2064
- // config-specified: silent fallback to parent
2065
- } else {
2066
- model = resolved;
2067
- }
2068
- }
2069
-
2070
- // Scope validation: the effective resolved model is checked against the
2071
- // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
2072
- // in model-scope.ts so the nested delegation tools apply the same rule.
2073
- const scopeVerdict = checkModelScope({
2074
- model,
2075
- cwd: ctx.cwd,
2076
- modelRegistry: ctx.modelRegistry,
2077
- callerSupplied: resolvedConfig.modelFromParams,
2078
- agentLabel: customConfig?.displayName ?? subagentType,
2079
- modelInput: resolvedConfig.modelInput,
2448
+ // A live resume keeps its record's selection, ignoring new model inputs.
2449
+ const selection = params.resume ? undefined : selectModel({
2450
+ homeDir: options.homeDir,
2451
+ agentType: subagentType, definitionModel: customConfig?.model, paramModel: params.model,
2452
+ parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd, agentLabel: customConfig?.displayName,
2080
2453
  });
2081
- if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
2082
- if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
2454
+ if (typeof selection === "string") return { ...textResult(selection), isError: true };
2455
+ const tierBlock = selection?.tierBinding && !params.schedule ? await admitModelSelection(selection, ctx.modelRegistry, pi.events) : undefined;
2456
+ const model = selection?.model;
2457
+ const modelFallback = selection?.fallback;
2458
+
2459
+ if (selection?.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
2083
2460
 
2084
2461
  const thinking = resolvedConfig.thinking;
2085
2462
  const inheritContext = resolvedConfig.inheritContext;
@@ -2088,7 +2465,7 @@ Terse command-style prompts produce shallow, generic work.
2088
2465
  const isolation = resolvedConfig.isolation;
2089
2466
  // Undefined = the session cwd. The manager validates it (absolute,
2090
2467
  // existing directory) at spawn and again at start.
2091
- const cwd = resolveCwdParam(params.cwd, ctx.cwd);
2468
+ const cwd = resolveCwdParam(params.cwd, ctx.cwd, options.homeDir);
2092
2469
  // Whether this spawn writes its .output transcript. Per-agent
2093
2470
  // frontmatter (`output_transcript`) wins; otherwise the project/global
2094
2471
  // default applies. `attachTranscript` below is the SOLE gate — every
@@ -2106,9 +2483,7 @@ Terse command-style prompts produce shallow, generic work.
2106
2483
  // parent's model used to show the level with nothing to attach it to.
2107
2484
  // This is the pre-session snapshot — agent-manager overwrites it with the
2108
2485
  // effective values the moment a session reports them.
2109
- const { modelName, modelId } = model
2110
- ? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model)
2111
- : { modelName: undefined, modelId: undefined };
2486
+ const { modelName, modelId } = describeSelection(selection, ctx.modelRegistry);
2112
2487
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
2113
2488
  const agentInvocation: AgentInvocation = {
2114
2489
  modelName,
@@ -2126,11 +2501,34 @@ Terse command-style prompts produce shallow, generic work.
2126
2501
  const modeLabel = getPromptModeLabel(subagentType);
2127
2502
  const { tags: invocationTags } = buildInvocationTags(agentInvocation);
2128
2503
  const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
2504
+ /**
2505
+ * The lead-in for a result: the type note, then the model note. The model
2506
+ * note is read off the record the shared spawn path created. Before a
2507
+ * record exists (scheduling) it is this call's own resolution, the only
2508
+ * one there is. Never recomputed for a resume, which runs on its old session.
2509
+ */
2510
+ const modelNoteOf = (rec: AgentRecord | undefined): string | undefined =>
2511
+ modelSelectionNote(rec ?? { modelFallback, modelTier: selection?.tier }) || undefined;
2512
+ const modelFallbackOf = (rec: AgentRecord | undefined): ModelFallback | undefined =>
2513
+ rec ? rec.modelFallback : modelFallback;
2514
+ /** A resume's lead-in: the record's note only (the type note describes a spawn, not a resume). */
2515
+ const resumeNoteFor = (rec: AgentRecord): string => {
2516
+ const note = [modelNoteOf(rec), modelIdentityNote(rec)].filter(Boolean).join("\n");
2517
+ return note ? `${note}\n\n` : "";
2518
+ };
2519
+ const fallbackNoteFor = (rec: AgentRecord | undefined): string => {
2520
+ // No record means a schedule or quota park, reselected at fire time: no identity claim.
2521
+ const modelNote = [modelNoteOf(rec), modelIdentityNote(rec)].filter(Boolean).join("\n");
2522
+ return typeNote + (modelNote ? `${modelNote}\n\n` : "");
2523
+ };
2129
2524
  const detailBase = {
2130
2525
  displayName,
2131
2526
  description: params.description,
2132
2527
  subagentType,
2133
2528
  modelName,
2529
+ modelNote: modelNoteOf(undefined),
2530
+ modelFallback: modelFallbackOf(undefined),
2531
+ modelTier: selection?.tier ? tierDisclosure(selection.tier) : undefined,
2134
2532
  tags: agentTags.length > 0 ? agentTags : undefined,
2135
2533
  };
2136
2534
 
@@ -2148,7 +2546,10 @@ Terse command-style prompts produce shallow, generic work.
2148
2546
  * buildInvocationTags would silently drop `twin`.
2149
2547
  */
2150
2548
  const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
2151
- if (!rec?.invocation) return detailBase;
2549
+ // A record's disclosure outranks this call's, even before its session
2550
+ // reports an invocation: resume reuses the old session, so this call's
2551
+ // config says nothing about what ran.
2552
+ if (!rec?.invocation) return rec ? { ...detailBase, modelNote: modelNoteOf(rec), modelFallback: modelFallbackOf(rec), modelTier: rec.modelTier } : detailBase;
2152
2553
  const type = rec.type;
2153
2554
  const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
2154
2555
  const recModeLabel = getPromptModeLabel(type);
@@ -2158,6 +2559,9 @@ Terse command-style prompts produce shallow, generic work.
2158
2559
  description: rec.description,
2159
2560
  subagentType: type,
2160
2561
  modelName: recModelName,
2562
+ modelNote: modelNoteOf(rec),
2563
+ modelFallback: modelFallbackOf(rec),
2564
+ modelTier: rec.modelTier,
2161
2565
  tags: recTags.length > 0 ? recTags : undefined,
2162
2566
  };
2163
2567
  };
@@ -2182,6 +2586,7 @@ Terse command-style prompts produce shallow, generic work.
2182
2586
  if (!scheduler.isActive()) {
2183
2587
  return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
2184
2588
  }
2589
+ if (planningNow(pi.events)) return textResult(PLAN_SCHEDULE_REFUSAL);
2185
2590
  try {
2186
2591
  const job = scheduler.addJob({
2187
2592
  name: params.description as string,
@@ -2191,7 +2596,7 @@ Terse command-style prompts produce shallow, generic work.
2191
2596
  // at fire time, and the original is what a user edits.
2192
2597
  subagent_type: requestedType,
2193
2598
  prompt: params.prompt as string,
2194
- model: params.model as string | undefined,
2599
+ model: selection?.requested?.startsWith("@") ? selection.requested : params.model as string | undefined,
2195
2600
  thinking: thinking,
2196
2601
  max_turns: effectiveMaxTurns,
2197
2602
  isolated: isolated,
@@ -2199,23 +2604,62 @@ Terse command-style prompts produce shallow, generic work.
2199
2604
  });
2200
2605
  const next = scheduler.getNextRun(job.id);
2201
2606
  return textResult(
2202
- `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2607
+ `${fallbackNoteFor(undefined)}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2203
2608
  `Next run: ${next ?? "(unknown)"}. ` +
2204
2609
  `Manage via /agents → Scheduled jobs.`,
2205
2610
  );
2206
2611
  } catch (err) {
2207
- return textResult(err instanceof Error ? err.message : String(err));
2612
+ return textResult(errorMessage(err));
2208
2613
  }
2209
2614
  }
2210
2615
 
2211
2616
  // Resume existing agent
2212
2617
  if (params.resume) {
2213
- const existing = manager.getRecord(params.resume);
2214
- if (!existing || !isTopLevelAgent(existing)) {
2618
+ // An evicted id that was reopened resolves to the newest record of the
2619
+ // same conversation, so it never reopens the session file twice (#330).
2620
+ const latest = manager.latestId(params.resume);
2621
+ const continued = latest === params.resume ? "" : `Agent ${params.resume} continues as ${latest}; use that id from now on.\n`;
2622
+ const existing = manager.getRecord(latest);
2623
+ if (!existing) {
2624
+ // Evicted by the retention sweep, but a persisted conversation is
2625
+ // still on disk: reopen it, as `@handle` does.
2626
+ const entry = manager.listTombstones().find(t => t.id === latest);
2627
+ if (!entry) {
2628
+ // Evicted with no session file left to reopen: `rememberAgents` off, or the tombstone was dropped.
2629
+ const note = manager.getEvictedNote(latest);
2630
+ const gone = note?.nameTaken
2631
+ ? `its name "${note.nameTaken}" now belongs to another agent, so its saved session was not kept reopenable.`
2632
+ : "no saved session is left to reopen.";
2633
+ const why = note ? `Cannot resume agent "${latest}": ${gone}\n${describeEvicted(note)}` : agentNotFound(latest);
2634
+ return textResult(`${continued}${why}`);
2635
+ }
2636
+ // The reopen is a fresh detached spawn: nothing can block on it here.
2637
+ if (!runInBackground) {
2638
+ return textResult(
2639
+ `${continued}Agent "${latest}" was evicted; its conversation reopens only in the background, under a new Agent ID. ` +
2640
+ "Call Agent again with this resume and run_in_background: true.",
2641
+ );
2642
+ }
2643
+ const reopened = await reopenEvicted(ctx, entry, params.prompt, {
2644
+ maxTurns: getAgentConfig(entry.type)?.maxTurns ?? params.max_turns,
2645
+ });
2646
+ if (!reopened.ok) return textResult(`${continued}${couldNotResume(`agent "${latest}"`, reopened.failure)}`);
2647
+ // No `.output` line: like every non-tool spawn it writes none, and
2648
+ // the session file it reopened is the full transcript.
2649
+ return textResult(
2650
+ `${continued}Agent resumed in background, reopening evicted agent ${latest}.\n` +
2651
+ `Agent ID: ${reopened.id} (new; ${params.resume} also resolves to it)\n` +
2652
+ `Type: ${entry.type}\n` +
2653
+ `Session file: ${entry.sessionFile}\n` +
2654
+ `\nYou will be notified when this agent completes.\n` +
2655
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2656
+ );
2657
+ }
2658
+ if (!isTopLevelAgent(existing)) {
2215
2659
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
2216
2660
  }
2217
2661
  if (!existing.session) {
2218
- return textResult(`Agent "${params.resume}" has no active session to resume.`);
2662
+ return textResult(`${continued}Agent "${existing.id}" has no active session to resume.`);
2219
2663
  }
2220
2664
  // Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
2221
2665
  // a resume ignores, so it may describe another agent.
@@ -2233,7 +2677,7 @@ Terse command-style prompts produce shallow, generic work.
2233
2677
  // instead of letting it read as a generic failure.
2234
2678
  if (existing.status === "running" || existing.status === "queued") {
2235
2679
  return textResult(
2236
- `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2680
+ `${continued}Agent "${existing.id}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2237
2681
  `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2238
2682
  );
2239
2683
  }
@@ -2252,7 +2696,7 @@ Terse command-style prompts produce shallow, generic work.
2252
2696
 
2253
2697
  const isQueued = record.status === "queued";
2254
2698
  return textResult(
2255
- `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2699
+ `${continued}${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2256
2700
  `Agent ID: ${id}\n` +
2257
2701
  `Type: ${existing.type}\n` +
2258
2702
  (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
@@ -2275,7 +2719,7 @@ Terse command-style prompts produce shallow, generic work.
2275
2719
  fleet.update();
2276
2720
  let record: AgentRecord | undefined;
2277
2721
  try {
2278
- record = await manager.resume(params.resume, params.prompt, signal, {
2722
+ record = await manager.resume(existing.id, params.prompt, signal, {
2279
2723
  maxTurns: resumeMaxTurns,
2280
2724
  onTurnEnd: resumeCallbacks.onTurnEnd,
2281
2725
  onTextDelta: resumeCallbacks.onTextDelta,
@@ -2294,10 +2738,10 @@ Terse command-style prompts produce shallow, generic work.
2294
2738
  // A failed resume surfaces the error, plus any partial output THIS
2295
2739
  // resume produced (never the previous turn's answer, #144).
2296
2740
  if (record.status === "error") {
2297
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
2741
+ return textResult(`${resumeNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
2298
2742
  }
2299
2743
  return textResult(
2300
- record.result?.trim() || "No output.",
2744
+ `${continued}${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
2301
2745
  buildDetails(detailBaseFor(record), record, resumeState),
2302
2746
  );
2303
2747
  }
@@ -2308,7 +2752,7 @@ Terse command-style prompts produce shallow, generic work.
2308
2752
  const backgroundResult = (id: string, record: AgentRecord | undefined) => {
2309
2753
  const isQueued = record?.status === "queued";
2310
2754
  return textResult(
2311
- `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2755
+ `${fallbackNoteFor(record)}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2312
2756
  `Agent ID: ${id}\n` +
2313
2757
  `Type: ${displayName}\n` +
2314
2758
  `Description: ${params.description}\n` +
@@ -2327,21 +2771,25 @@ Terse command-style prompts produce shallow, generic work.
2327
2771
  // exhaustion policy says fail now (`wait: false`). Anything else fails
2328
2772
  // now, with the reset, rather than starting on an exhausted provider.
2329
2773
  const dispatchModel = model ?? ctx.model;
2330
- const quotaBlock = dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
2774
+ const quotaBlock = selection?.tierBinding ? tierBlock
2775
+ : dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
2331
2776
  if (quotaBlock) {
2332
2777
  // A parked job is a scheduled job, which has no `cwd` to carry.
2333
2778
  const canPark = runInBackground && !inheritContext && cwd === undefined && mayPark(quotaBlock)
2334
2779
  && isSchedulingEnabled() && scheduler.isActive();
2335
2780
  if (!canPark || !mayPark(quotaBlock)) {
2336
- throw new Error(`${describeBlock(quotaBlock)} The agent was not started.`);
2781
+ throw new Error(`${selection?.tier ? `${modelNoteOf(undefined)}\n` : ""}${describeBlock(quotaBlock)} The agent was not started.`);
2337
2782
  }
2783
+ if (planningNow(pi.events)) throw new Error(`${describeBlock(quotaBlock)} ${PLAN_PARK_REFUSAL}`);
2338
2784
  const job = scheduler.addJob({
2339
2785
  name: `${params.description} (quota ${nanoid(4)})`,
2340
2786
  description: params.description as string,
2341
2787
  schedule: new Date(quotaBlock.resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
2342
2788
  subagent_type: requestedType,
2343
2789
  prompt: params.prompt as string,
2344
- model: dispatchModel ? modelRef(dispatchModel) : undefined,
2790
+ // Keep the request, not today's configured/parent pick. Fire-time
2791
+ // selection must still apply agentModels and aliases.
2792
+ model: selection?.requested?.startsWith("@") ? selection.requested : params.model,
2345
2793
  thinking,
2346
2794
  max_turns: effectiveMaxTurns,
2347
2795
  isolated,
@@ -2349,8 +2797,8 @@ Terse command-style prompts produce shallow, generic work.
2349
2797
  quotaParked: true,
2350
2798
  });
2351
2799
  return textResult(
2352
- `${fallbackNote}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
2353
- `${scheduler.getNextRun(job.id) ?? job.schedule}. Manage via /agents → Scheduled jobs.`,
2800
+ `${fallbackNoteFor(undefined)}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
2801
+ `${job.schedule}. Manage via /agents → Scheduled jobs.`,
2354
2802
  );
2355
2803
  }
2356
2804
 
@@ -2359,8 +2807,7 @@ Terse command-style prompts produce shallow, generic work.
2359
2807
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns, undefined, clock);
2360
2808
 
2361
2809
  // Wrap onSessionCreated to wire output file streaming.
2362
- // The callback lazily reads record.outputFile (set right after spawn)
2363
- // rather than closing over a value that doesn't exist yet.
2810
+ // The callback reads record.outputFile prepared before launch.
2364
2811
  let id: string;
2365
2812
  const origBgOnSession = bgCallbacks.onSessionCreated;
2366
2813
  bgCallbacks.onSessionCreated = (session: any) => {
@@ -2375,6 +2822,7 @@ Terse command-style prompts produce shallow, generic work.
2375
2822
  // tool call failed, whereas a returned message reads to the model as a
2376
2823
  // subagent that ran and reported this (#179).
2377
2824
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2825
+ modelSelection: selection,
2378
2826
  description: params.description,
2379
2827
  name: params.name as string | undefined,
2380
2828
  model,
@@ -2387,18 +2835,17 @@ Terse command-style prompts produce shallow, generic work.
2387
2835
  cwd,
2388
2836
  invocation: agentInvocation,
2389
2837
  rootSessionId: ctx.sessionManager.getSessionId(),
2838
+ onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
2390
2839
  ...bgCallbacks,
2391
2840
  });
2392
2841
 
2393
- // Set output file + join mode synchronously after spawn, before the
2394
- // event loop yields — onSessionCreated is async so this is safe.
2395
- const joinMode = resolveJoinMode(defaultJoinMode, true);
2396
- const record = manager.getRecord(id);
2397
- if (record && joinMode) {
2398
- record.joinMode = joinMode;
2399
- record.toolCallId = toolCallId;
2400
- attachTranscript(record, id);
2401
- }
2842
+ // `spawn` has not yielded yet, so the join mode and tool-call id are set before the
2843
+ // record's session starts.
2844
+ const joinMode = defaultJoinMode;
2845
+ // `spawn` registered the record before returning its id.
2846
+ const record = manager.getRecord(id)!;
2847
+ record.joinMode = joinMode;
2848
+ record.toolCallId = toolCallId;
2402
2849
 
2403
2850
  // With isolation: "worktree" the agent isn't running yet — the repo
2404
2851
  // copy is an awaited git call. Wait for it here, after the synchronous
@@ -2420,6 +2867,7 @@ Terse command-style prompts produce shallow, generic work.
2420
2867
  type: subagentType,
2421
2868
  description: params.description,
2422
2869
  isBackground: true,
2870
+ ...eventFields(manager.getRecord(id)),
2423
2871
  });
2424
2872
 
2425
2873
  return backgroundResult(id, record);
@@ -2478,8 +2926,7 @@ Terse command-style prompts produce shallow, generic work.
2478
2926
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate, clock);
2479
2927
 
2480
2928
  // Wire session creation: register in widget + stream to output file.
2481
- // The output file path is set synchronously after spawn (below),
2482
- // before onSessionCreated fires — same pattern as background agents.
2929
+ // The output file path is prepared before launch, as for background agents.
2483
2930
  const origOnSession = fgCallbacks.onSessionCreated;
2484
2931
  fgCallbacks.onSessionCreated = (session: any) => {
2485
2932
  origOnSession(session);
@@ -2497,16 +2944,11 @@ Terse command-style prompts produce shallow, generic work.
2497
2944
  widget.ensureTimer();
2498
2945
  fleet.ensureTimer();
2499
2946
  fleet.update();
2947
+ // Stream conversation to output file (foreground agent logging)
2948
+ if (a.outputFile) a.outputCleanup = streamToOutputFile(session, a.outputFile, a.id, ctx.cwd);
2500
2949
  break;
2501
2950
  }
2502
2951
  }
2503
- // Stream conversation to output file (foreground agent logging)
2504
- if (fgId) {
2505
- const rec = manager.getRecord(fgId);
2506
- if (rec?.outputFile) {
2507
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
2508
- }
2509
- }
2510
2952
  };
2511
2953
 
2512
2954
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
@@ -2528,6 +2970,7 @@ Terse command-style prompts produce shallow, generic work.
2528
2970
  let fgResult: { record: AgentRecord } | undefined;
2529
2971
  try {
2530
2972
  fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2973
+ modelSelection: selection,
2531
2974
  description: params.description,
2532
2975
  name: params.name as string | undefined,
2533
2976
  model,
@@ -2544,12 +2987,12 @@ Terse command-style prompts produce shallow, generic work.
2544
2987
  // widget and the `finally` cleanup below, none of which should see an
2545
2988
  // agent that has no session and may never get one.
2546
2989
  onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2990
+ onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
2547
2991
  ...fgCallbacks,
2548
2992
  }, (fgAgentId) => {
2549
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2550
- // Set up the output file so streamToOutputFile can pick it up.
2551
- const fgRec = manager.getRecord(fgAgentId);
2552
- attachTranscript(fgRec, fgAgentId);
2993
+ // onSpawned fires after the run promise exists, before onSessionCreated.
2994
+ // Running, so the manager still holds it: only settled records are removed.
2995
+ const fgRec = manager.getRecord(fgAgentId)!;
2553
2996
  // Fires once the run promise exists, i.e. the agent is running. From
2554
2997
  // here until this call returns, the run can be handed off.
2555
2998
  handoffId = fgAgentId;
@@ -2557,10 +3000,8 @@ Terse command-style prompts produce shallow, generic work.
2557
3000
  // What a background spawn sets up front: the notification names
2558
3001
  // this tool call, and it reports alone — its spawn batch is long
2559
3002
  // closed.
2560
- if (fgRec) {
2561
- fgRec.toolCallId = toolCallId;
2562
- fgRec.joinMode = "async";
2563
- }
3003
+ fgRec.toolCallId = toolCallId;
3004
+ fgRec.joinMode = "async";
2564
3005
  backgrounded = true;
2565
3006
  wakeBackgrounded();
2566
3007
  });
@@ -2591,10 +3032,10 @@ Terse command-style prompts produce shallow, generic work.
2591
3032
 
2592
3033
  if (record.status === "error") {
2593
3034
  // Error headline + any partial output the run produced before failing.
2594
- return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
3035
+ return textResult(`${fallbackNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
2595
3036
  }
2596
3037
 
2597
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
3038
+ const durationMs = recordElapsedMs(record, Date.now());
2598
3039
  const statsParts = [`${record.toolUses} tool uses`];
2599
3040
  if (tokenText) statsParts.push(tokenText);
2600
3041
  if (showCost) {
@@ -2602,7 +3043,7 @@ Terse command-style prompts produce shallow, generic work.
2602
3043
  if (costText) statsParts.push(costText);
2603
3044
  }
2604
3045
  return textResult(
2605
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
3046
+ `${fallbackNoteFor(record)}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status, record.stoppedBy)}.\n\n` +
2606
3047
  (record.result?.trim() || "No output."),
2607
3048
  details,
2608
3049
  );
@@ -2685,13 +3126,15 @@ Terse command-style prompts produce shallow, generic work.
2685
3126
  // run's progress log here would put O(log) work in the render loop.
2686
3127
  return [...workflowTasks.values()].map(task => ({
2687
3128
  id: task.id,
2688
- name: task.meta?.name ?? task.workflowName ?? task.id,
3129
+ // Every task here is created from a parsed `meta`, which also names the run.
3130
+ name: task.meta!.name,
2689
3131
  status: task.status,
2690
3132
  doneCount: task.doneCount,
2691
3133
  totalCount: task.agentCount,
2692
3134
  startedAt: task.startTime,
2693
3135
  ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2694
3136
  tokens: task.totalTokens,
3137
+ observationKey: task,
2695
3138
  }));
2696
3139
  }
2697
3140
 
@@ -2708,6 +3151,7 @@ Terse command-style prompts produce shallow, generic work.
2708
3151
  args: task.args,
2709
3152
  signal: task.abortController.signal,
2710
3153
  host: createWorkflowHost({
3154
+ homeDir: options.homeDir,
2711
3155
  pi,
2712
3156
  ctx,
2713
3157
  manager,
@@ -2728,7 +3172,7 @@ Terse command-style prompts produce shallow, generic work.
2728
3172
  });
2729
3173
  completeWorkflowTask(task, result);
2730
3174
  } catch (err) {
2731
- failWorkflowTask(task, err instanceof Error ? err.message : String(err));
3175
+ failWorkflowTask(task, errorMessage(err));
2732
3176
  }
2733
3177
  }
2734
3178
 
@@ -2745,7 +3189,7 @@ Terse command-style prompts produce shallow, generic work.
2745
3189
  content: formatWorkflowNotification(task),
2746
3190
  details: {
2747
3191
  id: task.id,
2748
- description: `Workflow ${task.workflowName ?? task.id}`,
3192
+ description: `Workflow ${task.meta!.name}`,
2749
3193
  status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2750
3194
  toolUses: task.totalToolCalls,
2751
3195
  // A workflow has agents, not turns; rendering "↻0" would be noise.
@@ -2865,6 +3309,7 @@ Terse command-style prompts produce shallow, generic work.
2865
3309
  ? { scriptPath: resumeFrom.scriptPath }
2866
3310
  : params,
2867
3311
  ctx.cwd,
3312
+ options.homeDir,
2868
3313
  );
2869
3314
  if (!resolved.ok) return textResult(resolved.message);
2870
3315
 
@@ -2875,7 +3320,7 @@ Terse command-style prompts produce shallow, generic work.
2875
3320
  try {
2876
3321
  meta = extractMeta(resolved.script).meta;
2877
3322
  } catch (err) {
2878
- return textResult(err instanceof Error ? err.message : String(err));
3323
+ return textResult(errorMessage(err));
2879
3324
  }
2880
3325
 
2881
3326
  const runId = workflowRunId();
@@ -2893,7 +3338,7 @@ Terse command-style prompts produce shallow, generic work.
2893
3338
  } catch (err) {
2894
3339
  savedPath = undefined;
2895
3340
  journalPath = undefined;
2896
- console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
3341
+ console.warn(`[pi-subagents] could not persist workflow script: ${errorMessage(err)}`);
2897
3342
  }
2898
3343
 
2899
3344
  const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
@@ -2928,7 +3373,7 @@ Terse command-style prompts produce shallow, generic work.
2928
3373
  `Task ID: ${runId}\n` +
2929
3374
  (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2930
3375
  (task.resumedFrom !== undefined
2931
- ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
3376
+ ? `Resuming ${task.resumedFrom}: ${task.replay!.length} recorded call(s) available to replay.\n`
2932
3377
  : params.resumeFromRunId !== undefined
2933
3378
  ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2934
3379
  : "") +
@@ -3053,7 +3498,7 @@ Terse command-style prompts produce shallow, generic work.
3053
3498
  try {
3054
3499
  script = readFileSync(path, "utf-8");
3055
3500
  } catch (err) {
3056
- report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
3501
+ report(`Could not read ${path}: ${errorMessage(err)}`, "warning");
3057
3502
  return;
3058
3503
  }
3059
3504
 
@@ -3061,7 +3506,7 @@ Terse command-style prompts produce shallow, generic work.
3061
3506
  try {
3062
3507
  meta = extractMeta(script).meta;
3063
3508
  } catch (err) {
3064
- report(err instanceof Error ? err.message : String(err), "warning");
3509
+ report(errorMessage(err), "warning");
3065
3510
  return;
3066
3511
  }
3067
3512
 
@@ -3100,7 +3545,7 @@ Terse command-style prompts produce shallow, generic work.
3100
3545
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
3101
3546
  label: "Get Agent Result",
3102
3547
  description:
3103
- "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
3548
+ "Check status and retrieve a background agent's full result: use it while the agent runs, or when its completion notification says the result was truncated. Use the agent ID returned by Agent.",
3104
3549
  promptSnippet: "Check status and retrieve results from a background agent",
3105
3550
  parameters: Type.Object({
3106
3551
  agent_id: Type.String({
@@ -3149,8 +3594,10 @@ Terse command-style prompts produce shallow, generic work.
3149
3594
 
3150
3595
  let output =
3151
3596
  `Agent: ${record.id}\n` +
3152
- `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
3153
- `Description: ${record.description}\n\n`;
3597
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status, record.stoppedBy)} | ${statsParts.join(" | ")}\n` +
3598
+ `Description: ${record.description}\n` +
3599
+ (modelSelectionNote(record) ? `${modelSelectionNote(record)}\n` : "") +
3600
+ `\n`;
3154
3601
 
3155
3602
  if (record.status === "running") {
3156
3603
  output += "Agent is still running. Use wait: true or check back later.";
@@ -3163,6 +3610,7 @@ Terse command-style prompts produce shallow, generic work.
3163
3610
  // Mark result as consumed — suppresses the completion notification
3164
3611
  if (record.status !== "running" && record.status !== "queued") {
3165
3612
  markConsumed(record, clock.now());
3613
+ fleet.acknowledgeFailure(record.id);
3166
3614
  outbox.cancel(record.id);
3167
3615
  }
3168
3616
 
@@ -3184,23 +3632,46 @@ Terse command-style prompts produce shallow, generic work.
3184
3632
  name: SUBAGENT_TOOL_NAMES.STEER,
3185
3633
  label: "Steer Agent",
3186
3634
  description:
3187
- "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
3188
- "and be injected into its conversation, allowing you to redirect its work mid-run. A queued agent gets the message when it starts.",
3189
- promptSnippet: "Send a steering message to redirect a running background agent",
3635
+ "Send a message to an agent. A running agent is interrupted after its current tool execution and the message is injected into its conversation, " +
3636
+ "allowing you to redirect its work mid-run. A queued agent gets the message when it starts. A finished agent is resumed in the background " +
3637
+ "with its full history and the message as its next prompt, exactly like Agent's resume; you will be notified when it completes. " +
3638
+ "A stopped agent is not resumed. To end an agent's run, use stop_subagent: a steer is read only after its current tool call, and the agent may ignore it.",
3639
+ promptSnippet: "Send a message to a background agent: steer it mid-run, or resume it once finished",
3190
3640
  parameters: Type.Object({
3191
3641
  agent_id: Type.String({
3192
- description: "The agent ID to steer (must be running or queued). The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
3642
+ description: "The agent ID to message. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
3193
3643
  }),
3194
3644
  message: Type.String({
3195
- description: "The steering message to send. This will appear as a user message in the agent's conversation.",
3645
+ description: "The message to send. This will appear as a user message in the agent's conversation.",
3196
3646
  }),
3197
3647
  }),
3198
- execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
3199
- const record = resolveAgentRef(params.agent_id);
3200
- if (!record || !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
3201
- if (record.status !== "running" && record.status !== "queued") {
3202
- return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a finished agent.`);
3648
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
3649
+ // An evicted id that was reopened answers as the record it continues under (#330).
3650
+ const latest = manager.latestId(params.agent_id);
3651
+ const record = resolveAgentRef(latest);
3652
+ if (record && !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
3653
+ if (record?.status !== "running" && record?.status !== "queued") {
3654
+ // Finished: continue it, as Claude Code's SendMessage does. An evicted
3655
+ // record is known only by its note; Agent's resume reopens its session
3656
+ // from disk or explains why it cannot.
3657
+ const finished = record ?? manager.getEvictedNote(latest);
3658
+ if (!finished) return textResult(agentNotFound(params.agent_id));
3659
+ if (finished.status === "stopped") {
3660
+ return textResult(`Agent "${finished.id}" was stopped (status: stopped). steer_subagent does not resume a stopped agent.`);
3661
+ }
3662
+ // The Agent tool's own resume path, not a copy: same reopen, turn
3663
+ // budget, transcript and result. `subagent_type` is the record's own,
3664
+ // so its frontmatter governs as it does for an `@handle` resume.
3665
+ return agentTool.execute(toolCallId, {
3666
+ resume: latest === params.agent_id ? finished.id : params.agent_id,
3667
+ prompt: params.message,
3668
+ description: finished.description,
3669
+ subagent_type: finished.type,
3670
+ run_in_background: true,
3671
+ }, signal, onUpdate, ctx);
3203
3672
  }
3673
+ // Running or queued under no Plan policy: a steer from a planning parent would drive edits.
3674
+ if (planSteerBlocked(record, pi.events)) return textResult(planSteerRefusal(record.id));
3204
3675
  if (!record.session) {
3205
3676
  // Queued for a concurrency slot, or running with its session still
3206
3677
  // being built: either way the manager flushes `pendingSteers` into the
@@ -3232,32 +3703,134 @@ Terse command-style prompts produce shallow, generic work.
3232
3703
  `Current state: ${stateParts.join(" · ")}`,
3233
3704
  );
3234
3705
  } catch (err) {
3235
- return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
3706
+ return textResult(`Failed to steer agent: ${errorMessage(err)}`);
3236
3707
  }
3237
3708
  },
3238
3709
  }));
3239
3710
 
3711
+ // ---- stop_subagent tool ----
3712
+
3713
+ /** A workflow run a stop can still reach: running or paused, not already told to stop. */
3714
+ const isLiveWorkflow = (task: WorkflowTask) => (task.status === "running" || task.status === "paused") && !task.abortController.signal.aborted;
3715
+
3716
+ /** Survivors of a session switch, still running in the orphaned manager (#330). */
3717
+ const runningSurvivors = () => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.());
3718
+
3719
+ /**
3720
+ * Agents a stop can reach: this session's own running or queued top-level
3721
+ * agents, then survivors. Never a nested or workflow child: those belong to
3722
+ * their owner, which stops them with itself (#446's ownership invariant).
3723
+ */
3724
+ function stoppableAgents(): AgentRecord[] {
3725
+ const own = manager.listAgents().filter(r => isTopLevelAgent(r) && (r.status === "running" || r.status === "queued"));
3726
+ return [...own, ...runningSurvivors().map(d => d.record).filter(r => r.status === "running")];
3727
+ }
3728
+
3729
+ /** Stop one top-level agent through the manager that owns it, this one or an orphan's. */
3730
+ function stopAgent(record: AgentRecord, by?: AgentRecord["stoppedBy"]): boolean {
3731
+ if (manager.getRecord(record.id) === record) return manager.abort(record.id, by);
3732
+ return !!runningSurvivors().find(d => d.record === record)?.abort(by);
3733
+ }
3734
+
3735
+ /** What a stop that matched nothing lists instead: everything still running or queued. */
3736
+ function describeStoppable(): string {
3737
+ const lines = [
3738
+ ...stoppableAgents().map(r => `- ${r.id} @${r.alias ?? r.handle} (${r.status}): ${r.description}`),
3739
+ ...[...workflowTasks.values()].filter(isLiveWorkflow).map(t => `- ${t.id} workflow "${t.meta!.name}" (${t.status})`),
3740
+ ];
3741
+ return lines.length > 0 ? `Running or queued:\n${lines.join("\n")}` : "Nothing is running or queued.";
3742
+ }
3743
+
3744
+ /**
3745
+ * Stop every running or queued top-level agent, survivor, and workflow run
3746
+ * (whose agents stop with it). A scheduled job that has not fired is not
3747
+ * running, so it is untouched. The human-side stop: records stay unmarked.
3748
+ */
3749
+ function stopAllRunning(): { agents: number; workflows: number } {
3750
+ const runs = [...workflowTasks.values()].filter(isLiveWorkflow);
3751
+ for (const task of runs) task.abortController.abort();
3752
+ return { agents: stoppableAgents().filter(record => stopAgent(record)).length, workflows: runs.length };
3753
+ }
3754
+
3755
+ registerToolReportingUsage(defineTool({
3756
+ name: SUBAGENT_TOOL_NAMES.STOP,
3757
+ label: "Stop Agent",
3758
+ description:
3759
+ "Stop (cancel, kill) a running or queued background agent, or a SubagentWorkflow run by the run id it returned. " +
3760
+ "Use it when an agent's work is no longer wanted, e.g. a sibling already answered. Unlike steer_subagent, the agent cannot ignore it: " +
3761
+ "the run is aborted at once, its current tool call gets the abort signal, and it starts no further tool call. Its status becomes `stopped`. " +
3762
+ "A running agent's partial output, and any worktree branch with changes, arrives in one completion notice; a queued agent never starts. " +
3763
+ "A workflow stops with all of its agents. A stopped agent is not resumed. " +
3764
+ "A run of another extension's `workflow` tool is not ours to stop: use that extension's control (e.g. `workflow_control` with action `stop`).",
3765
+ promptSnippet: "Stop or cancel a running background agent or workflow",
3766
+ // Plan mode admits a trusted tool only on `readOnlyHint`. Stopping edits no
3767
+ // file and starts nothing; it only ends work already running, so it must
3768
+ // stay callable in Plan, where an orchestrator should still be able to
3769
+ // stand a fan-out down.
3770
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3771
+ parameters: Type.Object({
3772
+ agent_id: Type.String({
3773
+ description: "The agent ID to stop. Its handle or name also works. A SubagentWorkflow run id stops that workflow run.",
3774
+ }),
3775
+ }),
3776
+ execute: async (_toolCallId, params) => {
3777
+ const task = workflowTasks.get(params.agent_id);
3778
+ if (task && isLiveWorkflow(task)) {
3779
+ // Marked first: the run's notice is written when it settles, after this returns.
3780
+ task.stoppedBy = "parent";
3781
+ task.abortController.abort();
3782
+ return textResult(`Workflow "${task.meta!.name}" (${task.id}) stopped, with its agents. A completion notice follows.`);
3783
+ }
3784
+ if (task && (task.status === "running" || task.status === "paused")) {
3785
+ // Told to stop, still unwinding: not "not running", and nothing more to do.
3786
+ return textResult(`Workflow "${task.meta!.name}" (${task.id}) is already stopping. A completion notice follows.`);
3787
+ }
3788
+ const latest = manager.latestId(params.agent_id);
3789
+ const found = resolveAgentRef(latest);
3790
+ const record = found && isTopLevelAgent(found) ? found : undefined;
3791
+ if (record?.status !== "running" && record?.status !== "queued") {
3792
+ const status = record?.status ?? task?.status ?? manager.getEvictedNote(latest)?.status;
3793
+ const what = status ? `"${params.agent_id}" is not running (status: ${status})` : `No agent or workflow "${params.agent_id}"`;
3794
+ return { ...textResult(`${what}, so there is nothing to stop.\n${describeStoppable()}`), isError: true };
3795
+ }
3796
+ const wasQueued = record.status === "queued";
3797
+ stopAgent(record, "parent");
3798
+ const statsParts = [`Tool uses: ${record.toolUses}`];
3799
+ const tokens = formatLifetimeTokens(record);
3800
+ if (tokens) statsParts.push(tokens);
3801
+ return textResult(
3802
+ `Agent ${record.id} stopped.\n` +
3803
+ `Type: ${getDisplayName(record.type)} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
3804
+ `Description: ${record.description}\n\n` +
3805
+ (wasQueued
3806
+ ? "It was still queued, so it never started and no completion notice follows."
3807
+ : "It starts no further tool call. One completion notice with its partial output follows."),
3808
+ );
3809
+ },
3810
+ }));
3811
+
3240
3812
  // ---- /agents interactive menu ----
3241
3813
 
3242
3814
  // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
3243
3815
  // so they are reachable from tests — this command handler is only registered
3244
3816
  // through `registerCommand`, which every test mocks.
3245
3817
 
3246
- function getModelLabel(type: string, registry?: ModelRegistry): string {
3818
+ function getModelLabel(type: string, registry: ModelRegistry, cwd: string): string {
3247
3819
  const cfg = getAgentConfig(type);
3248
- if (!cfg?.model) return "inherit"; // no model configured → really inherits parent
3249
- const label = getModelLabelFromConfig(cfg.model);
3250
- if (!registry) return label;
3251
- const resolved = resolveModel(cfg.model, registry);
3252
- // Configured but unresolvable: the runtime silently falls back to the parent
3253
- // model, so flag it (and the fallback) rather than hiding the config.
3254
- if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`;
3820
+ const input = modelRequest(type, cfg?.model).requested;
3821
+ if (!input) return "inherit";
3822
+ const label = getModelLabelFromConfig(input);
3823
+ const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd });
3824
+ if (typeof selection === "string" || selection.outcome === "fallback" || !selection.model) {
3825
+ return `${label} (unavailable, fallback: inherit)`;
3826
+ }
3827
+ const resolved = selection.model;
3255
3828
  // Surface what it actually resolved to when that differs from the config —
3256
3829
  // e.g. a provider fallback or a looser version pin. Cosmetic separator/date
3257
3830
  // differences are normalized away so an effectively-identical match stays quiet.
3258
3831
  const resolvedFull = `${resolved.provider}/${resolved.id}`;
3259
3832
  const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
3260
- if (norm(cfg.model) === norm(resolvedFull)) return label;
3833
+ if (norm(input) === norm(resolvedFull)) return label;
3261
3834
  return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`;
3262
3835
  }
3263
3836
 
@@ -3275,6 +3848,9 @@ Terse command-style prompts produce shallow, generic work.
3275
3848
  const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
3276
3849
  options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
3277
3850
  }
3851
+ // Survivors and workflow runs count too: they are what the chord stops.
3852
+ const stoppable = stoppableAgents().length + [...workflowTasks.values()].filter(isLiveWorkflow).length;
3853
+ if (stoppable > 0) options.push(`Stop all running (${stoppable})`);
3278
3854
 
3279
3855
  // Agent types list
3280
3856
  if (allNames.length > 0) {
@@ -3313,6 +3889,12 @@ Terse command-style prompts produce shallow, generic work.
3313
3889
  if (choice.startsWith("Running agents (")) {
3314
3890
  await showRunningAgents(ctx);
3315
3891
  await showAgentsMenu(ctx);
3892
+ } else if (choice.startsWith("Stop all running (")) {
3893
+ if (await ctx.ui.confirm("Stop all running", "Stop every running or queued agent and workflow?")) {
3894
+ const { agents: stoppedAgents, workflows } = stopAllRunning();
3895
+ ctx.ui.notify(`Stopped ${stoppedAgents} agent(s) and ${workflows} workflow(s).`, "info");
3896
+ }
3897
+ await showAgentsMenu(ctx);
3316
3898
  } else if (choice.startsWith("Agent types (")) {
3317
3899
  await showAllAgentsList(ctx);
3318
3900
  await showAgentsMenu(ctx);
@@ -3337,7 +3919,7 @@ Terse command-style prompts produce shallow, generic work.
3337
3919
  */
3338
3920
  function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
3339
3921
  if (cfg?.source !== "package" || !cfg.sourcePath) return undefined;
3340
- return packageNameForPath(cfg.sourcePath, process.cwd());
3922
+ return packageNameForPath(cfg.sourcePath, process.cwd(), "agents", { homeDir: options.homeDir });
3341
3923
  }
3342
3924
 
3343
3925
  async function showAllAgentsList(ctx: ExtensionCommandContext) {
@@ -3356,7 +3938,7 @@ Terse command-style prompts produce shallow, generic work.
3356
3938
  // it: the `▪` badge says a package won, not which one.
3357
3939
  const items: SettingItem[] = allNames.map((name, i) => {
3358
3940
  const cfg = configs[i];
3359
- const model = getModelLabel(name, ctx.modelRegistry);
3941
+ const model = getModelLabel(name, ctx.modelRegistry, ctx.cwd);
3360
3942
  return {
3361
3943
  id: name,
3362
3944
  label: `${sourceIndicator(cfg)}${name}`,
@@ -3391,8 +3973,9 @@ Terse command-style prompts produce shallow, generic work.
3391
3973
  };
3392
3974
  });
3393
3975
 
3394
- if (selected && getAgentConfig(selected)) {
3395
- await showAgentDetail(ctx, selected);
3976
+ const cfg = selected ? getAgentConfig(selected) : undefined;
3977
+ if (selected && cfg) {
3978
+ await showAgentDetail(ctx, selected, cfg);
3396
3979
  await showAllAgentsList(ctx);
3397
3980
  }
3398
3981
  }
@@ -3433,9 +4016,9 @@ Terse command-style prompts produce shallow, generic work.
3433
4016
  (tui, theme, keybindings, done) => {
3434
4017
  overlayOptions = getConversationOverlayOptions(tui);
3435
4018
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
3436
- if (manager.abort(record.id)) {
3437
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
3438
- }
4019
+ // The viewer offers stop only while the agent is running or queued, which `abort` always honours.
4020
+ manager.abort(record.id);
4021
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
3439
4022
  }, keybindings, (message: string) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), getFullscreenScrollbar(),
3440
4023
  backgroundActionFor(record, message => ctx.ui.notify(message, "info")), clock);
3441
4024
  },
@@ -3446,13 +4029,7 @@ Terse command-style prompts produce shallow, generic work.
3446
4029
  );
3447
4030
  }
3448
4031
 
3449
- async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
3450
- const cfg = getAgentConfig(name);
3451
- if (!cfg) {
3452
- ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
3453
- return;
3454
- }
3455
-
4032
+ async function showAgentDetail(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
3456
4033
  const isPackage = cfg.source === "package";
3457
4034
  const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source);
3458
4035
  const isDefault = cfg.isDefault === true;
@@ -3470,10 +4047,9 @@ Terse command-style prompts produce shallow, generic work.
3470
4047
  ? ["Eject (export as .md)", "Back"]
3471
4048
  : ["Eject (export as .md)", "Disable", "Back"];
3472
4049
  } else if (disabled && file) {
3473
- // Disabled agent with a file — offer Enable
3474
- menuOptions = isDefault
3475
- ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
3476
- : ["Enable", "Edit", "Delete", "Back"];
4050
+ // Disabled agent with a file — offer Enable. Never a built-in: a built-in is only ever
4051
+ // disabled by the very file that replaces it, which makes the entry a user definition.
4052
+ menuOptions = ["Enable", "Edit", "Delete", "Back"];
3477
4053
  } else if (isDefault && !file) {
3478
4054
  // Default agent with no .md override
3479
4055
  menuOptions = ["Eject (export as .md)", "Disable", "Back"];
@@ -3779,7 +4355,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
3779
4355
  else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
3780
4356
  else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
3781
4357
  else if (modelChoice === "custom...") {
3782
- model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
4358
+ // A blank answer writes no `model:` line, exactly as no answer does.
4359
+ model = await ctx.ui.input("Model (provider/modelId)");
3783
4360
  }
3784
4361
 
3785
4362
  // 5. Thinking
@@ -3831,6 +4408,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3831
4408
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3832
4409
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3833
4410
  graceTurns: getGraceTurns(),
4411
+ notificationResultChars,
3834
4412
  defaultJoinMode: getDefaultJoinMode(),
3835
4413
  backgroundByDefault: getBackgroundByDefault(),
3836
4414
  schedulingEnabled: isSchedulingEnabled(),
@@ -3862,6 +4440,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
3862
4440
  maxActiveSubagents: undefined,
3863
4441
  nestedSubagentReserve: undefined,
3864
4442
  modelAliases: undefined,
4443
+ modelTiers: undefined,
4444
+ modelResolution: undefined,
3865
4445
  agentModels: undefined,
3866
4446
  reportUsage: isReportUsageEnabled(),
3867
4447
  showCost: isShowCostEnabled(),
@@ -3909,6 +4489,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3909
4489
 
3910
4490
  const NUMERIC_IDS = new Set([
3911
4491
  "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
4492
+ "notificationResultChars",
3912
4493
  ]);
3913
4494
 
3914
4495
  async function showSettings(ctx: ExtensionCommandContext) {
@@ -3962,6 +4543,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3962
4543
  currentValue: String(msd),
3963
4544
  values: [String(msd)],
3964
4545
  },
4546
+ {
4547
+ id: "notificationResultChars",
4548
+ label: "Notice result chars",
4549
+ description: "Result characters a completion notice carries per agent (0 = 500-char preview, Enter to type)",
4550
+ currentValue: String(notificationResultChars),
4551
+ values: [String(notificationResultChars)],
4552
+ },
3965
4553
  {
3966
4554
  id: "joinMode",
3967
4555
  label: "Join mode",
@@ -4159,6 +4747,14 @@ Write the file using the write tool. Only write the file, nothing else.`;
4159
4747
  setGraceTurns(n);
4160
4748
  notifyApplied(ctx, `Grace turns set to ${n}`);
4161
4749
  }
4750
+ } else if (id === "notificationResultChars") {
4751
+ const n = parseInt(value, 10);
4752
+ if (n >= 0 && n <= NOTIFICATION_RESULT_CHARS_CEILING) {
4753
+ notificationResultChars = n;
4754
+ notifyApplied(ctx, n === 0
4755
+ ? "Completion notices carry a 500-char preview"
4756
+ : `Completion notices carry up to ${n} result chars per agent`);
4757
+ }
4162
4758
  } else if (id === "maxSubagentDepth") {
4163
4759
  const n = parseInt(value, 10);
4164
4760
  if (n >= 0) {
@@ -4300,7 +4896,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
4300
4896
  const enabled = value === "on";
4301
4897
  setRememberAgents(enabled);
4302
4898
  notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
4303
- } else if (id === "widgetMode") {
4899
+ } else {
4900
+ // widgetMode, the last row: every id `buildItems` offers has an arm.
4304
4901
  setWidgetMode(value as WidgetMode);
4305
4902
  notifyApplied(ctx, `Widget set to ${value}`);
4306
4903
  }
@@ -4360,7 +4957,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
4360
4957
  ? String(getDefaultMaxTurns() ?? 0)
4361
4958
  : result === "maxSubagentDepth"
4362
4959
  ? String(getMaxSubagentDepth())
4363
- : String(getGraceTurns());
4960
+ : result === "notificationResultChars"
4961
+ ? String(notificationResultChars)
4962
+ : String(getGraceTurns());
4364
4963
 
4365
4964
  const label = result === "maxConcurrent"
4366
4965
  ? "Max concurrency (1+)"
@@ -4370,7 +4969,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
4370
4969
  ? "Default max turns (0 = unlimited)"
4371
4970
  : result === "maxSubagentDepth"
4372
4971
  ? "Nested depth (0/1 = nesting off)"
4373
- : "Grace turns (1+)";
4972
+ : result === "notificationResultChars"
4973
+ ? `Notice result chars (0 = preview, max ${NOTIFICATION_RESULT_CHARS_CEILING})`
4974
+ : "Grace turns (1+)";
4374
4975
 
4375
4976
  // Loop until user enters a valid integer or cancels (Esc / null).
4376
4977
  // Silently trims whitespace; rejects non-numeric input by re-prompting.