@nredd/pi-subagents 0.22.0-nredd.8 → 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 (50) hide show
  1. package/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. 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
@@ -19,7 +20,7 @@ import { Type } from "typebox";
19
20
  import { abortable } from "./abortable.js";
20
21
  import { hasAgentBadge, renderAgentName } from "./agent-color.js";
21
22
  import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile as locateAgentFileAtHome, personalAgentsDir as personalAgentsDirAtHome, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
22
- import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.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,27 +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
45
  import { resolveAgentDir } from "./home-paths.js";
43
- import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam, resolveJoinMode } from "./invocation-config.js";
46
+ import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam } from "./invocation-config.js";
44
47
  import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
45
48
  import { runMentionClone } from "./mention-clone.js";
46
49
  import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
47
50
  import { type ModelFallback, type ModelRegistry, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
48
51
  import { isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
49
- import { admitModelSelection, describeSelection, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
50
- import { tierDisclosure } from "./model-tiers.js";
52
+ import { admitModelSelection, describeSelection, modelIdentityNote, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
53
+ import { configuredTierNames, tierDisclosure } from "./model-tiers.js";
51
54
  import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.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";
52
56
  import { appendModelFallbackEntry, appendModelTierEntry, createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
53
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";
54
59
  import { getBudgetPolicy, setBudgetPolicy } from "./process-budget.js";
55
60
  import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
56
61
  import { SubagentScheduler } from "./schedule.js";
57
62
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
58
63
  import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
59
64
  import { hostStartupErrorClass } from "./startup-error.js";
60
- import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
61
- 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";
62
67
  import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
63
68
  import { AgentCallView, AgentResultView, type AgentRowLive, keepRowTicking, keepTicking } from "./ui/agent-row.js";
64
69
  import {
@@ -115,7 +120,7 @@ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
115
120
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
116
121
  * Used by both foreground and background paths to avoid duplication.
117
122
  */
118
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, clock: Clock = realClock) {
123
+ function createActivityTracker(maxTurns: number | undefined, onStreamUpdate: (() => void) | undefined, clock: Clock) {
119
124
  const state: AgentActivity = {
120
125
  activeTools: new Map(),
121
126
  toolUses: 0,
@@ -171,6 +176,11 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, c
171
176
  */
172
177
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
173
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
+
174
184
  /** Human-readable status label for agent completion. */
175
185
  function getStatusLabel(status: string, error?: string): string {
176
186
  switch (status) {
@@ -182,10 +192,13 @@ function getStatusLabel(status: string, error?: string): string {
182
192
  }
183
193
  }
184
194
 
185
- /** Format a structured task notification matching Claude Code's <task-notification> XML. */
186
- 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 {
187
200
  const status = getStatusLabel(record.status, record.error);
188
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
201
+ const durationMs = recordElapsedMs(record, Date.now());
189
202
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
190
203
  const contextPercent = getSessionContextPercent(record.session);
191
204
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
@@ -195,24 +208,29 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
195
208
  const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
196
209
  const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
197
210
 
198
- const resultPreview = record.result
199
- ? record.result.length > resultMaxLen
200
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
201
- : record.result
202
- : "No output.";
211
+ const modelNote = [modelSelectionNote(record), modelIdentityNote(record)].filter(Boolean).join("\n");
212
+ const hint = resumeHint(record);
203
213
 
204
- return [
214
+ const head = [
205
215
  `<task-notification>`,
206
216
  `<task-id>${record.id}</task-id>`,
207
217
  record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
208
218
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
209
219
  `<status>${escapeXml(status)}</status>`,
210
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
211
- modelSelectionNote(record) ? `<model-note>${escapeXml(modelSelectionNote(record))}</model-note>` : null,
212
- `<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 = [
213
224
  `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
214
225
  `</task-notification>`,
215
- ].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.";
216
234
  }
217
235
 
218
236
  /** Build AgentDetails from a base + record-specific fields. */
@@ -232,7 +250,7 @@ function buildDetails(
232
250
  cost: getLifetimeCost(record.lifetimeUsage),
233
251
  turnCount: activity?.turnCount,
234
252
  maxTurns: activity?.maxTurns,
235
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
253
+ durationMs: recordElapsedMs(record, Date.now()),
236
254
  status: record.status as AgentDetails["status"],
237
255
  agentId: record.id,
238
256
  error: record.error,
@@ -242,6 +260,31 @@ function buildDetails(
242
260
  }
243
261
 
244
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
+ }
245
288
  function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
246
289
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
247
290
 
@@ -257,9 +300,10 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
257
300
  // data, and a notification rendered before a mid-session toggle should not
258
301
  // be stuck with the old answer.
259
302
  totalCost: getLifetimeCost(record.lifetimeUsage),
260
- durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
303
+ durationMs: recordElapsedMs(record, Date.now()),
261
304
  outputFile: record.outputFile,
262
305
  error: record.error,
306
+ resumeHint: resumeHint(record) || undefined,
263
307
  modelNote: modelSelectionNote(record) || undefined,
264
308
  modelTier: record.modelTier,
265
309
  resultPreview: record.result
@@ -364,7 +408,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
364
408
  try {
365
409
  getBudgetPolicy();
366
410
  } catch (error) {
367
- const message = error instanceof Error ? error.message : String(error);
411
+ const message = errorMessage(error);
368
412
  if (!StartupError) throw new Error(message + " This host also lacks ExtensionStartupError; upgrade pi before retrying.", { cause: error });
369
413
  pi.on("session_start", async () => { throw new StartupError(message, { cause: error }); });
370
414
  return;
@@ -402,7 +446,11 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
402
446
  });
403
447
 
404
448
  // Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
405
- setModelScopeProvider(registry => resolveEnabledModels(readEnabledModels(process.cwd(), options.homeDir), registry, process.cwd(), options.homeDir));
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
+ });
406
454
 
407
455
  // Read directly rather than waiting for applyAndEmitLoaded below: these decide
408
456
  // the initial load, which happens hundreds of lines before settings are applied.
@@ -483,6 +531,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
483
531
  persistSettings(ctx, `Viewer markdown set to ${mode}`);
484
532
  }
485
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;
486
536
 
487
537
  // ---- Completion delivery ----
488
538
  // Every completion notification goes through the outbox, which sends what is
@@ -490,15 +540,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
490
540
  // completion-outbox.ts). Each is built when the agent finishes, and reading
491
541
  // the result (get_subagent_result, rpc consume) cancels it before it is sent.
492
542
  const outbox = new CompletionOutbox(
493
- (message) => pi.sendMessage<NotificationDetails>({
494
- customType: "subagent-notification",
495
- content: message.content,
496
- display: true,
497
- details: message.details,
498
- }, { 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
+ },
499
563
  () => currentCtx?.signal !== undefined,
500
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
+ },
501
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
+ }
502
599
  // A queued result wait must observe completion before its held notification
503
600
  // can fire, so successful waits can still suppress that redundant nudge.
504
601
  const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
@@ -511,11 +608,19 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
511
608
  // cannot be formatted is skipped, as a failed send always was.
512
609
  try {
513
610
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
514
- const message = {
515
- content: formatTaskNotification(record, 500, showCost) + footer,
516
- details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
517
- };
518
- 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
+ });
519
624
  } catch { /* ignore stale completion side-effect errors */ }
520
625
  widget.update();
521
626
  }
@@ -527,15 +632,21 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
527
632
 
528
633
  const members = records.filter(r => !r.resultConsumed).flatMap(r => {
529
634
  try {
530
- 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)];
531
636
  } catch { return []; /* as in sendIndividualNudge */ }
532
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." : "";
533
640
  if (members.length > 0) {
534
641
  outbox.add({
535
642
  ids: members.map(m => m.id),
536
- build: (ids) => {
643
+ resultChars: resultCharsOf(members),
644
+ build: (ids, caps) => {
537
645
  const unread = members.filter(m => ids.includes(m.id));
538
- 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);
539
650
  const label = partial
540
651
  ? `${unread.length} agent(s) finished (partial — others still running)`
541
652
  : `${unread.length} agent(s) finished`;
@@ -544,8 +655,10 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
544
655
  const details = first.details;
545
656
  if (rest.length > 0) details.others = rest.map(m => m.details);
546
657
  return {
547
- 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}`,
548
659
  details,
660
+ ...(delivered.length > 0 ? { delivered } : {}),
661
+ ...(noticed.length > 0 ? { noticed } : {}),
549
662
  };
550
663
  },
551
664
  });
@@ -557,7 +670,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
557
670
 
558
671
  /** Helper: build event data for lifecycle events from an AgentRecord. */
559
672
  function buildEventData(record: AgentRecord) {
560
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
673
+ const durationMs = recordElapsedMs(record, Date.now());
561
674
  // All three fields are lifetime-accumulated (Σ over every assistant message_end),
562
675
  // so they survive compaction together — input + output ≤ total always.
563
676
  // tokens is omitted when nothing was ever produced (e.g. agent errored before
@@ -583,6 +696,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
583
696
  id: record.id,
584
697
  type: record.type,
585
698
  description: record.description,
699
+ ...eventFields(record),
586
700
  result: record.result,
587
701
  error: record.error,
588
702
  status: record.status,
@@ -590,8 +704,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
590
704
  durationMs,
591
705
  tokens,
592
706
  usage,
593
- // Present only on a fallback, so the payload keeps its shape otherwise.
594
- ...fallbackFields(record),
707
+ // Stamped by the only caller, the settle path, just before this runs.
708
+ settleSeq: record.settleSeq,
595
709
  };
596
710
  }
597
711
 
@@ -601,6 +715,30 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
601
715
  */
602
716
  let detachedSessionFile: string | undefined;
603
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
+
604
742
  /** `modelFallback` + `modelNote` event fields, or nothing. Only what is KNOWN when the event fires. */
605
743
  function fallbackFields(record: AgentRecord | undefined): Record<string, unknown> {
606
744
  return {
@@ -621,15 +759,40 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
621
759
  };
622
760
  }
623
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
+
624
779
  function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
625
780
  if (record.resultConsumed) return;
626
781
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
627
782
  try {
783
+ const notice = snapshotNotice(record, PREVIEW_RESULT_CHARS);
628
784
  spoolDetached(sessionFile, {
629
785
  id: record.id,
630
- content: formatTaskNotification(record, 500, showCost) + footer,
631
- details: buildNotificationDetails(record, 500),
786
+ content: notice.render().text + footer,
787
+ details: notice.details,
632
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)),
794
+ },
795
+ ...(notice.parts ? { notice: { ...notice.parts, footer } } : {}),
633
796
  });
634
797
  } catch { /* spool unwritable: the transcript file still has the result */ }
635
798
  }
@@ -639,22 +802,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
639
802
  for (const result of drainDetached(sessionFile)) deliverDetachedResult(result);
640
803
  }
641
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
+
642
830
  function deliverDetachedResult(result: DetachedResult) {
643
831
  try {
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);
644
837
  if (result.record) {
645
838
  pi.appendEntry("subagents:record", result.record);
646
839
  fleet.ingestFailure(result.record);
647
840
  fleet.update();
648
841
  }
649
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;
650
846
  outbox.add({
651
847
  ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
652
- 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 },
653
852
  });
654
853
  } catch { /* session already gone again; the transcript file still has the result */ }
655
854
  }
656
855
 
657
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);
658
861
 
659
862
  // Background completion: route through group join or send individual nudge
660
863
  const manager = new AgentManager((record) => {
@@ -674,13 +877,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
674
877
 
675
878
  if (!record.resultConsumed) fleet.ingestFailure(record);
676
879
  // Emit lifecycle event based on terminal status
677
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
678
- const eventData = buildEventData(record);
679
- if (isError) {
680
- pi.events.emit("subagents:failed", eventData);
681
- } else {
682
- pi.events.emit("subagents:completed", eventData);
683
- }
880
+ record.settleSeq = manager.nextSequence();
881
+ pi.events.emit(lifecycleChannel(record), buildEventData(record));
684
882
 
685
883
  // Persist final record for cross-extension history reconstruction
686
884
  pi.appendEntry("subagents:record", historyRecord(record));
@@ -718,12 +916,14 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
718
916
  fleet.ensureTimer();
719
917
  fleet.update();
720
918
  }
919
+ // Bind later handoff announcements to this exact run.
920
+ lifecycleRuns.set(record, manager.nextSequence());
721
921
  // Emit started event when agent transitions to running (including from queue)
722
922
  pi.events.emit("subagents:started", {
723
923
  id: record.id,
724
924
  type: record.type,
725
925
  description: record.description,
726
- ...fallbackFields(record),
926
+ ...eventFields(record),
727
927
  });
728
928
  }, (record, info) => {
729
929
  if (!isTopLevelAgent(record)) return;
@@ -735,7 +935,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
735
935
  reason: info.reason,
736
936
  tokensBefore: info.tokensBefore,
737
937
  compactionCount: record.compactionCount,
738
- ...fallbackFields(record),
938
+ ...eventFields(record),
739
939
  });
740
940
  }, (record, usage) => {
741
941
  // Every assistant message from every agent — nested included, exactly once.
@@ -772,7 +972,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
772
972
  // can show `low (asked minimal)` instead of silently relabelling.
773
973
  ...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
774
974
  ...fallbackFields(record),
775
- depth: record.depth ?? 1,
975
+ depth: record.depth,
776
976
  ...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
777
977
  ...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
778
978
  usage: reported,
@@ -780,7 +980,26 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
780
980
  }, (record, message) => {
781
981
  if (!isTopLevelAgent(record)) return;
782
982
  pi.events.emit("subagents:steered", { id: record.id, message });
783
- }, clock, options.homeDir);
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));
784
1003
 
785
1004
  // Expose manager via Symbol.for() global registry for cross-package access.
786
1005
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
@@ -851,6 +1070,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
851
1070
  // Bypasses handle allocation, so a forged value would duplicate a live
852
1071
  // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
853
1072
  delete safeOptions.reclaim;
1073
+ // Would make the new agent claim an evicted agent's id on lifecycle events.
1074
+ delete safeOptions.previousId;
854
1075
  // Every spawn through here is DETACHED — the caller gets an id back and
855
1076
  // awaits nothing. A forged `blocking` would charge it to the foreground
856
1077
  // pool and could defer it behind a queue whose gate nobody is holding.
@@ -876,7 +1097,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
876
1097
  const byId = manager.getRecord(ref);
877
1098
  if (byId) return byId;
878
1099
  const resolved = manager.resolveMention(ref);
879
- 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);
880
1106
  };
881
1107
 
882
1108
  const registryEntry = {
@@ -949,7 +1175,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
949
1175
  reloadCustomAgents(strictLoad);
950
1176
  } catch (err) {
951
1177
  if (strictLoad && StartupError) {
952
- throw new StartupError(err instanceof Error ? err.message : String(err), { cause: err });
1178
+ throw new StartupError(errorMessage(err), { cause: err });
953
1179
  }
954
1180
  throw err;
955
1181
  }
@@ -970,6 +1196,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
970
1196
  unwatchDetached = watchDetached(sessionFile, () => deliverDetached(sessionFile));
971
1197
  deliverDetached(sessionFile);
972
1198
  }
1199
+ announceSurvivors();
1200
+ unwatchAnnounce ??= pi.events.on("subagents:announce_survivors", announceSurvivors);
973
1201
  // Orphans from the previous instance are already registered; show them now.
974
1202
  fleet.update();
975
1203
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
@@ -1035,7 +1263,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1035
1263
 
1036
1264
  /** Agent types `@` can start, in the shape the roster wants. */
1037
1265
  const mentionTypes = (): TypeInfo[] =>
1038
- getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
1266
+ getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)!.description }));
1039
1267
 
1040
1268
  /**
1041
1269
  * `@handle message` typed at the prompt addresses that agent instead of the
@@ -1134,65 +1362,9 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1134
1362
  if (resolved?.kind === "tombstone") {
1135
1363
  const entry = resolved.entry;
1136
1364
  const target = `@${entry.alias ?? entry.handle}`;
1137
-
1138
- // Checked here rather than left to SessionManager.open: that runs inside
1139
- // runAgent, whose rejection lands on the record as an agent error, not in
1140
- // the catch below. A `/new` in another pi window or a manual delete makes
1141
- // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
1142
- // the entry — a row that can only ever fail is worse than none — and say
1143
- // so rather than quietly sending this message to an unrelated agent.
1144
- if (!existsSync(entry.sessionFile)) {
1145
- manager.dropTombstone(entry.handle);
1146
- ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
1147
- return { action: "handled" };
1148
- }
1149
-
1150
- // The Agent tool deliberately falls back to general-purpose for a type it
1151
- // cannot resolve (#183), which covers a deleted file AND a merely
1152
- // disabled one. A resume must not inherit that: reopening this
1153
- // conversation under a different agent's prompt and tools is not
1154
- // continuing it, and the new record would re-tombstone under the
1155
- // substitute, so the handle would never find its way back.
1156
- reloadCustomAgents();
1157
- const dispatch = resolveSpawnType(entry.type);
1158
- if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
1159
- // The tombstone stays: re-enabling the agent makes the handle work
1160
- // again, which a drop would foreclose.
1161
- ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
1162
- return { action: "handled" };
1163
- }
1164
-
1165
- try {
1166
- // spawnResolved, not spawnTopLevel: the latter strips
1167
- // `resumeSessionFile` and `reclaim` as untrusted. This path is the
1168
- // exception — both come from a tombstone this extension wrote.
1169
- const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
1170
- description: entry.description,
1171
- reclaim: { handle: entry.handle, alias: entry.alias },
1172
- resumeSessionFile: entry.sessionFile,
1173
- // Historical disclosures never replace the continuation's current selection.
1174
- ...(entry.modelFallback ? { previousModelFallback: entry.modelFallback } : {}),
1175
- ...(entry.modelTier ? { previousModelTier: entry.modelTier } : {}),
1176
- isBackground: true,
1177
- });
1178
- // The agent may still be starting — wait, so a startup failure lands in
1179
- // the catch below instead of being announced as a resume.
1180
- await manager.awaitStartup(id);
1181
- // The tombstone deliberately stays. `resolveMention` prefers the live
1182
- // record holding these same names, so it cannot shadow the resume — and
1183
- // if this run dies before establishing its own session, the original
1184
- // transcript is still the right thing for the next mention to reopen.
1185
- // Once the resumed record is evicted it overwrites this entry in place,
1186
- // keyed by the same handle, so nothing accumulates.
1187
- ctx.ui.notify(`Resuming ${target}`, "info");
1188
- } catch (err) {
1189
- // The type is already settled above, so what is left is a spawn-time
1190
- // failure: a strict worktree-isolation error, an unusable cwd.
1191
- ctx.ui.notify(
1192
- `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
1193
- "warning",
1194
- );
1195
- }
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");
1196
1368
  return { action: "handled" };
1197
1369
  }
1198
1370
 
@@ -1243,7 +1415,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1243
1415
  ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
1244
1416
  } catch (err) {
1245
1417
  ctx.ui.notify(
1246
- `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
1418
+ `Could not start ${label}: ${errorMessage(err)}`,
1247
1419
  "error",
1248
1420
  );
1249
1421
  }
@@ -1267,7 +1439,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1267
1439
  await manager.awaitStartup(id);
1268
1440
  ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1269
1441
  } catch (err) {
1270
- 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");
1271
1443
  }
1272
1444
  return { action: "handled" };
1273
1445
  });
@@ -1288,6 +1460,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1288
1460
  const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
1289
1461
  unwatchDetached?.();
1290
1462
  unwatchDetached = undefined;
1463
+ unwatchAnnounce?.();
1464
+ unwatchAnnounce = undefined;
1291
1465
  rpcHandle?.unsubSpawn();
1292
1466
  rpcHandle?.unsubStop();
1293
1467
  rpcHandle?.unsubPing();
@@ -1318,7 +1492,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1318
1492
  }
1319
1493
  const pending = outbox.takePending();
1320
1494
  if (pending) {
1321
- 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 }); }
1322
1501
  catch { console.error("[pi-subagents] Could not spool held completion notices; results remain in their transcripts."); }
1323
1502
  }
1324
1503
  }
@@ -1350,7 +1529,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1350
1529
  registerRunningDetached(sessionFile, {
1351
1530
  record,
1352
1531
  activity: agentActivity.get(record.id),
1353
- abort: () => manager.abort(record.id),
1532
+ abort: (by) => manager.abort(record.id, by),
1354
1533
  steer: (message) => manager.steer(record.id, message),
1355
1534
  });
1356
1535
  }
@@ -1400,17 +1579,17 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1400
1579
  wake();
1401
1580
  sent++;
1402
1581
  // It is a background agent from here on, so announce it as one — as a
1403
- // detached resume re-announces an existing id.
1404
- const record = manager.getRecord(id);
1405
- if (record) {
1406
- pi.events.emit("subagents:created", {
1407
- id,
1408
- type: record.type,
1409
- description: record.description,
1410
- isBackground: true,
1411
- ...fallbackFields(record),
1412
- });
1413
- }
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
+ });
1414
1593
  }
1415
1594
  if (sent > 0) {
1416
1595
  widget.ensureTimer();
@@ -1474,6 +1653,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1474
1653
  // Agents the previous instance of this session left running (/reload, /new, ...).
1475
1654
  () => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
1476
1655
  fleet.setParkedSource(() => scheduler.list().filter(job => job.enabled && job.quotaParked && job.lastStatus !== "running").length);
1656
+ fleet.setStopAll(stopAllRunning);
1477
1657
  /** Replay UI-only state from the active branch, like workflow/goal custom snapshots. */
1478
1658
  function restoreFleetDisclosure(ctx: ExtensionContext): void {
1479
1659
  fleet.restoreSession(() => ctx.sessionManager?.getBranch?.() ?? [], state => {
@@ -1624,6 +1804,100 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1624
1804
  return getAgentConfig(record.type)?.maxTurns ?? explicit ?? record.invocation?.maxTurns ?? getDefaultMaxTurns();
1625
1805
  }
1626
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
+
1627
1901
  /**
1628
1902
  * Launch a detached resume of an existing agent and wire everything a
1629
1903
  * re-running agent needs: transcript anchoring, activity tracking, join-mode
@@ -1643,13 +1917,16 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1643
1917
  opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1644
1918
  ): Promise<AgentRecord | undefined> {
1645
1919
  const id = existing.id;
1646
- 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;
1647
1924
  // Assigned unconditionally: the completion notification carries this as
1648
1925
  // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1649
1926
  // id left by the spawn that created the record. Keeping it would point the
1650
1927
  // orchestrator's new result at a tool call that was answered runs ago.
1651
1928
  existing.toolCallId = opts.toolCallId;
1652
- if (joinMode) existing.joinMode = joinMode;
1929
+ existing.joinMode = joinMode;
1653
1930
  // Reuse the agent's transcript rather than starting a fresh one: the
1654
1931
  // path is deterministic per agent+session, so writing an initial entry
1655
1932
  // would truncate the previous run's turns (see ensureOutputFile).
@@ -1663,7 +1940,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1663
1940
  // Anchor streaming past the turns already on disk, captured BEFORE the
1664
1941
  // run starts. The resumed prompt lands as an ordinary user message at
1665
1942
  // this index, so it is written exactly once.
1666
- 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;
1667
1945
 
1668
1946
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(normalizeMaxTurns(opts.maxTurns), undefined, clock);
1669
1947
  // resumeAgent has no onSessionCreated — the session predates this run —
@@ -1717,7 +1995,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1717
1995
  type: existing.type,
1718
1996
  description: existing.description,
1719
1997
  isBackground: true,
1720
- ...fallbackFields(existing),
1998
+ ...(record.status === "queued" && priorSettleSeq !== undefined ? { queuedAfterSettleSeq: priorSettleSeq, queuedResumeSeq: resumeSeq } : {}),
1999
+ ...eventFields(existing),
1721
2000
  });
1722
2001
 
1723
2002
  return record;
@@ -1738,17 +2017,28 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1738
2017
  outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
1739
2018
  });
1740
2019
  pi.on("agent_end", () => outbox.onAgentEnd());
1741
- 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
+ });
1742
2031
 
1743
2032
  /** Build the full type list text dynamically from available agents only. */
1744
2033
  const buildTypeListText = () => {
1745
2034
  const available = getAvailableTypes();
1746
2035
 
1747
2036
  return available.map((name) => {
1748
- const cfg = getAgentConfig(name);
1749
- 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)})` : "";
1750
2040
  const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1751
- return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
2041
+ return `- ${name}: ${cfg.description}${modelSuffix}${toolsSuffix}`;
1752
2042
  }).join("\n");
1753
2043
  };
1754
2044
 
@@ -1761,8 +2051,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1761
2051
  /** Compact type list: one line per agent, first sentence only. */
1762
2052
  const buildCompactTypeListText = () =>
1763
2053
  getAvailableTypes().map((name) => {
1764
- const cfg = getAgentConfig(name);
1765
- return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
2054
+ const cfg = getAgentConfig(name)!;
2055
+ return `- ${name}: ${firstSentence(cfg.description)} (Tools: ${formatToolsSuffix(cfg)})`;
1766
2056
  }).join("\n");
1767
2057
 
1768
2058
  /** Derive a short model label from a model string. */
@@ -1790,6 +2080,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1790
2080
  setBudgetPolicy,
1791
2081
  setDefaultMaxTurns,
1792
2082
  setGraceTurns,
2083
+ setNotificationResultChars: (n) => { notificationResultChars = n; },
1793
2084
  setDefaultJoinMode,
1794
2085
  setBackgroundByDefault,
1795
2086
  setSchedulingEnabled,
@@ -1852,13 +2143,35 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1852
2143
  // With no per-result note by design, the model would have every reason to go
1853
2144
  // on reporting a `pi-agent-*` branch that was never created.
1854
2145
  const isolationGuideline = isWorktreeIsolationEnabled()
1855
- ? `\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\`.`
1856
2147
  : "";
1857
2148
 
1858
2149
  const isolationCompactGuideline = isWorktreeIsolationEnabled()
1859
- ? `\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.`
1860
2151
  : "";
1861
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
+
1862
2175
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1863
2176
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
1864
2177
  // small/local models. Per-option details live in the param descriptions.
@@ -1872,7 +2185,7 @@ Notes:
1872
2185
  - Parallel work: one message, multiple Agent calls — they run concurrently.
1873
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.
1874
2187
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
1875
- - 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}`;
1876
2189
 
1877
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.
1878
2191
 
@@ -1897,10 +2210,11 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1897
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.
1898
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.
1899
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.
1900
- - 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.
1901
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.
1902
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.
1903
- - Use model to select a configured tier (e.g. "@cheap"), an exact "provider/modelId", or a fuzzy tool-call input (e.g. "haiku").
2217
+ - ${modelGuideline()}
1904
2218
  - Use thinking to control extended thinking level.
1905
2219
  - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1906
2220
 
@@ -1949,7 +2263,7 @@ Terse command-style prompts produce shallow, generic work.
1949
2263
  if (text) return renderToolDescriptionTemplate(text);
1950
2264
  console.warn(`[pi-subagents] ${path} is empty — ignoring`);
1951
2265
  } catch (err) {
1952
- 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)}`);
1953
2267
  }
1954
2268
  }
1955
2269
  return undefined;
@@ -2001,8 +2315,7 @@ Terse command-style prompts produce shallow, generic work.
2001
2315
  }),
2002
2316
  model: Type.Optional(
2003
2317
  Type.String({
2004
- description:
2005
- '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.',
2318
+ description: modelParamDescription(),
2006
2319
  }),
2007
2320
  ),
2008
2321
  thinking: Type.Optional(
@@ -2023,7 +2336,7 @@ Terse command-style prompts produce shallow, generic work.
2023
2336
  ),
2024
2337
  resume: Type.Optional(
2025
2338
  Type.String({
2026
- 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).",
2027
2340
  }),
2028
2341
  ),
2029
2342
  isolated: Type.Optional(
@@ -2200,11 +2513,12 @@ Terse command-style prompts produce shallow, generic work.
2200
2513
  rec ? rec.modelFallback : modelFallback;
2201
2514
  /** A resume's lead-in: the record's note only (the type note describes a spawn, not a resume). */
2202
2515
  const resumeNoteFor = (rec: AgentRecord): string => {
2203
- const note = modelNoteOf(rec);
2516
+ const note = [modelNoteOf(rec), modelIdentityNote(rec)].filter(Boolean).join("\n");
2204
2517
  return note ? `${note}\n\n` : "";
2205
2518
  };
2206
2519
  const fallbackNoteFor = (rec: AgentRecord | undefined): string => {
2207
- const modelNote = modelNoteOf(rec);
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");
2208
2522
  return typeNote + (modelNote ? `${modelNote}\n\n` : "");
2209
2523
  };
2210
2524
  const detailBase = {
@@ -2272,6 +2586,7 @@ Terse command-style prompts produce shallow, generic work.
2272
2586
  if (!scheduler.isActive()) {
2273
2587
  return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
2274
2588
  }
2589
+ if (planningNow(pi.events)) return textResult(PLAN_SCHEDULE_REFUSAL);
2275
2590
  try {
2276
2591
  const job = scheduler.addJob({
2277
2592
  name: params.description as string,
@@ -2294,18 +2609,57 @@ Terse command-style prompts produce shallow, generic work.
2294
2609
  `Manage via /agents → Scheduled jobs.`,
2295
2610
  );
2296
2611
  } catch (err) {
2297
- return textResult(err instanceof Error ? err.message : String(err));
2612
+ return textResult(errorMessage(err));
2298
2613
  }
2299
2614
  }
2300
2615
 
2301
2616
  // Resume existing agent
2302
2617
  if (params.resume) {
2303
- const existing = manager.getRecord(params.resume);
2304
- 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)) {
2305
2659
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
2306
2660
  }
2307
2661
  if (!existing.session) {
2308
- return textResult(`Agent "${params.resume}" has no active session to resume.`);
2662
+ return textResult(`${continued}Agent "${existing.id}" has no active session to resume.`);
2309
2663
  }
2310
2664
  // Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
2311
2665
  // a resume ignores, so it may describe another agent.
@@ -2323,7 +2677,7 @@ Terse command-style prompts produce shallow, generic work.
2323
2677
  // instead of letting it read as a generic failure.
2324
2678
  if (existing.status === "running" || existing.status === "queued") {
2325
2679
  return textResult(
2326
- `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` +
2327
2681
  `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2328
2682
  );
2329
2683
  }
@@ -2342,7 +2696,7 @@ Terse command-style prompts produce shallow, generic work.
2342
2696
 
2343
2697
  const isQueued = record.status === "queued";
2344
2698
  return textResult(
2345
- `${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2699
+ `${continued}${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2346
2700
  `Agent ID: ${id}\n` +
2347
2701
  `Type: ${existing.type}\n` +
2348
2702
  (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
@@ -2365,7 +2719,7 @@ Terse command-style prompts produce shallow, generic work.
2365
2719
  fleet.update();
2366
2720
  let record: AgentRecord | undefined;
2367
2721
  try {
2368
- record = await manager.resume(params.resume, params.prompt, signal, {
2722
+ record = await manager.resume(existing.id, params.prompt, signal, {
2369
2723
  maxTurns: resumeMaxTurns,
2370
2724
  onTurnEnd: resumeCallbacks.onTurnEnd,
2371
2725
  onTextDelta: resumeCallbacks.onTextDelta,
@@ -2387,7 +2741,7 @@ Terse command-style prompts produce shallow, generic work.
2387
2741
  return textResult(`${resumeNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
2388
2742
  }
2389
2743
  return textResult(
2390
- `${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
2744
+ `${continued}${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
2391
2745
  buildDetails(detailBaseFor(record), record, resumeState),
2392
2746
  );
2393
2747
  }
@@ -2426,6 +2780,7 @@ Terse command-style prompts produce shallow, generic work.
2426
2780
  if (!canPark || !mayPark(quotaBlock)) {
2427
2781
  throw new Error(`${selection?.tier ? `${modelNoteOf(undefined)}\n` : ""}${describeBlock(quotaBlock)} The agent was not started.`);
2428
2782
  }
2783
+ if (planningNow(pi.events)) throw new Error(`${describeBlock(quotaBlock)} ${PLAN_PARK_REFUSAL}`);
2429
2784
  const job = scheduler.addJob({
2430
2785
  name: `${params.description} (quota ${nanoid(4)})`,
2431
2786
  description: params.description as string,
@@ -2443,7 +2798,7 @@ Terse command-style prompts produce shallow, generic work.
2443
2798
  });
2444
2799
  return textResult(
2445
2800
  `${fallbackNoteFor(undefined)}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
2446
- `${scheduler.getNextRun(job.id) ?? job.schedule}. Manage via /agents → Scheduled jobs.`,
2801
+ `${job.schedule}. Manage via /agents → Scheduled jobs.`,
2447
2802
  );
2448
2803
  }
2449
2804
 
@@ -2452,8 +2807,7 @@ Terse command-style prompts produce shallow, generic work.
2452
2807
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns, undefined, clock);
2453
2808
 
2454
2809
  // Wrap onSessionCreated to wire output file streaming.
2455
- // The callback lazily reads record.outputFile (set right after spawn)
2456
- // rather than closing over a value that doesn't exist yet.
2810
+ // The callback reads record.outputFile prepared before launch.
2457
2811
  let id: string;
2458
2812
  const origBgOnSession = bgCallbacks.onSessionCreated;
2459
2813
  bgCallbacks.onSessionCreated = (session: any) => {
@@ -2481,18 +2835,17 @@ Terse command-style prompts produce shallow, generic work.
2481
2835
  cwd,
2482
2836
  invocation: agentInvocation,
2483
2837
  rootSessionId: ctx.sessionManager.getSessionId(),
2838
+ onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
2484
2839
  ...bgCallbacks,
2485
2840
  });
2486
2841
 
2487
- // Set output file + join mode synchronously after spawn, before the
2488
- // event loop yields — onSessionCreated is async so this is safe.
2489
- const joinMode = resolveJoinMode(defaultJoinMode, true);
2490
- const record = manager.getRecord(id);
2491
- if (record && joinMode) {
2492
- record.joinMode = joinMode;
2493
- record.toolCallId = toolCallId;
2494
- attachTranscript(record, id);
2495
- }
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;
2496
2849
 
2497
2850
  // With isolation: "worktree" the agent isn't running yet — the repo
2498
2851
  // copy is an awaited git call. Wait for it here, after the synchronous
@@ -2514,7 +2867,7 @@ Terse command-style prompts produce shallow, generic work.
2514
2867
  type: subagentType,
2515
2868
  description: params.description,
2516
2869
  isBackground: true,
2517
- ...fallbackFields(manager.getRecord(id)),
2870
+ ...eventFields(manager.getRecord(id)),
2518
2871
  });
2519
2872
 
2520
2873
  return backgroundResult(id, record);
@@ -2573,8 +2926,7 @@ Terse command-style prompts produce shallow, generic work.
2573
2926
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate, clock);
2574
2927
 
2575
2928
  // Wire session creation: register in widget + stream to output file.
2576
- // The output file path is set synchronously after spawn (below),
2577
- // before onSessionCreated fires — same pattern as background agents.
2929
+ // The output file path is prepared before launch, as for background agents.
2578
2930
  const origOnSession = fgCallbacks.onSessionCreated;
2579
2931
  fgCallbacks.onSessionCreated = (session: any) => {
2580
2932
  origOnSession(session);
@@ -2592,16 +2944,11 @@ Terse command-style prompts produce shallow, generic work.
2592
2944
  widget.ensureTimer();
2593
2945
  fleet.ensureTimer();
2594
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);
2595
2949
  break;
2596
2950
  }
2597
2951
  }
2598
- // Stream conversation to output file (foreground agent logging)
2599
- if (fgId) {
2600
- const rec = manager.getRecord(fgId);
2601
- if (rec?.outputFile) {
2602
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
2603
- }
2604
- }
2605
2952
  };
2606
2953
 
2607
2954
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
@@ -2640,12 +2987,12 @@ Terse command-style prompts produce shallow, generic work.
2640
2987
  // widget and the `finally` cleanup below, none of which should see an
2641
2988
  // agent that has no session and may never get one.
2642
2989
  onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2990
+ onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
2643
2991
  ...fgCallbacks,
2644
2992
  }, (fgAgentId) => {
2645
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2646
- // Set up the output file so streamToOutputFile can pick it up.
2647
- const fgRec = manager.getRecord(fgAgentId);
2648
- 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)!;
2649
2996
  // Fires once the run promise exists, i.e. the agent is running. From
2650
2997
  // here until this call returns, the run can be handed off.
2651
2998
  handoffId = fgAgentId;
@@ -2653,10 +3000,8 @@ Terse command-style prompts produce shallow, generic work.
2653
3000
  // What a background spawn sets up front: the notification names
2654
3001
  // this tool call, and it reports alone — its spawn batch is long
2655
3002
  // closed.
2656
- if (fgRec) {
2657
- fgRec.toolCallId = toolCallId;
2658
- fgRec.joinMode = "async";
2659
- }
3003
+ fgRec.toolCallId = toolCallId;
3004
+ fgRec.joinMode = "async";
2660
3005
  backgrounded = true;
2661
3006
  wakeBackgrounded();
2662
3007
  });
@@ -2690,7 +3035,7 @@ Terse command-style prompts produce shallow, generic work.
2690
3035
  return textResult(`${fallbackNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
2691
3036
  }
2692
3037
 
2693
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
3038
+ const durationMs = recordElapsedMs(record, Date.now());
2694
3039
  const statsParts = [`${record.toolUses} tool uses`];
2695
3040
  if (tokenText) statsParts.push(tokenText);
2696
3041
  if (showCost) {
@@ -2698,7 +3043,7 @@ Terse command-style prompts produce shallow, generic work.
2698
3043
  if (costText) statsParts.push(costText);
2699
3044
  }
2700
3045
  return textResult(
2701
- `${fallbackNoteFor(record)}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` +
2702
3047
  (record.result?.trim() || "No output."),
2703
3048
  details,
2704
3049
  );
@@ -2781,7 +3126,8 @@ Terse command-style prompts produce shallow, generic work.
2781
3126
  // run's progress log here would put O(log) work in the render loop.
2782
3127
  return [...workflowTasks.values()].map(task => ({
2783
3128
  id: task.id,
2784
- 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,
2785
3131
  status: task.status,
2786
3132
  doneCount: task.doneCount,
2787
3133
  totalCount: task.agentCount,
@@ -2826,7 +3172,7 @@ Terse command-style prompts produce shallow, generic work.
2826
3172
  });
2827
3173
  completeWorkflowTask(task, result);
2828
3174
  } catch (err) {
2829
- failWorkflowTask(task, err instanceof Error ? err.message : String(err));
3175
+ failWorkflowTask(task, errorMessage(err));
2830
3176
  }
2831
3177
  }
2832
3178
 
@@ -2843,7 +3189,7 @@ Terse command-style prompts produce shallow, generic work.
2843
3189
  content: formatWorkflowNotification(task),
2844
3190
  details: {
2845
3191
  id: task.id,
2846
- description: `Workflow ${task.workflowName ?? task.id}`,
3192
+ description: `Workflow ${task.meta!.name}`,
2847
3193
  status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2848
3194
  toolUses: task.totalToolCalls,
2849
3195
  // A workflow has agents, not turns; rendering "↻0" would be noise.
@@ -2974,7 +3320,7 @@ Terse command-style prompts produce shallow, generic work.
2974
3320
  try {
2975
3321
  meta = extractMeta(resolved.script).meta;
2976
3322
  } catch (err) {
2977
- return textResult(err instanceof Error ? err.message : String(err));
3323
+ return textResult(errorMessage(err));
2978
3324
  }
2979
3325
 
2980
3326
  const runId = workflowRunId();
@@ -2992,7 +3338,7 @@ Terse command-style prompts produce shallow, generic work.
2992
3338
  } catch (err) {
2993
3339
  savedPath = undefined;
2994
3340
  journalPath = undefined;
2995
- 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)}`);
2996
3342
  }
2997
3343
 
2998
3344
  const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
@@ -3027,7 +3373,7 @@ Terse command-style prompts produce shallow, generic work.
3027
3373
  `Task ID: ${runId}\n` +
3028
3374
  (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
3029
3375
  (task.resumedFrom !== undefined
3030
- ? `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`
3031
3377
  : params.resumeFromRunId !== undefined
3032
3378
  ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
3033
3379
  : "") +
@@ -3152,7 +3498,7 @@ Terse command-style prompts produce shallow, generic work.
3152
3498
  try {
3153
3499
  script = readFileSync(path, "utf-8");
3154
3500
  } catch (err) {
3155
- report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
3501
+ report(`Could not read ${path}: ${errorMessage(err)}`, "warning");
3156
3502
  return;
3157
3503
  }
3158
3504
 
@@ -3160,7 +3506,7 @@ Terse command-style prompts produce shallow, generic work.
3160
3506
  try {
3161
3507
  meta = extractMeta(script).meta;
3162
3508
  } catch (err) {
3163
- report(err instanceof Error ? err.message : String(err), "warning");
3509
+ report(errorMessage(err), "warning");
3164
3510
  return;
3165
3511
  }
3166
3512
 
@@ -3199,7 +3545,7 @@ Terse command-style prompts produce shallow, generic work.
3199
3545
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
3200
3546
  label: "Get Agent Result",
3201
3547
  description:
3202
- "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.",
3203
3549
  promptSnippet: "Check status and retrieve results from a background agent",
3204
3550
  parameters: Type.Object({
3205
3551
  agent_id: Type.String({
@@ -3248,7 +3594,7 @@ Terse command-style prompts produce shallow, generic work.
3248
3594
 
3249
3595
  let output =
3250
3596
  `Agent: ${record.id}\n` +
3251
- `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
3597
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status, record.stoppedBy)} | ${statsParts.join(" | ")}\n` +
3252
3598
  `Description: ${record.description}\n` +
3253
3599
  (modelSelectionNote(record) ? `${modelSelectionNote(record)}\n` : "") +
3254
3600
  `\n`;
@@ -3286,23 +3632,46 @@ Terse command-style prompts produce shallow, generic work.
3286
3632
  name: SUBAGENT_TOOL_NAMES.STEER,
3287
3633
  label: "Steer Agent",
3288
3634
  description:
3289
- "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
3290
- "and be injected into its conversation, allowing you to redirect its work mid-run. A queued agent gets the message when it starts.",
3291
- 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",
3292
3640
  parameters: Type.Object({
3293
3641
  agent_id: Type.String({
3294
- 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`).",
3295
3643
  }),
3296
3644
  message: Type.String({
3297
- 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.",
3298
3646
  }),
3299
3647
  }),
3300
- execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
3301
- const record = resolveAgentRef(params.agent_id);
3302
- if (!record || !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
3303
- if (record.status !== "running" && record.status !== "queued") {
3304
- 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);
3305
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));
3306
3675
  if (!record.session) {
3307
3676
  // Queued for a concurrency slot, or running with its session still
3308
3677
  // being built: either way the manager flushes `pendingSteers` into the
@@ -3334,24 +3703,124 @@ Terse command-style prompts produce shallow, generic work.
3334
3703
  `Current state: ${stateParts.join(" · ")}`,
3335
3704
  );
3336
3705
  } catch (err) {
3337
- return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
3706
+ return textResult(`Failed to steer agent: ${errorMessage(err)}`);
3338
3707
  }
3339
3708
  },
3340
3709
  }));
3341
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
+
3342
3812
  // ---- /agents interactive menu ----
3343
3813
 
3344
3814
  // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
3345
3815
  // so they are reachable from tests — this command handler is only registered
3346
3816
  // through `registerCommand`, which every test mocks.
3347
3817
 
3348
- function getModelLabel(type: string, registry?: ModelRegistry): string {
3818
+ function getModelLabel(type: string, registry: ModelRegistry, cwd: string): string {
3349
3819
  const cfg = getAgentConfig(type);
3350
3820
  const input = modelRequest(type, cfg?.model).requested;
3351
3821
  if (!input) return "inherit";
3352
3822
  const label = getModelLabelFromConfig(input);
3353
- if (!registry) return label;
3354
- const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd: currentCtx?.cwd ?? process.cwd() });
3823
+ const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd });
3355
3824
  if (typeof selection === "string" || selection.outcome === "fallback" || !selection.model) {
3356
3825
  return `${label} (unavailable, fallback: inherit)`;
3357
3826
  }
@@ -3379,6 +3848,9 @@ Terse command-style prompts produce shallow, generic work.
3379
3848
  const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
3380
3849
  options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
3381
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})`);
3382
3854
 
3383
3855
  // Agent types list
3384
3856
  if (allNames.length > 0) {
@@ -3417,6 +3889,12 @@ Terse command-style prompts produce shallow, generic work.
3417
3889
  if (choice.startsWith("Running agents (")) {
3418
3890
  await showRunningAgents(ctx);
3419
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);
3420
3898
  } else if (choice.startsWith("Agent types (")) {
3421
3899
  await showAllAgentsList(ctx);
3422
3900
  await showAgentsMenu(ctx);
@@ -3460,7 +3938,7 @@ Terse command-style prompts produce shallow, generic work.
3460
3938
  // it: the `▪` badge says a package won, not which one.
3461
3939
  const items: SettingItem[] = allNames.map((name, i) => {
3462
3940
  const cfg = configs[i];
3463
- const model = getModelLabel(name, ctx.modelRegistry);
3941
+ const model = getModelLabel(name, ctx.modelRegistry, ctx.cwd);
3464
3942
  return {
3465
3943
  id: name,
3466
3944
  label: `${sourceIndicator(cfg)}${name}`,
@@ -3538,9 +4016,9 @@ Terse command-style prompts produce shallow, generic work.
3538
4016
  (tui, theme, keybindings, done) => {
3539
4017
  overlayOptions = getConversationOverlayOptions(tui);
3540
4018
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
3541
- if (manager.abort(record.id)) {
3542
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
3543
- }
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");
3544
4022
  }, keybindings, (message: string) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), getFullscreenScrollbar(),
3545
4023
  backgroundActionFor(record, message => ctx.ui.notify(message, "info")), clock);
3546
4024
  },
@@ -3569,10 +4047,9 @@ Terse command-style prompts produce shallow, generic work.
3569
4047
  ? ["Eject (export as .md)", "Back"]
3570
4048
  : ["Eject (export as .md)", "Disable", "Back"];
3571
4049
  } else if (disabled && file) {
3572
- // Disabled agent with a file — offer Enable
3573
- menuOptions = isDefault
3574
- ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
3575
- : ["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"];
3576
4053
  } else if (isDefault && !file) {
3577
4054
  // Default agent with no .md override
3578
4055
  menuOptions = ["Eject (export as .md)", "Disable", "Back"];
@@ -3878,7 +4355,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
3878
4355
  else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
3879
4356
  else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
3880
4357
  else if (modelChoice === "custom...") {
3881
- 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)");
3882
4360
  }
3883
4361
 
3884
4362
  // 5. Thinking
@@ -3930,6 +4408,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3930
4408
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3931
4409
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3932
4410
  graceTurns: getGraceTurns(),
4411
+ notificationResultChars,
3933
4412
  defaultJoinMode: getDefaultJoinMode(),
3934
4413
  backgroundByDefault: getBackgroundByDefault(),
3935
4414
  schedulingEnabled: isSchedulingEnabled(),
@@ -4010,6 +4489,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
4010
4489
 
4011
4490
  const NUMERIC_IDS = new Set([
4012
4491
  "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
4492
+ "notificationResultChars",
4013
4493
  ]);
4014
4494
 
4015
4495
  async function showSettings(ctx: ExtensionCommandContext) {
@@ -4063,6 +4543,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
4063
4543
  currentValue: String(msd),
4064
4544
  values: [String(msd)],
4065
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
+ },
4066
4553
  {
4067
4554
  id: "joinMode",
4068
4555
  label: "Join mode",
@@ -4260,6 +4747,14 @@ Write the file using the write tool. Only write the file, nothing else.`;
4260
4747
  setGraceTurns(n);
4261
4748
  notifyApplied(ctx, `Grace turns set to ${n}`);
4262
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
+ }
4263
4758
  } else if (id === "maxSubagentDepth") {
4264
4759
  const n = parseInt(value, 10);
4265
4760
  if (n >= 0) {
@@ -4401,7 +4896,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
4401
4896
  const enabled = value === "on";
4402
4897
  setRememberAgents(enabled);
4403
4898
  notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
4404
- } else if (id === "widgetMode") {
4899
+ } else {
4900
+ // widgetMode, the last row: every id `buildItems` offers has an arm.
4405
4901
  setWidgetMode(value as WidgetMode);
4406
4902
  notifyApplied(ctx, `Widget set to ${value}`);
4407
4903
  }
@@ -4461,7 +4957,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
4461
4957
  ? String(getDefaultMaxTurns() ?? 0)
4462
4958
  : result === "maxSubagentDepth"
4463
4959
  ? String(getMaxSubagentDepth())
4464
- : String(getGraceTurns());
4960
+ : result === "notificationResultChars"
4961
+ ? String(notificationResultChars)
4962
+ : String(getGraceTurns());
4465
4963
 
4466
4964
  const label = result === "maxConcurrent"
4467
4965
  ? "Max concurrency (1+)"
@@ -4471,7 +4969,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
4471
4969
  ? "Default max turns (0 = unlimited)"
4472
4970
  : result === "maxSubagentDepth"
4473
4971
  ? "Nested depth (0/1 = nesting off)"
4474
- : "Grace turns (1+)";
4972
+ : result === "notificationResultChars"
4973
+ ? `Notice result chars (0 = preview, max ${NOTIFICATION_RESULT_CHARS_CEILING})`
4974
+ : "Grace turns (1+)";
4475
4975
 
4476
4976
  // Loop until user enters a valid integer or cancels (Esc / null).
4477
4977
  // Silently trims whitespace; rejects non-numeric input by re-prompting.