@kushalkhemka/opensec-pi-subagents 0.20.2 → 0.20.4

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 (72) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +148 -7
  3. package/dist/agent-manager.d.ts +119 -9
  4. package/dist/agent-manager.js +263 -56
  5. package/dist/agent-runner.d.ts +11 -0
  6. package/dist/agent-runner.js +96 -22
  7. package/dist/completion-outbox.d.ts +65 -0
  8. package/dist/completion-outbox.js +108 -0
  9. package/dist/cross-extension-rpc.js +8 -0
  10. package/dist/env.js +3 -2
  11. package/dist/exec-tree.js +11 -2
  12. package/dist/index.d.ts +7 -1
  13. package/dist/index.js +846 -301
  14. package/dist/invocation-config.d.ts +17 -4
  15. package/dist/invocation-config.js +29 -2
  16. package/dist/nested-tools.js +10 -4
  17. package/dist/schedule.d.ts +1 -1
  18. package/dist/schedule.js +10 -4
  19. package/dist/session-handoff.d.ts +102 -0
  20. package/dist/session-handoff.js +162 -0
  21. package/dist/settings.d.ts +24 -12
  22. package/dist/settings.js +10 -5
  23. package/dist/types.d.ts +24 -16
  24. package/dist/ui/agent-widget.d.ts +4 -0
  25. package/dist/ui/agent-widget.js +5 -0
  26. package/dist/ui/conversation-viewer.d.ts +113 -74
  27. package/dist/ui/conversation-viewer.js +495 -377
  28. package/dist/ui/fleet-list.d.ts +35 -28
  29. package/dist/ui/fleet-list.js +41 -25
  30. package/dist/ui/viewer-keys.d.ts +10 -1
  31. package/dist/ui/viewer-keys.js +5 -0
  32. package/dist/ui/viewer-transcript.d.ts +152 -0
  33. package/dist/ui/viewer-transcript.js +721 -0
  34. package/dist/workflow/collisions.d.ts +3 -1
  35. package/dist/workflow/collisions.js +4 -1
  36. package/dist/workflow/host.js +30 -7
  37. package/dist/workflow/journal.d.ts +16 -8
  38. package/dist/workflow/journal.js +12 -9
  39. package/dist/workflow/runtime.d.ts +7 -1
  40. package/dist/workflow/runtime.js +38 -8
  41. package/dist/workflow/task.d.ts +11 -1
  42. package/dist/workflow/task.js +13 -1
  43. package/dist/workflow/tool-description.d.ts +9 -1
  44. package/dist/workflow/tool-description.js +21 -1
  45. package/docs/rpc.md +10 -10
  46. package/docs/workflows.md +4 -4
  47. package/examples/agent-tool-description.md +1 -3
  48. package/package.json +16 -11
  49. package/src/agent-manager.ts +288 -55
  50. package/src/agent-runner.ts +100 -21
  51. package/src/completion-outbox.ts +121 -0
  52. package/src/cross-extension-rpc.ts +8 -0
  53. package/src/env.ts +3 -2
  54. package/src/exec-tree.ts +11 -2
  55. package/src/index.ts +830 -292
  56. package/src/invocation-config.ts +44 -4
  57. package/src/nested-tools.ts +10 -4
  58. package/src/schedule.ts +10 -4
  59. package/src/session-handoff.ts +188 -0
  60. package/src/settings.ts +32 -16
  61. package/src/types.ts +24 -17
  62. package/src/ui/agent-widget.ts +8 -0
  63. package/src/ui/conversation-viewer.ts +487 -373
  64. package/src/ui/fleet-list.ts +39 -24
  65. package/src/ui/viewer-keys.ts +13 -1
  66. package/src/ui/viewer-transcript.ts +779 -0
  67. package/src/workflow/collisions.ts +4 -1
  68. package/src/workflow/host.ts +30 -7
  69. package/src/workflow/journal.ts +18 -9
  70. package/src/workflow/runtime.ts +50 -8
  71. package/src/workflow/task.ts +14 -0
  72. package/src/workflow/tool-description.ts +27 -1
package/dist/index.js CHANGED
@@ -12,21 +12,22 @@
12
12
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
13
  import { isAbsolute, join } from "node:path";
14
14
  import { defineTool, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
15
- import { Container, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
- import { Type } from "@sinclair/typebox";
15
+ import { Container, isKeyRelease, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
+ import { Type } from "typebox";
17
17
  import { abortable } from "./abortable.js";
18
18
  import { hasAgentBadge, renderAgentName } from "./agent-color.js";
19
19
  import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
20
- import { AgentManager, isTopLevelAgent, resumeStateOf, resumeStatesFromEntries } from "./agent-manager.js";
20
+ import { AgentManager, isTopLevelAgent, managerServices, RECORD_ENTRY_TYPE, RESUME_ENTRY_TYPE, resumeStateOf, resumeStatesFromEntries } from "./agent-manager.js";
21
21
  import { boundBashTimeout, getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
22
22
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
23
23
  import { inChildSessionContext } from "./child-context.js";
24
+ import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
24
25
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
25
26
  import { loadCustomAgents } from "./custom-agents.js";
26
27
  import { DEFAULT_AGENTS } from "./default-agents.js";
27
28
  import { useTreeKillingExec } from "./exec-tree.js";
28
29
  import { GroupJoinManager } from "./group-join.js";
29
- import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
30
+ import { isModelOverrideAllowed, isolationParam, resolveAgentInvocationConfig, resolveJoinMode, setModelOverrideAllowed } from "./invocation-config.js";
30
31
  import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
31
32
  import { runMentionClone } from "./mention-clone.js";
32
33
  import { describeModel, resolveModel } from "./model-resolver.js";
@@ -36,10 +37,12 @@ import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, ses
36
37
  import { isProjectCodeTrusted, isProjectTrustedBy, predictProjectTrust } from "./project-trust.js";
37
38
  import { SubagentScheduler } from "./schedule.js";
38
39
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
40
+ import { appendToSessionFile, HANDOFF_PROTOCOL, isCompatible, isSessionOnDisk, isSessionSwitch, PACKAGE_VERSION, parkSubagents, sessionFileOf, takeParkedSubagents } from "./session-handoff.js";
39
41
  import { applyAndEmitLoaded, applySettings, loadSettings, saveAndEmitChanged } from "./settings.js";
40
42
  import { agentIdLine, getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
41
43
  import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
42
44
  import { AgentWidget, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
45
+ import { ConversationViewer, getConversationOverlayOptions } from "./ui/conversation-viewer.js";
43
46
  import { FleetList } from "./ui/fleet-list.js";
44
47
  import { RefreshTicker } from "./ui/refresh-ticker.js";
45
48
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
@@ -57,7 +60,7 @@ import { runWorkflow } from "./workflow/runtime.js";
57
60
  import { resolveWorkflowScript } from "./workflow/saved.js";
58
61
  import { readWorkflowScriptFile } from "./workflow/script-file.js";
59
62
  import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, pruneSettledWorkflowTasks, resolveResumeTarget, updateWorkflowProgressBatch, workflowResultText, workflowRunId } from "./workflow/task.js";
60
- import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
63
+ import { workflowToolDescription } from "./workflow/tool-description.js";
61
64
  import { isWorktreeIsolationEnabled, MERGED_BRANCH_MAX_AGE_DAYS, pruneMergedAgentBranches, recoverLeftoverWorktrees, setWorktreeIsolationEnabled } from "./worktree.js";
62
65
  import { escapeXml } from "./xml.js";
63
66
  // ---- Shared helpers ----
@@ -71,6 +74,10 @@ export function renderRunningAgentStatus(frame, statsText, activity, theme) {
71
74
  container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
72
75
  return container;
73
76
  }
77
+ /** How long a foreground `Agent` call runs before its row offers the background key. */
78
+ const BACKGROUND_HINT_DELAY_MS = 2000;
79
+ /** The `backgroundKey` default: Claude Code's ctrl+b (`task:background`). */
80
+ const DEFAULT_BACKGROUND_KEY = "ctrl+b";
74
81
  /** Format an agent's lifetime token total, or "" when zero. */
75
82
  function formatLifetimeTokens(o) {
76
83
  const t = getLifetimeTotal(o.lifetimeUsage);
@@ -211,6 +218,116 @@ function buildNotificationDetails(record, resultMaxLen, activity) {
211
218
  : "No output.",
212
219
  };
213
220
  }
221
+ /** Session entry marking `pending` results (see {@link recordEntryData}) as delivered. */
222
+ const DELIVERED_ENTRY_TYPE = "subagents:delivered";
223
+ /**
224
+ * A top-level agent's `subagents:record` entry. `pending`: its session has not
225
+ * been notified yet — it finished while another session was open — and
226
+ * delivers the notification when it starts; the extra figures let it render
227
+ * one from the entry alone.
228
+ */
229
+ function recordEntryData(record, pending = false) {
230
+ return {
231
+ id: record.id, type: record.type, description: record.description,
232
+ status: record.status, result: record.result, error: record.error,
233
+ startedAt: record.startedAt, completedAt: record.completedAt,
234
+ resume: resumeStateOf(record),
235
+ ...(pending
236
+ ? { pending: true, toolUses: record.toolUses, lifetimeUsage: { ...record.lifetimeUsage }, outputFile: record.outputFile, toolCallId: record.toolCallId }
237
+ : {}),
238
+ };
239
+ }
240
+ /** An owned child's `subagents:resume` entry, or undefined without a session file to reopen. */
241
+ function resumeEntryData(record) {
242
+ const resume = resumeStateOf(record);
243
+ return resume ? { resume } : undefined;
244
+ }
245
+ /**
246
+ * The `pending` results among a session's entries that no later
247
+ * `subagents:delivered` entry covers, in order, as record-shaped objects a
248
+ * notification can be rendered from.
249
+ */
250
+ export function pendingResultsFromEntries(entries) {
251
+ const pending = new Map();
252
+ for (const entry of entries) {
253
+ const e = entry;
254
+ if (e?.type !== "custom" || !e.data)
255
+ continue;
256
+ if (e.customType === RECORD_ENTRY_TYPE && e.data.pending === true && typeof e.data.id === "string") {
257
+ const d = e.data;
258
+ pending.set(d.id, {
259
+ id: d.id,
260
+ type: String(d.type ?? ""),
261
+ description: String(d.description ?? ""),
262
+ status: d.status ?? "completed",
263
+ result: typeof d.result === "string" ? d.result : undefined,
264
+ error: typeof d.error === "string" ? d.error : undefined,
265
+ startedAt: typeof d.startedAt === "number" ? d.startedAt : 0,
266
+ completedAt: typeof d.completedAt === "number" ? d.completedAt : undefined,
267
+ toolUses: typeof d.toolUses === "number" ? d.toolUses : 0,
268
+ lifetimeUsage: d.lifetimeUsage ?? { input: 0, output: 0, cacheWrite: 0, cost: 0 },
269
+ compactionCount: 0,
270
+ outputFile: typeof d.outputFile === "string" ? d.outputFile : undefined,
271
+ toolCallId: typeof d.toolCallId === "string" ? d.toolCallId : undefined,
272
+ });
273
+ }
274
+ else if (e.customType === DELIVERED_ENTRY_TYPE && Array.isArray(e.data.ids)) {
275
+ for (const id of e.data.ids)
276
+ if (typeof id === "string")
277
+ pending.delete(id);
278
+ }
279
+ }
280
+ return [...pending.values()];
281
+ }
282
+ /**
283
+ * Stop a parked manager's agents with this (the parking instance's) code and
284
+ * record each in its own session, as a quit would. For an adopter of another
285
+ * version, or a parked manager something else replaced. Touches no instance
286
+ * state: the instance that parked it is gone.
287
+ */
288
+ async function stopParkedAgents(manager, entry, write) {
289
+ manager.setObservers({});
290
+ const put = (record, pending) => {
291
+ const [type, data] = isTopLevelAgent(record)
292
+ ? [RECORD_ENTRY_TYPE, recordEntryData(record, pending)]
293
+ : [RESUME_ENTRY_TYPE, resumeEntryData(record)];
294
+ if (!data)
295
+ return;
296
+ if (!write?.(record.ownerSession, type, data))
297
+ appendToSessionFile(record.ownerSession, type, data);
298
+ };
299
+ for (const record of entry.completed.splice(0))
300
+ put(record, isTopLevelAgent(record) && !record.resultConsumed);
301
+ const interrupted = manager.listAllAgents().filter(r => r.status === "running" || r.status === "queued");
302
+ manager.abortAll();
303
+ await manager.awaitWorktreeCleanup(15_000);
304
+ for (const record of interrupted)
305
+ put(record, false);
306
+ await manager.dispose();
307
+ }
308
+ /**
309
+ * The process is exiting with a manager still parked (no instance took it
310
+ * over): record what can be recorded, synchronously — results that settled
311
+ * while parked, and the running agents as stopped. Worktrees cannot be
312
+ * committed this late; a later start recovers them (`recoverLeftoverWorktrees`).
313
+ */
314
+ function recordParkedAgentsAtExit(manager, entry) {
315
+ manager.setObservers({});
316
+ const put = (record, pending) => {
317
+ const data = isTopLevelAgent(record) ? recordEntryData(record, pending) : resumeEntryData(record);
318
+ if (data)
319
+ appendToSessionFile(record.ownerSession, isTopLevelAgent(record) ? RECORD_ENTRY_TYPE : RESUME_ENTRY_TYPE, data);
320
+ };
321
+ for (const record of entry.completed.splice(0))
322
+ put(record, isTopLevelAgent(record) && !record.resultConsumed);
323
+ for (const record of manager.listAllAgents()) {
324
+ if (record.status !== "running" && record.status !== "queued")
325
+ continue;
326
+ record.status = "stopped";
327
+ record.completedAt ??= Date.now();
328
+ put(record, false);
329
+ }
330
+ }
214
331
  /**
215
332
  * Format an agent's tool scope for the Agent tool description.
216
333
  *
@@ -424,23 +541,13 @@ export default function (pi) {
424
541
  let showModel = false;
425
542
  function isShowModelEnabled() { return showModel; }
426
543
  function setShowModel(b) { showModel = b; widget.update(); }
427
- /**
428
- * How much of the conversation viewer renders as Markdown. Read through a
429
- * getter by the viewer rather than captured like `showCost`, because the
430
- * viewer's `m` key writes back here while the overlay is on screen.
431
- */
432
- let viewerMarkdown = "assistant";
433
- function getViewerMarkdown() { return viewerMarkdown; }
434
- function setViewerMarkdown(mode) { viewerMarkdown = mode; }
435
- /**
436
- * The viewer's `m` key, from either entry point: set the mode and persist it,
437
- * so the key and `/agents → Settings` stay one setting rather than one per
438
- * entry point. `ctx` carries only the warning a failed write notifies with,
439
- * and the fleet list may be acting without one.
440
- */
441
- function chooseViewerMarkdown(mode, ctx) {
442
- setViewerMarkdown(mode);
443
- persistSettings(ctx, `Viewer markdown set to ${mode}`);
544
+ /** The `backgroundKey` setting as written; undefined means the default. Persisted as written. */
545
+ let backgroundKeySetting;
546
+ function setBackgroundKey(key) { backgroundKeySetting = key; }
547
+ /** The key that hands foreground `Agent` calls to the background, or undefined when it is off. */
548
+ function getBackgroundKey() {
549
+ const key = backgroundKeySetting ?? DEFAULT_BACKGROUND_KEY;
550
+ return key === "off" ? undefined : key;
444
551
  }
445
552
  const pendingUsage = new PendingUsagePool();
446
553
  /**
@@ -483,82 +590,86 @@ export default function (pi) {
483
590
  }
484
591
  catch { /* stale ctx */ }
485
592
  }
486
- // ---- Cancellable pending notifications ----
487
- // Holds notifications briefly so get_subagent_result can cancel them
488
- // before they reach pi.sendMessage (fire-and-forget).
489
- const pendingNudges = new Map();
490
- const NUDGE_HOLD_MS = 200;
491
- // A queued result wait must observe completion before its held notification
492
- // can fire, so successful waits can still suppress that redundant nudge.
493
- const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
494
- function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
495
- cancelNudge(key);
496
- pendingNudges.set(key, setTimeout(() => {
497
- pendingNudges.delete(key);
498
- try {
499
- send();
500
- }
501
- catch { /* ignore stale completion side-effect errors */ }
502
- }, delay));
503
- }
504
- function cancelNudge(key) {
505
- const timer = pendingNudges.get(key);
506
- if (timer != null) {
507
- clearTimeout(timer);
508
- pendingNudges.delete(key);
509
- }
510
- }
511
- // ---- Individual nudge helper (async join mode) ----
512
- function emitIndividualNudge(record) {
513
- if (record.resultConsumed)
514
- return; // re-check at send time
515
- const notification = formatTaskNotification(record, 500, showCost);
516
- const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
593
+ // ---- Completion delivery ----
594
+ // Every completion notification goes through the outbox, which sends what is
595
+ // pending as one message at the parent's next boundary (see
596
+ // completion-outbox.ts). Each is built when the agent finishes, and reading
597
+ // the result (get_subagent_result, rpc consume) cancels it before it is sent.
598
+ const outbox = new CompletionOutbox((message) => {
599
+ // A torn-down session cannot take a message (`pi` is stale).
600
+ if (disposed)
601
+ return;
517
602
  pi.sendMessage({
518
603
  customType: "subagent-notification",
519
- content: notification + footer,
604
+ content: message.content,
520
605
  display: true,
521
- details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
522
- }, { deliverAs: "followUp", triggerTurn: true });
606
+ details: message.details,
607
+ }, { deliverAs: "steer", triggerTurn: true });
608
+ }, () => parentTurnActive());
609
+ /** Whether the parent has a run in flight, so a `turn_end` is coming. A stale ctx has none. */
610
+ function parentTurnActive() {
611
+ try {
612
+ return currentCtx?.signal !== undefined;
613
+ }
614
+ catch {
615
+ return false;
616
+ }
523
617
  }
618
+ // A queued result wait must observe completion before its held notification
619
+ // can fire, so successful waits can still suppress that redundant nudge.
620
+ const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
524
621
  function sendIndividualNudge(record) {
525
622
  agentActivity.delete(record.id);
526
623
  widget.markFinished(record.id);
527
624
  fleet.onAgentFinished(record.id);
528
- scheduleNudge(record.id, () => emitIndividualNudge(record));
625
+ // Built now, so a resume before the send cannot rewrite it. A record that
626
+ // cannot be formatted is skipped, as a failed send always was.
627
+ try {
628
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
629
+ const message = {
630
+ content: formatTaskNotification(record, 500, showCost) + footer,
631
+ details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
632
+ };
633
+ outbox.add({ ids: [record.id], build: () => message });
634
+ }
635
+ catch { /* ignore stale completion side-effect errors */ }
529
636
  widget.update();
530
637
  }
531
- // ---- Group join manager ----
638
+ // ---- Group join manager (`group` join mode only) ----
532
639
  const groupJoin = new GroupJoinManager((records, partial) => {
533
640
  for (const r of records) {
534
641
  agentActivity.delete(r.id);
535
642
  widget.markFinished(r.id);
536
643
  fleet.onAgentFinished(r.id);
537
644
  }
538
- const groupKey = `group:${records.map(r => r.id).join(",")}`;
539
- scheduleNudge(groupKey, () => {
540
- // Re-check at send time
541
- const unconsumed = records.filter(r => !r.resultConsumed);
542
- if (unconsumed.length === 0) {
543
- widget.update();
544
- return;
645
+ const members = records.filter(r => !r.resultConsumed).flatMap(r => {
646
+ try {
647
+ return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
545
648
  }
546
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300, showCost)).join('\n\n');
547
- const label = partial
548
- ? `${unconsumed.length} agent(s) finished (partial — others still running)`
549
- : `${unconsumed.length} agent(s) finished`;
550
- const [first, ...rest] = unconsumed;
551
- const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
552
- if (rest.length > 0) {
553
- details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
649
+ catch {
650
+ return []; /* as in sendIndividualNudge */
554
651
  }
555
- pi.sendMessage({
556
- customType: "subagent-notification",
557
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
558
- display: true,
559
- details,
560
- }, { deliverAs: "followUp", triggerTurn: true });
561
652
  });
653
+ if (members.length > 0) {
654
+ outbox.add({
655
+ ids: members.map(m => m.id),
656
+ build: (ids) => {
657
+ const unread = members.filter(m => ids.includes(m.id));
658
+ const notifications = unread.map(m => m.text).join('\n\n');
659
+ const label = partial
660
+ ? `${unread.length} agent(s) finished (partial — others still running)`
661
+ : `${unread.length} agent(s) finished`;
662
+ const [first, ...rest] = unread;
663
+ const details = first.details;
664
+ if (rest.length > 0)
665
+ details.others = rest.map(m => m.details);
666
+ return {
667
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
668
+ details,
669
+ };
670
+ },
671
+ });
672
+ }
562
673
  widget.update();
563
674
  }, 30_000);
564
675
  /** Helper: build event data for lifecycle events from an AgentRecord. */
@@ -598,38 +709,86 @@ export default function (pi) {
598
709
  usage,
599
710
  };
600
711
  }
601
- // Background completion: route through group join or send individual nudge
602
712
  /**
603
- * Write a top-level agent's final record into the parent session. `resume`
604
- * carries what reopening it needs (session file, handle, model, directory,
605
- * worktree, isolation, turn limit); `session_start` reads it back, so the
606
- * agent stays resumable after a restart, `/resume`, `/fork` or `/reload`.
607
- * Guarded: it also runs while the session is shutting down.
713
+ * The session this activation belongs to: its session file, or undefined for
714
+ * one kept in memory. Agents spawned here carry it as `ownerSession`; a
715
+ * background agent that outlives a session switch (session-handoff.ts)
716
+ * reports to its owner, not to whichever session is open.
608
717
  */
609
- function appendRecordEntry(record) {
610
- try {
611
- pi.appendEntry("subagents:record", {
612
- id: record.id, type: record.type, description: record.description,
613
- status: record.status, result: record.result, error: record.error,
614
- startedAt: record.startedAt, completedAt: record.completedAt,
615
- resume: resumeStateOf(record),
616
- });
718
+ let currentSession;
719
+ const ownedHere = (record) => record.ownerSession === currentSession;
720
+ /**
721
+ * Write a custom entry into the session `owner`: through `pi` while that is
722
+ * the session this instance has open (Pi keeps it in memory), otherwise by
723
+ * appending to the owner's file, which Pi reads when it is opened again.
724
+ * Guarded: it also runs while the session is shutting down, and after.
725
+ */
726
+ function writeEntry(owner, customType, data) {
727
+ if (owner === currentSession) {
728
+ try {
729
+ pi.appendEntry(customType, data);
730
+ return true;
731
+ }
732
+ catch { /* torn down: the session is no longer open, so its file is safe to append to */ }
617
733
  }
618
- catch { /* a session already torn down cannot record anything */ }
734
+ return appendToSessionFile(owner, customType, data);
735
+ }
736
+ // Background completion: route through group join or send individual nudge
737
+ /**
738
+ * Write a top-level agent's final record into the session it belongs to.
739
+ * `resume` carries what reopening it needs (session file, handle, model,
740
+ * directory, worktree, isolation, turn limit); `session_start` reads it back,
741
+ * so the agent stays resumable after a restart, `/resume`, `/fork` or
742
+ * `/reload`. `pending` marks a result its session has not been told about
743
+ * yet (it finished while another session was open); that session delivers it
744
+ * when it starts.
745
+ */
746
+ function appendRecordEntry(record, pending = false) {
747
+ writeEntry(record.ownerSession, RECORD_ENTRY_TYPE, recordEntryData(record, pending));
748
+ }
749
+ /**
750
+ * Write an owned child's resume state (a nested agent's, a workflow's) into
751
+ * the session it belongs to, so `session_start` can rebuild it after a
752
+ * restart, `/resume`, `/fork` or `/reload`: its owner can then reopen it from
753
+ * its session file — the parent agent's nested tools, or a workflow's
754
+ * `agent({ resume })`. Only the resume state, never the result: the owner
755
+ * already has that, and the entry stays small. Nothing for an agent without
756
+ * a session file.
757
+ */
758
+ function appendResumeEntry(record) {
759
+ const data = resumeEntryData(record);
760
+ if (data)
761
+ writeEntry(record.ownerSession, RESUME_ENTRY_TYPE, data);
762
+ }
763
+ /**
764
+ * A top-level agent of another session finished while this one is open: its
765
+ * result belongs to that session. Record it there, marked for delivery; no
766
+ * notification, event or row here.
767
+ */
768
+ function completeForeignAgent(record) {
769
+ agentActivity.delete(record.id);
770
+ appendRecordEntry(record, !record.resultConsumed);
619
771
  }
620
- const manager = new AgentManager((record) => {
772
+ function onAgentComplete(record) {
621
773
  // After shutdown `pi` is stale, and the shutdown handler records the
622
774
  // agents it interrupted itself.
623
775
  if (disposed)
624
776
  return;
625
- // Spend that landed after the parent's last subagent tool result.
626
- flushPendingUsageIfIdle();
627
777
  // Owned children — nested, or a workflow's — report only through their
628
778
  // owner: the parent's scoped tools, or the workflow's card, notification
629
779
  // and dialog. Keep them out of top-level lifecycle, transcript,
630
- // notification, and UI channels.
631
- if (!isTopLevelAgent(record))
780
+ // notification, and UI channels. Their resume state is still recorded.
781
+ if (!isTopLevelAgent(record)) {
782
+ flushPendingUsageIfIdle();
783
+ appendResumeEntry(record);
784
+ return;
785
+ }
786
+ if (!ownedHere(record)) {
787
+ completeForeignAgent(record);
632
788
  return;
789
+ }
790
+ // Spend that landed after the parent's last subagent tool result.
791
+ flushPendingUsageIfIdle();
633
792
  // Emit lifecycle event based on terminal status
634
793
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
635
794
  const eventData = buildEventData(record);
@@ -650,9 +809,9 @@ export default function (pi) {
650
809
  widget.update();
651
810
  return;
652
811
  }
653
- // If this agent is pending batch finalization (debounce window still open),
654
- // don't send an individual nudge — finalizeBatch will pick it up retroactively.
655
- if (currentBatchAgents.some(a => a.id === record.id)) {
812
+ // A `group` spawn whose turn has not ended yet: its group is registered at
813
+ // that turn's end, which picks this completion up retroactively.
814
+ if (turnGroupSpawns.includes(record.id)) {
656
815
  widget.update();
657
816
  return;
658
817
  }
@@ -663,8 +822,9 @@ export default function (pi) {
663
822
  // 'held' → do nothing, group will fire later
664
823
  // 'delivered' → group callback already fired
665
824
  widget.update();
666
- }, undefined, (record) => {
667
- if (disposed || !isTopLevelAgent(record))
825
+ }
826
+ function onAgentStart(record) {
827
+ if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
668
828
  return;
669
829
  // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
670
830
  // and scheduler spawns enter through the manager directly.
@@ -680,8 +840,9 @@ export default function (pi) {
680
840
  type: record.type,
681
841
  description: record.description,
682
842
  });
683
- }, (record, info) => {
684
- if (disposed || !isTopLevelAgent(record))
843
+ }
844
+ function onAgentCompact(record, info) {
845
+ if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
685
846
  return;
686
847
  // Emit compacted event when agent's session compacts (preserves count on record).
687
848
  pi.events.emit("subagents:compacted", {
@@ -692,14 +853,28 @@ export default function (pi) {
692
853
  tokensBefore: info.tokensBefore,
693
854
  compactionCount: record.compactionCount,
694
855
  });
695
- }, (_record, usage) => {
856
+ }
857
+ function onAgentUsage(record, usage) {
696
858
  // Every assistant message from every agent — nested included, exactly once.
697
859
  // Parked here until a tool result can carry it back to the parent session;
698
860
  // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
699
- // pool grows in a session that will never drain it.
700
- if (reportUsage)
861
+ // pool grows in a session that will never drain it. Another session's
862
+ // agents bill that session, not this one.
863
+ if (reportUsage && ownedHere(record))
701
864
  pendingUsage.add(usage);
702
- });
865
+ }
866
+ const managerObservers = {
867
+ onComplete: onAgentComplete,
868
+ onStart: onAgentStart,
869
+ onCompact: onAgentCompact,
870
+ onUsage: onAgentUsage,
871
+ };
872
+ /**
873
+ * Replaced at `session_start` by a manager the previous session parked, so
874
+ * its background agents keep running (see session-handoff.ts). Everything
875
+ * reads it through this binding, never a copy.
876
+ */
877
+ let manager = new AgentManager(onAgentComplete, undefined, onAgentStart, onAgentCompact, onAgentUsage);
703
878
  // Expose manager via Symbol.for() global registry for cross-package access.
704
879
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
705
880
  // Documented for callers in docs/rpc.md ("The manager registry").
@@ -770,6 +945,14 @@ export default function (pi) {
770
945
  delete safeOptions.reclaim;
771
946
  // Internal to a reopen, like `reclaim`: it seeds an agent's lifetime figures.
772
947
  delete safeOptions.carry;
948
+ // A caller's model and thinking level are per-call overrides, which
949
+ // `allowModelOverride` (off by default) refuses on every spawn path: the
950
+ // agent then runs on the parent's current model and level, or its agent
951
+ // file's.
952
+ if (!isModelOverrideAllowed()) {
953
+ delete safeOptions.model;
954
+ delete safeOptions.thinkingLevel;
955
+ }
773
956
  // Every spawn through here is DETACHED — the caller gets an id back and
774
957
  // awaits nothing. A forged `blocking` would charge it to the foreground
775
958
  // pool and could defer it behind a queue whose gate nobody is holding.
@@ -785,8 +968,9 @@ export default function (pi) {
785
968
  */
786
969
  const resolveAgentRef = (ref) => {
787
970
  const byId = manager.getRecord(ref);
971
+ // Another session's agent is not this session's to read or steer.
788
972
  if (byId)
789
- return byId;
973
+ return manager.isVisible(byId) ? byId : undefined;
790
974
  const resolved = manager.resolveMention(ref);
791
975
  return resolved?.kind === "live" ? resolved.record : undefined;
792
976
  };
@@ -796,7 +980,7 @@ export default function (pi) {
796
980
  spawn: spawnTopLevel,
797
981
  getRecord: (id) => {
798
982
  const record = manager.getRecord(id);
799
- return record !== undefined && isTopLevelAgent(record) ? record : undefined;
983
+ return record !== undefined && isTopLevelAgent(record) && manager.isVisible(record) ? record : undefined;
800
984
  },
801
985
  };
802
986
  const ownsManagerRegistry = globalThis[MANAGER_KEY] === undefined;
@@ -842,17 +1026,26 @@ export default function (pi) {
842
1026
  pi.on("session_start", async (_event, ctx) => {
843
1027
  disposed = false;
844
1028
  currentCtx = ctx;
1029
+ currentSession = sessionFileOf(ctx);
1030
+ // First: everything below works on `manager`, which this may replace.
1031
+ adoptParkedAgents();
1032
+ manager.setVisibleOwner(currentSession);
845
1033
  syncProjectTrust(ctx);
846
1034
  void tidyAgentWorktrees(ctx);
847
1035
  if (ctx.hasUI) {
848
1036
  widget.setUICtx(ctx.ui);
849
1037
  fleet.setUICtx(ctx.ui);
1038
+ watchHandoffKey(ctx.ui);
850
1039
  }
851
1040
  manager.clearCompleted(true);
852
1041
  // Agents of this session from before a restart, `/resume`, `/fork` or
853
1042
  // `/reload`: resumable again, by the model and by `@handle`. The branch,
854
1043
  // not every entry: a fork keeps only the agents on its own path.
855
- manager.restoreTombstones(resumeStatesFromEntries(ctx.sessionManager?.getBranch?.() ?? []));
1044
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
1045
+ manager.restoreTombstones(resumeStatesFromEntries(branch));
1046
+ // Results of this session's agents that finished while another session
1047
+ // was open, recorded here for delivery now.
1048
+ deliverPendingResults(branch);
856
1049
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
857
1050
  // fires once per activation, but a double-bind must not leak listeners.
858
1051
  if (!rpcHandle) {
@@ -863,7 +1056,10 @@ export default function (pi) {
863
1056
  manager: {
864
1057
  spawn: spawnTopLevel,
865
1058
  awaitStartup: (id) => manager.awaitStartup(id),
866
- getRecord: (id) => manager.getRecord(id),
1059
+ getRecord: (id) => {
1060
+ const record = manager.getRecord(id);
1061
+ return record && manager.isVisible(record) ? record : undefined;
1062
+ },
867
1063
  // Unguarded on purpose: the stop handler now runs the top-level check
868
1064
  // itself off `getRecord`, and reports the refusal instead of the
869
1065
  // "Agent not found" a false from here used to be read as.
@@ -880,7 +1076,7 @@ export default function (pi) {
880
1076
  if (manager.isRunActive(record.id))
881
1077
  return false;
882
1078
  record.resultConsumed = true;
883
- cancelNudge(record.id);
1079
+ outbox.cancel(record.id);
884
1080
  return true;
885
1081
  },
886
1082
  },
@@ -1143,9 +1339,130 @@ export default function (pi) {
1143
1339
  // it returns. Pi confirms replacement by emitting session_shutdown; keep
1144
1340
  // the current schedules and agent records intact until then.
1145
1341
  });
1146
- // On shutdown, abort all agents immediately and clean up.
1147
- // If the session is going down, there's nothing left to consume agent results.
1148
- pi.on("session_shutdown", async () => {
1342
+ /**
1343
+ * Take over the manager the previous session parked at its switch, if it was
1344
+ * parked for this session by this same version: its background agents keep
1345
+ * running and show here when they are this session's. Another version cannot
1346
+ * share the manager's code, so it stops its agents itself, as a quit would.
1347
+ * See session-handoff.ts.
1348
+ */
1349
+ function adoptParkedAgents() {
1350
+ const parked = takeParkedSubagents(currentSession);
1351
+ if (!parked)
1352
+ return;
1353
+ if (!isCompatible(parked)) {
1354
+ void parked.stop(writeEntry).catch(() => { });
1355
+ return;
1356
+ }
1357
+ const adopted = parked.manager;
1358
+ const fresh = manager;
1359
+ // New work runs with this instance's modules (agent registry, settings).
1360
+ adopted.useServices(managerServices());
1361
+ adopted.setObservers(managerObservers);
1362
+ adopted.setMaxConcurrent(fresh.getMaxConcurrent());
1363
+ adopted.setMaxConcurrentForeground(fresh.getMaxConcurrentForeground());
1364
+ manager = adopted;
1365
+ widget.setManager(adopted);
1366
+ fleet.setManager(adopted);
1367
+ for (const [id, state] of parked.activity) {
1368
+ if (!agentActivity.has(id))
1369
+ agentActivity.set(id, state);
1370
+ }
1371
+ void fresh.dispose();
1372
+ // Runs that settled between the two instances, handled as if just now —
1373
+ // once this start has finished setting up.
1374
+ const settled = parked.completed;
1375
+ if (settled.length > 0)
1376
+ setTimeout(() => { for (const record of settled)
1377
+ onAgentComplete(record); }, 0);
1378
+ }
1379
+ /**
1380
+ * At a session switch: leave the background agents running in a manager
1381
+ * parked for the next instance (session-handoff.ts). Nothing parked here
1382
+ * may touch this instance's `pi` or ctx, which Pi invalidates next: until
1383
+ * adopted, completions are queued in the entry.
1384
+ */
1385
+ function parkAgents(reason, targetSessionFile) {
1386
+ const parkedManager = manager;
1387
+ const entry = {
1388
+ protocol: HANDOFF_PROTOCOL,
1389
+ version: PACKAGE_VERSION,
1390
+ reason,
1391
+ sessionFile: currentSession,
1392
+ targetSessionFile,
1393
+ manager: parkedManager,
1394
+ activity: new Map(agentActivity),
1395
+ completed: [],
1396
+ stop: (write) => stopParkedAgents(parkedManager, entry, write),
1397
+ };
1398
+ parkedManager.setObservers({ onComplete: (record) => { entry.completed.push(record); } });
1399
+ parkedManager.detachPendingContexts();
1400
+ parkedManager.setVisibleOwner(null);
1401
+ parkSubagents(entry, () => recordParkedAgentsAtExit(parkedManager, entry));
1402
+ }
1403
+ /**
1404
+ * Notify this session of its agents that finished while another session was
1405
+ * open (`pending` record entries), once, after this start: a turn follows,
1406
+ * as for any background completion. Marked delivered at once, so a second
1407
+ * start does not repeat it.
1408
+ */
1409
+ function deliverPendingResults(branch) {
1410
+ const pending = pendingResultsFromEntries(branch);
1411
+ if (pending.length === 0)
1412
+ return;
1413
+ const ids = pending.map(r => r.id);
1414
+ try {
1415
+ pi.appendEntry(DELIVERED_ENTRY_TYPE, { ids });
1416
+ }
1417
+ catch {
1418
+ return;
1419
+ }
1420
+ // The record still in memory carries more (context, compactions) than its entry.
1421
+ const records = pending.map(entry => {
1422
+ const live = manager.getRecord(entry.id);
1423
+ return live && manager.isVisible(live) ? live : entry;
1424
+ });
1425
+ // Through the completion outbox, like every other completion: delivered at
1426
+ // this session's next boundary (a turn when idle), coalesced with anything
1427
+ // else that finished, and cancelled per agent by get_subagent_result.
1428
+ // An entry is all there is of an evicted agent, and get_subagent_result
1429
+ // cannot fetch more, so its whole result goes in.
1430
+ const maxLen = (r) => (manager.getRecord(r.id) === r ? 500 : 20_000);
1431
+ for (const r of records) {
1432
+ agentActivity.delete(r.id);
1433
+ widget.markFinished(r.id);
1434
+ fleet.onAgentFinished(r.id);
1435
+ }
1436
+ const parts = records.flatMap(r => {
1437
+ try {
1438
+ return [{ id: r.id, text: formatTaskNotification(r, maxLen(r), showCost), details: buildNotificationDetails(r, 300) }];
1439
+ }
1440
+ catch {
1441
+ return []; /* as in sendIndividualNudge */
1442
+ }
1443
+ });
1444
+ if (parts.length > 0) {
1445
+ outbox.add({
1446
+ ids: parts.map(p => p.id),
1447
+ build: (unreadIds) => {
1448
+ const unread = parts.filter(p => unreadIds.includes(p.id));
1449
+ const [first, ...rest] = unread;
1450
+ const details = { ...first.details };
1451
+ if (rest.length > 0)
1452
+ details.others = rest.map(p => p.details);
1453
+ const label = unread.length === 1 ? "An agent" : `${unread.length} agents`;
1454
+ return {
1455
+ content: `${label} of this session finished while another session was open.\n\n${unread.map(p => p.text).join("\n\n")}`,
1456
+ details,
1457
+ };
1458
+ },
1459
+ });
1460
+ }
1461
+ widget.update();
1462
+ }
1463
+ // On shutdown: on a session switch, background agents keep running (parked
1464
+ // for the next instance); on quit, every agent is stopped and recorded.
1465
+ pi.on("session_shutdown", async (event) => {
1149
1466
  // Spend no tool result carried back yet: record it while the session can.
1150
1467
  flushPendingUsage();
1151
1468
  disposed = true;
@@ -1171,26 +1488,73 @@ export default function (pi) {
1171
1488
  for (const task of workflowTasks.values())
1172
1489
  task.abortController.abort();
1173
1490
  workflowTasks.clear();
1174
- // Pi tears this extension instance down with the session, so agents still
1175
- // running cannot be kept alive across the switch. Stop them, and record
1176
- // each as stopped and resumable in the session it belongs to.
1177
- const interrupted = manager.listAgents().filter(r => isTopLevelAgent(r) && (r.status === "running" || r.status === "queued"));
1178
- manager.abortAll();
1491
+ const live = manager.listAllAgents().filter(r => r.status === "running" || r.status === "queued");
1492
+ // Pi says why (Pi >= 1.0): `new`, `resume`, `fork` and `reload` replace the
1493
+ // session within this process; `quit` (also /quit and SIGTERM/SIGHUP) or no
1494
+ // reason ends it. On a switch, a background agent whose session is on disk
1495
+ // keeps running — its results can still be recorded there — along with
1496
+ // the agents it owns. Foreground agents (Pi aborted their tool call before
1497
+ // this), workflows and agents of a session kept in memory stop as before.
1498
+ const { reason, targetSessionFile } = (event ?? {});
1499
+ // The next instance finds the parked manager by the session it starts, so
1500
+ // a switch must name it (Pi 1.0 does; `/reload` keeps this one).
1501
+ const switching = isSessionSwitch(reason)
1502
+ && (reason === "reload" ? currentSession !== undefined : typeof targetSessionFile === "string");
1503
+ const kept = new Set(switching
1504
+ ? live.filter(r => isTopLevelAgent(r) && r.isBackground !== false && isSessionOnDisk(r.ownerSession)).map(r => r.id)
1505
+ : []);
1506
+ const rootOf = (record) => {
1507
+ let current = record;
1508
+ for (let parent = current.parentAgentId; parent !== undefined; parent = current.parentAgentId) {
1509
+ const next = manager.getRecord(parent);
1510
+ if (!next)
1511
+ break;
1512
+ current = next;
1513
+ }
1514
+ return current.id;
1515
+ };
1516
+ const interrupted = live.filter(r => !kept.has(rootOf(r)));
1517
+ // Also worth keeping: another session's agents that finished while this
1518
+ // one was open, so their session shows them in full when it is reopened.
1519
+ const carried = switching && manager.listAllAgents().some(r => isTopLevelAgent(r) && !ownedHere(r) && !r.resultConsumed && !manager.isRunActive(r.id) && isSessionOnDisk(r.ownerSession));
1520
+ const parking = kept.size > 0 || carried;
1521
+ if (kept.size > 0) {
1522
+ for (const record of interrupted)
1523
+ manager.abort(record.id);
1524
+ }
1525
+ else {
1526
+ manager.abortAll();
1527
+ }
1179
1528
  // A stopped worktree agent still commits its changes to a branch while it
1180
- // settles; dispose() does not wait for that, and the process exits next.
1181
- await manager.awaitWorktreeCleanup(15_000);
1182
- for (const record of interrupted)
1183
- appendRecordEntry(record);
1184
- for (const timer of pendingNudges.values())
1185
- clearTimeout(timer);
1186
- pendingNudges.clear();
1529
+ // settles; dispose() does not wait for that, and the process may exit next.
1530
+ await manager.awaitWorktreeCleanup(15_000, kept.size > 0 ? new Set(interrupted.map(r => r.id)) : undefined);
1531
+ // Each recorded as stopped and resumable, in the session it belongs to.
1532
+ for (const record of interrupted) {
1533
+ if (isTopLevelAgent(record))
1534
+ appendRecordEntry(record);
1535
+ else
1536
+ appendResumeEntry(record);
1537
+ }
1538
+ if (parking)
1539
+ parkAgents(reason, targetSessionFile);
1540
+ // Completions still waiting for this session's next boundary (a busy or
1541
+ // interrupted parent): marked pending in the session they belong to, so
1542
+ // its next start (a switch back, a restart) delivers them like any result
1543
+ // that landed while another session was open.
1544
+ for (const id of outbox.pendingIds()) {
1545
+ const record = manager.getRecord(id);
1546
+ if (record && isTopLevelAgent(record) && !record.resultConsumed && !manager.isRunActive(id))
1547
+ appendRecordEntry(record, true);
1548
+ }
1187
1549
  // Neither may fire into a torn-down instance (or hold `pi -p` open).
1550
+ outbox.dispose();
1188
1551
  groupJoin.dispose();
1189
- if (batchFinalizeTimer)
1190
- clearTimeout(batchFinalizeTimer);
1191
- batchFinalizeTimer = undefined;
1192
- currentBatchAgents = [];
1552
+ turnGroupSpawns = [];
1193
1553
  fleet.dispose();
1554
+ unsubHandoffKey?.();
1555
+ unsubHandoffKey = undefined;
1556
+ handoffKeyUi = undefined;
1557
+ foregroundHandoffs.clear();
1194
1558
  // Its 80 ms refresh interval otherwise outlives the session across /reload.
1195
1559
  // The interval is cleared first; the UI calls after it may meet a context
1196
1560
  // that is already gone.
@@ -1199,6 +1563,9 @@ export default function (pi) {
1199
1563
  }
1200
1564
  catch { /* stale UI context at shutdown */ }
1201
1565
  refreshTicker.clear();
1566
+ // A parked manager lives on, with its agents' sessions.
1567
+ if (parking)
1568
+ return;
1202
1569
  // Awaited: it emits `session_shutdown` into every retained child session so
1203
1570
  // extensions bound there can release what they armed in `session_start` (#242).
1204
1571
  // pi awaits this handler, and the process exits right after — unawaited, those
@@ -1217,10 +1584,120 @@ export default function (pi) {
1217
1584
  const refreshTicker = new RefreshTicker();
1218
1585
  const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled, refreshTicker);
1219
1586
  function setWidgetMode(m) { widgetMode = m; widget.update(); }
1587
+ /**
1588
+ * Foreground `Agent` calls whose run can be handed to the background, by
1589
+ * agent id. Each entry wakes its blocked tool call, which then returns as a
1590
+ * background spawn would. Only the Agent tool registers here, so a spawn with
1591
+ * no tool call to return — the agent generator, a workflow's agents, nested
1592
+ * children — never offers the handoff.
1593
+ */
1594
+ const foregroundHandoffs = new Map();
1595
+ /** Hand these runs to the background and wake their tool calls; returns how many took. */
1596
+ function sendForegroundToBackground(ids) {
1597
+ let sent = 0;
1598
+ for (const id of [...ids]) {
1599
+ const wake = foregroundHandoffs.get(id);
1600
+ if (!wake || !manager.sendToBackground(id))
1601
+ continue;
1602
+ foregroundHandoffs.delete(id);
1603
+ wake();
1604
+ sent++;
1605
+ // It is a background agent from here on, so announce it as one — as a
1606
+ // detached resume re-announces an existing id.
1607
+ const record = manager.getRecord(id);
1608
+ if (record) {
1609
+ pi.events.emit("subagents:created", {
1610
+ id,
1611
+ type: record.type,
1612
+ description: record.description,
1613
+ isBackground: true,
1614
+ });
1615
+ }
1616
+ }
1617
+ if (sent > 0) {
1618
+ widget.ensureTimer();
1619
+ widget.update();
1620
+ fleet.ensureTimer();
1621
+ fleet.update();
1622
+ }
1623
+ return sent;
1624
+ }
1625
+ /** The conversation viewer's `b`: hand just this run to the background. */
1626
+ function backgroundActionFor(record, notify) {
1627
+ return {
1628
+ // Registered while the run is live, removed the moment its call returns.
1629
+ available: () => foregroundHandoffs.has(record.id),
1630
+ send: () => {
1631
+ if (sendForegroundToBackground([record.id]) > 0)
1632
+ notify(`Sent "${record.description}" to the background.`);
1633
+ },
1634
+ };
1635
+ }
1636
+ // Claude Code's ctrl+b (`task:background`), configurable as `backgroundKey`:
1637
+ // hand every running foreground `Agent` call to the background. Claimed only
1638
+ // while one is pending and the prompt editor has focus, so the rest of the
1639
+ // time the key keeps its editor meaning (ctrl+b is pi's cursor-left).
1640
+ let handoffKeyUi;
1641
+ let unsubHandoffKey;
1642
+ function watchHandoffKey(ui) {
1643
+ if (ui === handoffKeyUi)
1644
+ return;
1645
+ unsubHandoffKey?.();
1646
+ handoffKeyUi = ui;
1647
+ unsubHandoffKey = ui.onTerminalInput(data => {
1648
+ // Cheapest checks first: this listener sees every key the user types.
1649
+ if (foregroundHandoffs.size === 0)
1650
+ return undefined;
1651
+ const key = getBackgroundKey();
1652
+ // Kitty reports a release too, and matchesKey matches it — act on the
1653
+ // press only, or a refused handoff would be retried by its own release.
1654
+ if (key === undefined || isKeyRelease(data) || !matchesKey(data, key))
1655
+ return undefined;
1656
+ // Listeners run before the focused component, so a dialog or the
1657
+ // viewer's composer would lose the key. The viewer has its own `b`.
1658
+ if (!fleet.editorHasFocus())
1659
+ return undefined;
1660
+ const sent = sendForegroundToBackground(foregroundHandoffs.keys());
1661
+ if (sent > 0)
1662
+ ui.notify(`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`, "info");
1663
+ return { consume: true };
1664
+ });
1665
+ }
1666
+ /**
1667
+ * Pi's fullscreen scrollbar preference, read from the main session's live,
1668
+ * trust-aware settings and normalized as Pi's own `getFullscreenScrollbar()` does.
1669
+ */
1670
+ function getFullscreenScrollbar() {
1671
+ try {
1672
+ const mode = pi.getSettings().fullscreenScrollbar;
1673
+ return mode === "always" || mode === "hidden" ? mode : "auto";
1674
+ }
1675
+ catch {
1676
+ return "auto";
1677
+ }
1678
+ }
1679
+ /**
1680
+ * Pi's chat settings the conversation viewer draws with, read from the main
1681
+ * session's live settings and normalized as Pi's SettingsManager getters do,
1682
+ * so a subagent's transcript looks like the main one.
1683
+ */
1684
+ function getViewerRenderOptions() {
1685
+ try {
1686
+ const settings = pi.getSettings();
1687
+ return {
1688
+ hideThinkingBlock: settings.hideThinkingBlock ?? false,
1689
+ outputPad: settings.outputPad === 0 ? 0 : 1,
1690
+ codeBlockIndent: settings.markdown?.codeBlockIndent ?? " ",
1691
+ };
1692
+ }
1693
+ catch {
1694
+ return {};
1695
+ }
1696
+ }
1220
1697
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
1221
- // The last two arguments keep a conversation overlay opened here identical to
1222
- // one opened from `/agents`: same setting on the way in, same persist out.
1223
- const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, currentCtx), refreshTicker);
1698
+ // The render options keep a conversation overlay opened here identical to one
1699
+ // opened from `/agents`.
1700
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerRenderOptions, refreshTicker, backgroundActionFor, getFullscreenScrollbar);
1224
1701
  let fleetViewEnabled = true;
1225
1702
  function isFleetViewEnabled() { return fleetViewEnabled; }
1226
1703
  function setFleetViewEnabled(b) { fleetViewEnabled = b; fleet.setEnabled(b); }
@@ -1295,45 +1772,42 @@ export default function (pi) {
1295
1772
  let toolDescriptionMode = "full";
1296
1773
  function getToolDescriptionMode() { return toolDescriptionMode; }
1297
1774
  function setToolDescriptionMode(mode) { toolDescriptionMode = mode; }
1298
- // ---- Batch tracking for smart join mode ----
1299
- // Collects background agent IDs spawned in the current turn for smart grouping.
1300
- // Uses a debounced timer: each new agent resets the 100ms window so that all
1301
- // parallel tool calls (which may be dispatched across multiple microtasks by the
1302
- // framework) are captured in the same batch.
1303
- let currentBatchAgents = [];
1304
- let batchFinalizeTimer;
1305
- let batchCounter = 0;
1306
- /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
1307
- function finalizeBatch() {
1308
- batchFinalizeTimer = undefined;
1309
- const batchAgents = [...currentBatchAgents];
1310
- currentBatchAgents = [];
1311
- const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
1312
- if (smartAgents.length >= 2) {
1313
- const groupId = `batch-${++batchCounter}`;
1314
- const ids = smartAgents.map(a => a.id);
1315
- groupJoin.registerGroup(groupId, ids);
1316
- // Retroactively process agents that already completed during the debounce window.
1317
- // Their onComplete fired but was deferred (agent was in currentBatchAgents),
1318
- // so we feed them into the group now.
1319
- for (const id of ids) {
1320
- const record = manager.getRecord(id);
1321
- if (!record)
1322
- continue;
1323
- record.groupId = groupId;
1324
- if (record.completedAt != null && !record.resultConsumed) {
1325
- groupJoin.onAgentComplete(record);
1326
- }
1327
- }
1775
+ // ---- Turn batches for `group` join mode ----
1776
+ // Background spawns the parent made in its current turn. The turn's end is
1777
+ // the batch boundary: every `Agent` call of the batch has returned by then,
1778
+ // so a spawn whose startup failed has already thrown and was never recorded,
1779
+ // and a slow worktree copy cannot split the batch.
1780
+ let turnGroupSpawns = [];
1781
+ let turnGroupCounter = 0;
1782
+ /** Record a `group`-mode spawn made inside a parent turn. */
1783
+ function joinTurnGroup(id, joinMode) {
1784
+ // Outside a parent turn (an `@handle` mention) no turn end is coming, and a
1785
+ // group of one is delivered exactly like a single completion.
1786
+ if (joinMode === 'group' && parentTurnActive())
1787
+ turnGroupSpawns.push(id);
1788
+ }
1789
+ /** Register the ending turn's `group` spawns, two or more, as one group. */
1790
+ function registerTurnGroup() {
1791
+ const ids = turnGroupSpawns;
1792
+ turnGroupSpawns = [];
1793
+ // One spawn is no group: it reports like any single completion.
1794
+ if (ids.length < 2) {
1795
+ const record = ids.length === 1 ? manager.getRecord(ids[0]) : undefined;
1796
+ if (record?.completedAt != null && !record.resultConsumed)
1797
+ sendIndividualNudge(record);
1798
+ return;
1328
1799
  }
1329
- else {
1330
- // No group formed — send individual nudges for any agents that completed
1331
- // during the debounce window and had their notification deferred.
1332
- for (const { id } of batchAgents) {
1333
- const record = manager.getRecord(id);
1334
- if (record?.completedAt != null && !record.resultConsumed) {
1335
- sendIndividualNudge(record);
1336
- }
1800
+ const groupId = `turn-${++turnGroupCounter}`;
1801
+ groupJoin.registerGroup(groupId, ids);
1802
+ // Members that finished before their turn ended had their completion
1803
+ // deferred (see the manager's onComplete); feed them in now.
1804
+ for (const id of ids) {
1805
+ const record = manager.getRecord(id);
1806
+ if (!record)
1807
+ continue;
1808
+ record.groupId = groupId;
1809
+ if (record.completedAt != null && !record.resultConsumed) {
1810
+ groupJoin.onAgentComplete(record);
1337
1811
  }
1338
1812
  }
1339
1813
  }
@@ -1429,12 +1903,9 @@ export default function (pi) {
1429
1903
  });
1430
1904
  if (!record)
1431
1905
  return undefined;
1432
- if (joinMode != null && joinMode !== 'async') {
1433
- currentBatchAgents.push({ id, joinMode });
1434
- if (batchFinalizeTimer)
1435
- clearTimeout(batchFinalizeTimer);
1436
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1437
- }
1906
+ // Only a resume from an Agent call belongs to that call's turn.
1907
+ if (opts.toolCallId !== undefined)
1908
+ joinTurnGroup(id, joinMode);
1438
1909
  agentActivity.set(id, bgState);
1439
1910
  // This agent already finished once, so the widget holds a finished-age
1440
1911
  // for it that is past the linger limit — without clearing it, the
@@ -1470,11 +1941,20 @@ export default function (pi) {
1470
1941
  // result (background agents, workflows) reaches the session stats now.
1471
1942
  pi.on("agent_end", (_event, ctx) => {
1472
1943
  flushPendingUsage(ctx);
1944
+ outbox.onAgentEnd();
1945
+ });
1946
+ pi.on("agent_settled", () => outbox.onAgentEnd());
1947
+ // The parent's tool batch is done and its next model call not yet made: the
1948
+ // boundary where completions are delivered, as in Claude Code.
1949
+ pi.on("turn_end", (event) => {
1950
+ registerTurnGroup();
1951
+ outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
1473
1952
  });
1474
1953
  // Grab UI context from first tool execution + clear lingering widget on new turn
1475
1954
  pi.on("tool_execution_start", async (_event, ctx) => {
1476
1955
  widget.setUICtx(ctx.ui);
1477
1956
  fleet.setUICtx(ctx.ui);
1957
+ watchHandoffKey(ctx.ui);
1478
1958
  widget.onTurnStart();
1479
1959
  });
1480
1960
  /** Build the full type list text dynamically from available agents only. */
@@ -1519,6 +1999,7 @@ export default function (pi) {
1519
1999
  setBackgroundByDefault,
1520
2000
  setSchedulingEnabled,
1521
2001
  setScopeModels: setScopeModelsEnabled,
2002
+ setAllowModelOverride: setModelOverrideAllowed,
1522
2003
  setStrictAgentFiles: (b) => { strictAgentFiles = b; },
1523
2004
  setDisableDefaultAgents: setDisableDefaultAgents,
1524
2005
  setToolDescriptionMode: setToolDescriptionMode,
@@ -1534,7 +2015,7 @@ export default function (pi) {
1534
2015
  setReportUsage,
1535
2016
  setShowCost,
1536
2017
  setShowModel,
1537
- setViewerMarkdown,
2018
+ setBackgroundKey,
1538
2019
  };
1539
2020
  applyAndEmitLoaded(settingsAppliers, (event, payload) => pi.events.emit(event, payload), process.cwd(), projectTrusted);
1540
2021
  /**
@@ -1609,6 +2090,22 @@ export default function (pi) {
1609
2090
  const isolationGuideline = isWorktreeIsolationEnabled()
1610
2091
  ? `\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.`
1611
2092
  : "";
2093
+ // Same trade again, for `allowModelOverride`: with overrides off the `model`
2094
+ // and `thinking` parameters are not in the schema, so the prose that teaches
2095
+ // them goes too — a model told it may pick a model will keep trying.
2096
+ const modelOverrideAllowed = isModelOverrideAllowed();
2097
+ const modelGuideline = modelOverrideAllowed
2098
+ ? `- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").\n- Use thinking to control extended thinking level.\n`
2099
+ : "";
2100
+ const modelParamShape = {
2101
+ model: Type.Optional(Type.String({
2102
+ description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
2103
+ })),
2104
+ thinking: Type.Optional(Type.String({
2105
+ description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
2106
+ })),
2107
+ };
2108
+ const modelParams = modelOverrideAllowed ? modelParamShape : {};
1612
2109
  const isolationCompactGuideline = isWorktreeIsolationEnabled()
1613
2110
  ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1614
2111
  : "";
@@ -1652,9 +2149,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1652
2149
  - Use steer_subagent to send mid-run messages to a running background agent.
1653
2150
  - 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.
1654
2151
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1655
- - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
1656
- - Use thinking to control extended thinking level.
1657
- - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
2152
+ ${modelGuideline}- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1658
2153
 
1659
2154
  ## Writing the prompt
1660
2155
 
@@ -1679,6 +2174,7 @@ Terse command-style prompts produce shallow, generic work.
1679
2174
  agentDir: getAgentDir,
1680
2175
  isolationGuideline: () => isolationGuideline,
1681
2176
  scheduleGuideline: () => scheduleGuideline,
2177
+ modelGuideline: () => modelGuideline,
1682
2178
  };
1683
2179
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
1684
2180
  return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
@@ -1746,12 +2242,7 @@ Terse command-style prompts produce shallow, generic work.
1746
2242
  subagent_type: Type.String({
1747
2243
  description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
1748
2244
  }),
1749
- model: Type.Optional(Type.String({
1750
- description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
1751
- })),
1752
- thinking: Type.Optional(Type.String({
1753
- description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1754
- })),
2245
+ ...modelParams,
1755
2246
  max_turns: Type.Optional(Type.Number({
1756
2247
  description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
1757
2248
  minimum: 1,
@@ -1826,7 +2317,14 @@ Terse command-style prompts produce shallow, generic work.
1826
2317
  if (isPartial || details.status === "running") {
1827
2318
  const frame = SPINNER[details.spinnerFrame ?? 0];
1828
2319
  const s = stats(details);
1829
- return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
2320
+ const status = renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
2321
+ // Claude Code's hint, after the same 2s: a call that finishes quickly
2322
+ // never flashes it.
2323
+ const backgroundKey = getBackgroundKey();
2324
+ if (details.backgroundHint && backgroundKey && details.durationMs >= BACKGROUND_HINT_DELAY_MS) {
2325
+ status.addChild(new Text(theme.fg("dim", ` (${backgroundKey} to run in background)`), 0, 0));
2326
+ }
2327
+ return status;
1830
2328
  }
1831
2329
  // ---- Background agent launched ----
1832
2330
  if (details.status === "background") {
@@ -2066,7 +2564,9 @@ Terse command-style prompts produce shallow, generic work.
2066
2564
  // at fire time, and the original is what a user edits.
2067
2565
  subagent_type: requestedType,
2068
2566
  prompt: params.prompt,
2069
- model: params.model,
2567
+ // Only when overrides are on: a job keeps the call's model choice
2568
+ // for every later fire, which is exactly what the setting refuses.
2569
+ model: isModelOverrideAllowed() ? params.model : undefined,
2070
2570
  thinking: thinking,
2071
2571
  max_turns: effectiveMaxTurns,
2072
2572
  isolated: isolated,
@@ -2193,6 +2693,21 @@ Terse command-style prompts produce shallow, generic work.
2193
2693
  (record.result?.trim() || "No output.") +
2194
2694
  agentIdLine(record.id), buildDetails(detailBaseFor(record), record));
2195
2695
  }
2696
+ // What the model sees for a run it no longer waits on — spawned in the
2697
+ // background, or handed there mid-run (background key / viewer `b`). One
2698
+ // text for both, as in Claude Code: from here on they are the same kind of agent.
2699
+ const backgroundResult = (id, record) => {
2700
+ const isQueued = record?.status === "queued";
2701
+ return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2702
+ `Agent ID: ${id}\n` +
2703
+ `Type: ${displayName}\n` +
2704
+ `Description: ${params.description}\n` +
2705
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2706
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2707
+ `\nYou will be notified when this agent completes.\n` +
2708
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2709
+ `Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
2710
+ };
2196
2711
  // Background execution
2197
2712
  if (runInBackground) {
2198
2713
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
@@ -2200,12 +2715,15 @@ Terse command-style prompts produce shallow, generic work.
2200
2715
  // The callback lazily reads record.outputFile (set right after spawn)
2201
2716
  // rather than closing over a value that doesn't exist yet.
2202
2717
  let id;
2718
+ // Read now: a queued agent may start after a session switch, when this
2719
+ // ctx is stale and every read of it throws.
2720
+ const bgCwd = ctx.cwd;
2203
2721
  const origBgOnSession = bgCallbacks.onSessionCreated;
2204
2722
  bgCallbacks.onSessionCreated = (session) => {
2205
2723
  origBgOnSession(session);
2206
2724
  const rec = manager.getRecord(id);
2207
2725
  if (rec?.outputFile) {
2208
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
2726
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, bgCwd);
2209
2727
  }
2210
2728
  };
2211
2729
  // A throw here means the agent never started. Let it out: pi marks a
@@ -2239,18 +2757,7 @@ Terse command-style prompts produce shallow, generic work.
2239
2757
  // wiring above, so a strict-isolation failure still fails THIS tool
2240
2758
  // call instead of being reported as a subagent that ran (#179).
2241
2759
  await manager.awaitStartup(id);
2242
- if (joinMode == null || joinMode === 'async') {
2243
- // Foreground/no join mode or explicit async — not part of any batch
2244
- }
2245
- else {
2246
- // smart or group — add to current batch
2247
- currentBatchAgents.push({ id, joinMode });
2248
- // Debounce: reset timer on each new agent so parallel tool calls
2249
- // dispatched across multiple event loop ticks are captured together
2250
- if (batchFinalizeTimer)
2251
- clearTimeout(batchFinalizeTimer);
2252
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
2253
- }
2760
+ joinTurnGroup(id, joinMode);
2254
2761
  agentActivity.set(id, bgState);
2255
2762
  widget.ensureTimer();
2256
2763
  widget.update();
@@ -2263,26 +2770,26 @@ Terse command-style prompts produce shallow, generic work.
2263
2770
  description: params.description,
2264
2771
  isBackground: true,
2265
2772
  });
2266
- const isQueued = record?.status === "queued";
2267
- return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2268
- `Agent ID: ${id}\n` +
2269
- `Type: ${displayName}\n` +
2270
- `Description: ${params.description}\n` +
2271
- (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2272
- (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2273
- `\nYou will be notified when this agent completes.\n` +
2274
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2275
- `Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
2773
+ return backgroundResult(id, record);
2276
2774
  }
2277
2775
  // Foreground (synchronous) execution — stream progress via onUpdate
2278
2776
  let spinnerFrame = 0;
2279
2777
  const startedAt = Date.now();
2280
2778
  let fgId;
2779
+ // Set once the run is live and can be handed to the background — see
2780
+ // `foregroundHandoffs`. Unset while queued: there is no run to hand over.
2781
+ let handoffId;
2782
+ // Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
2783
+ // updates once `execute` has returned), so `streamUpdate` stops building
2784
+ // them; the tracker keeps `fgState` live for the widget and FleetView.
2785
+ let backgrounded = false;
2281
2786
  // Set only while the spawn is parked on a foreground concurrency slot
2282
2787
  // (maxConcurrentForeground); undefined the rest of the time, including
2283
2788
  // always when the limit is unset.
2284
2789
  let queuedAhead;
2285
2790
  const streamUpdate = () => {
2791
+ if (backgrounded)
2792
+ return;
2286
2793
  // Spend from the record, everything else from the live tracker. `fgId`
2287
2794
  // is set in onSessionCreated below, which fires before the first
2288
2795
  // assistant message — so nothing is spent while this reads zero.
@@ -2305,6 +2812,9 @@ Terse command-style prompts produce shallow, generic work.
2305
2812
  ? describeActivity(fgState.activeTools, fgState.responseText)
2306
2813
  : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
2307
2814
  spinnerFrame: spinnerFrame % SPINNER.length,
2815
+ // One hint per batch, as in Claude Code: on the earliest call still
2816
+ // pending (a Map iterates in insertion order). The key takes them all.
2817
+ backgroundHint: handoffId !== undefined && foregroundHandoffs.keys().next().value === handoffId,
2308
2818
  };
2309
2819
  onUpdate?.({
2310
2820
  content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
@@ -2353,44 +2863,76 @@ Terse command-style prompts produce shallow, generic work.
2353
2863
  streamUpdate();
2354
2864
  });
2355
2865
  streamUpdate();
2356
- let record;
2866
+ // The background key or the viewer's `b` wakes this with the run already
2867
+ // handed over (`sendForegroundToBackground`), raced against the run
2868
+ // below. The manager only accepts a handoff of a run that has not
2869
+ // settled, and the wake-up resolves synchronously with it, so whichever
2870
+ // promise wins is the truth: the result is never also delivered as a
2871
+ // notification.
2872
+ let wakeBackgrounded;
2873
+ const handedOff = new Promise(resolve => { wakeBackgrounded = () => resolve(undefined); });
2874
+ let fgResult;
2357
2875
  try {
2358
- const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2359
- description: params.description,
2360
- name: params.name,
2361
- model,
2362
- maxTurns: effectiveMaxTurns,
2363
- isolated,
2364
- inheritContext,
2365
- thinkingLevel: thinking,
2366
- isolation,
2367
- invocation: agentInvocation,
2368
- signal,
2369
- rootSessionId: ctx.sessionManager.getSessionId(),
2370
- // Deliberately does NOT set fgId: that drives agentActivity, the
2371
- // widget and the `finally` cleanup below, none of which should see an
2372
- // agent that has no session and may never get one.
2373
- onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2374
- ...fgCallbacks,
2375
- }, (fgAgentId) => {
2376
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2377
- // Set up the output file so streamToOutputFile can pick it up.
2378
- const fgRec = manager.getRecord(fgAgentId);
2379
- attachTranscript(fgRec, fgAgentId);
2380
- });
2381
- record = fgResult.record;
2876
+ fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2877
+ description: params.description,
2878
+ name: params.name,
2879
+ model,
2880
+ maxTurns: effectiveMaxTurns,
2881
+ isolated,
2882
+ inheritContext,
2883
+ thinkingLevel: thinking,
2884
+ isolation,
2885
+ invocation: agentInvocation,
2886
+ signal,
2887
+ rootSessionId: ctx.sessionManager.getSessionId(),
2888
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2889
+ // widget and the `finally` cleanup below, none of which should see an
2890
+ // agent that has no session and may never get one.
2891
+ onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2892
+ ...fgCallbacks,
2893
+ }, (fgAgentId) => {
2894
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2895
+ // Set up the output file so streamToOutputFile can pick it up.
2896
+ const fgRec = manager.getRecord(fgAgentId);
2897
+ attachTranscript(fgRec, fgAgentId);
2898
+ // Fires once the run promise exists, i.e. the agent is running. From
2899
+ // here until this call returns, the run can be handed off.
2900
+ handoffId = fgAgentId;
2901
+ foregroundHandoffs.set(fgAgentId, () => {
2902
+ // What a background spawn sets up front: the notification names
2903
+ // this tool call, and it reports alone — its turn's batch is long
2904
+ // closed.
2905
+ if (fgRec) {
2906
+ fgRec.toolCallId = toolCallId;
2907
+ fgRec.joinMode = "async";
2908
+ }
2909
+ backgrounded = true;
2910
+ wakeBackgrounded();
2911
+ });
2912
+ }), handedOff]);
2382
2913
  }
2383
2914
  finally {
2384
2915
  // Runs on both paths, so a startup throw — which now propagates, see
2385
2916
  // the background spawn above (#179) — no longer leaves the spinner
2386
2917
  // ticking or a finished agent on the widget.
2387
2918
  refreshTicker.delete(spinnerOwner);
2388
- if (fgId) {
2919
+ if (handoffId !== undefined)
2920
+ foregroundHandoffs.delete(handoffId);
2921
+ // A handed-off run is still working: its row stays, and the
2922
+ // background settle in `onComplete` tears it down when it really ends.
2923
+ if (fgId && !backgrounded) {
2389
2924
  agentActivity.delete(fgId);
2390
2925
  widget.markFinished(fgId);
2391
2926
  fleet.onAgentFinished(fgId);
2392
2927
  }
2393
2928
  }
2929
+ if (fgResult === undefined) {
2930
+ // Shown as a background agent from here on (the widget's default mode
2931
+ // hides foreground rows).
2932
+ widget.update();
2933
+ return backgroundResult(handoffId, manager.getRecord(handoffId));
2934
+ }
2935
+ const record = fgResult.record;
2394
2936
  // Get final token count — from the record, like the cost below it, so the
2395
2937
  // two describe the same work when the agent delegated to nested children.
2396
2938
  const tokenText = formatLifetimeTokens(record);
@@ -2516,33 +3058,30 @@ Terse command-style prompts produce shallow, generic work.
2516
3058
  }
2517
3059
  /**
2518
3060
  * Hand a finished run back to the model through the SAME channel a background
2519
- * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2520
- * triggers a turn, rendered by the existing `subagent-notification` renderer.
3061
+ * agent uses — the completion outbox, which delivers at the parent's next
3062
+ * boundary — rendered by the existing `subagent-notification` renderer.
2521
3063
  */
2522
3064
  function notifyWorkflowFinished(task) {
2523
3065
  widget.update();
2524
3066
  fleet.update();
2525
3067
  flushPendingUsageIfIdle();
2526
3068
  const result = workflowResultText(task);
2527
- scheduleNudge(task.id, () => {
2528
- pi.sendMessage({
2529
- customType: "subagent-notification",
2530
- content: formatWorkflowNotification(task),
2531
- display: true,
2532
- details: {
2533
- id: task.id,
2534
- description: `Workflow ${task.workflowName ?? task.id}`,
2535
- status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2536
- toolUses: task.totalToolCalls,
2537
- // A workflow has agents, not turns; rendering "↻0" would be noise.
2538
- turnCount: 0,
2539
- totalTokens: task.totalTokens,
2540
- durationMs: elapsedMs(task, Date.now()),
2541
- error: task.error,
2542
- resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2543
- },
2544
- }, { deliverAs: "followUp", triggerTurn: true });
2545
- });
3069
+ const message = {
3070
+ content: formatWorkflowNotification(task),
3071
+ details: {
3072
+ id: task.id,
3073
+ description: `Workflow ${task.workflowName ?? task.id}`,
3074
+ status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
3075
+ toolUses: task.totalToolCalls,
3076
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
3077
+ turnCount: 0,
3078
+ totalTokens: task.totalTokens,
3079
+ durationMs: elapsedMs(task, Date.now()),
3080
+ error: task.error,
3081
+ resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
3082
+ },
3083
+ };
3084
+ outbox.add({ ids: [task.id], build: () => message });
2546
3085
  }
2547
3086
  // Defined unconditionally, registered only when the feature is on — the same
2548
3087
  // shape the Agent tool uses. Keeping the definition out of the `if` means the
@@ -2550,7 +3089,7 @@ Terse command-style prompts produce shallow, generic work.
2550
3089
  const workflowTool = defineTool({
2551
3090
  name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2552
3091
  label: "SubagentWorkflow",
2553
- description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
3092
+ description: renderToolDescriptionTemplate(workflowToolDescription(isModelOverrideAllowed())),
2554
3093
  promptSnippet: "Run a deterministic script that orchestrates many subagents",
2555
3094
  promptGuidelines: [
2556
3095
  "Use SubagentWorkflow when the number of agents depends on something discovered at runtime, when work flows through stages, or when findings should be independently verified. Use Agent for one delegated task or a handful you can name up front.",
@@ -2611,7 +3150,21 @@ Terse command-style prompts produce shallow, generic work.
2611
3150
  }, theme);
2612
3151
  },
2613
3152
  execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2614
- const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
3153
+ // A run from before a restart, `/resume` or `/reload` is found by its
3154
+ // files: its journal and saved script sit in this session's task dir.
3155
+ const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks, (id) => {
3156
+ try {
3157
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
3158
+ const journal = join(dir, `${id}.workflow.jsonl`);
3159
+ if (!existsSync(journal))
3160
+ return undefined;
3161
+ const script = join(dir, `${id}.workflow.js`);
3162
+ return { journalPath: journal, ...(existsSync(script) ? { scriptPath: script } : {}) };
3163
+ }
3164
+ catch {
3165
+ return undefined;
3166
+ }
3167
+ });
2615
3168
  if (resumeFrom !== undefined && !resumeFrom.ok)
2616
3169
  return textResult(resumeFrom.message);
2617
3170
  // A resume with no source of its own re-runs what that run ran. The
@@ -2921,7 +3474,7 @@ Terse command-style prompts produce shallow, generic work.
2921
3474
  // Mark result as consumed — suppresses the completion notification
2922
3475
  if (!active) {
2923
3476
  record.resultConsumed = true;
2924
- cancelNudge(record.id);
3477
+ outbox.cancel(record.id);
2925
3478
  }
2926
3479
  // Verbose: include full conversation
2927
3480
  if (params.verbose && record.session) {
@@ -3167,18 +3720,19 @@ Terse command-style prompts produce shallow, generic work.
3167
3720
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
3168
3721
  return;
3169
3722
  }
3170
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
3171
3723
  const session = record.session;
3172
3724
  const activity = agentActivity.get(record.id);
3725
+ let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
3173
3726
  await ctx.ui.custom((tui, theme, keybindings, done) => {
3727
+ overlayOptions = getConversationOverlayOptions(tui);
3174
3728
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
3175
3729
  if (manager.abort(record.id)) {
3176
3730
  ctx.ui.notify(`Stopped "${record.description}".`, "info");
3177
3731
  }
3178
- }, keybindings, (message) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx));
3732
+ }, keybindings, (message) => manager.steer(record.id, message), showCost, backgroundActionFor(record, message => ctx.ui.notify(message, "info")), getFullscreenScrollbar(), getViewerRenderOptions());
3179
3733
  }, {
3180
3734
  overlay: true,
3181
- overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
3735
+ overlayOptions: () => overlayOptions,
3182
3736
  });
3183
3737
  }
3184
3738
  async function showAgentDetail(ctx, name) {
@@ -3540,6 +4094,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3540
4094
  backgroundByDefault: getBackgroundByDefault(),
3541
4095
  schedulingEnabled: isSchedulingEnabled(),
3542
4096
  scopeModels: isScopeModelsEnabled(),
4097
+ allowModelOverride: isModelOverrideAllowed(),
3543
4098
  strictAgentFiles,
3544
4099
  disableDefaultAgents: isDefaultsDisabled(),
3545
4100
  toolDescriptionMode: getToolDescriptionMode(),
@@ -3566,7 +4121,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
3566
4121
  reportUsage: isReportUsageEnabled(),
3567
4122
  showCost: isShowCostEnabled(),
3568
4123
  showModel: isShowModelEnabled(),
3569
- viewerMarkdown: getViewerMarkdown(),
4124
+ // As written: undefined (the default) stays out of the file.
4125
+ backgroundKey: backgroundKeySetting,
3570
4126
  };
3571
4127
  }
3572
4128
  const _settingsSnapshotIsComplete = true;
@@ -3635,9 +4191,10 @@ Write the file using the write tool. Only write the file, nothing else.`;
3635
4191
  {
3636
4192
  id: "joinMode",
3637
4193
  label: "Join mode",
3638
- description: "Default join mode for background agents",
3639
- currentValue: getDefaultJoinMode(),
3640
- values: ["smart", "async", "group"],
4194
+ description: "smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
4195
+ // `async` is an older name for `smart`; settings files may still hold it.
4196
+ currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
4197
+ values: ["smart", "group"],
3641
4198
  },
3642
4199
  {
3643
4200
  id: "backgroundByDefault",
@@ -3668,6 +4225,15 @@ Write the file using the write tool. Only write the file, nothing else.`;
3668
4225
  currentValue: isScopeModelsEnabled() ? "on" : "off",
3669
4226
  values: ["on", "off"],
3670
4227
  },
4228
+ {
4229
+ id: "allowModelOverride",
4230
+ label: "Model override",
4231
+ description: "Let Agent calls, workflows and scheduled jobs pick a subagent's model and thinking level; "
4232
+ + "off runs every subagent on your current model and thinking level unless its agent file sets one "
4233
+ + "(the tool parameters change on next pi session)",
4234
+ currentValue: isModelOverrideAllowed() ? "on" : "off",
4235
+ values: ["on", "off"],
4236
+ },
3671
4237
  {
3672
4238
  id: "strictAgentFiles",
3673
4239
  label: "Strict agent files",
@@ -3724,13 +4290,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3724
4290
  currentValue: isShowModelEnabled() ? "on" : "off",
3725
4291
  values: ["on", "off"],
3726
4292
  },
3727
- {
3728
- id: "viewerMarkdown",
3729
- label: "Viewer markdown",
3730
- description: "How much of the conversation viewer renders as Markdown. assistant = assistant text only (default); all = tool results too, for tools that emit Markdown — accepting that a Markdown pass over a diff or a log eats `#` comments, swallows a `---` line and re-fences indented output; off = everything verbatim. `m` in the viewer cycles the same setting (footer: raw / md / md+).",
3731
- currentValue: getViewerMarkdown(),
3732
- values: ["off", "assistant", "all"],
3733
- },
3734
4293
  {
3735
4294
  id: "fleetView",
3736
4295
  label: "Fleet view",
@@ -3861,6 +4420,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3861
4420
  setScopeModelsEnabled(enabled);
3862
4421
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
3863
4422
  }
4423
+ else if (id === "allowModelOverride") {
4424
+ const enabled = value === "on";
4425
+ setModelOverrideAllowed(enabled);
4426
+ notifyApplied(ctx, enabled
4427
+ ? "Subagent calls may pick a model and thinking level. Tool spec change takes effect on next pi session."
4428
+ : "Subagents run on your current model and thinking level. Tool spec change takes effect on next pi session.");
4429
+ }
3864
4430
  else if (id === "strictAgentFiles") {
3865
4431
  const enabled = value === "on";
3866
4432
  strictAgentFiles = enabled;
@@ -3910,10 +4476,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3910
4476
  setShowModel(enabled);
3911
4477
  notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
3912
4478
  }
3913
- else if (id === "viewerMarkdown") {
3914
- setViewerMarkdown(value);
3915
- notifyApplied(ctx, `Viewer markdown set to ${value}`);
3916
- }
3917
4479
  else if (id === "fleetView") {
3918
4480
  const enabled = value === "on";
3919
4481
  setFleetViewEnabled(enabled);
@@ -4015,23 +4577,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
4015
4577
  // the right toast. Successful saves show info; persistence failures downgrade
4016
4578
  // to warning so users aren't silently reverted on restart. Event fires regardless
4017
4579
  // of outcome so listeners see the in-memory change.
4018
- /**
4019
- * Persist + broadcast the settings, silent on success — for a change whose
4020
- * feedback is the UI it just changed: the viewer's `m` key, where a
4021
- * notification per press would talk over the overlay it is describing.
4022
- *
4023
- * A *failed* write still speaks. Every other settings path warns when the
4024
- * value is session-only, and swallowing it here would leave a preference
4025
- * looking persisted when the next session will not have it.
4026
- */
4027
- function persistSettings(ctx, changeMsg) {
4028
- const { message, level } = saveAndEmitChanged(snapshotSettings(), changeMsg, (event, payload) => pi.events.emit(event, payload));
4029
- // `ctx` is absent only on the fleet path between sessions, where
4030
- // `currentCtx` has been cleared and there is no UI to carry the warning to.
4031
- // The write still happens.
4032
- if (level === "warning")
4033
- ctx?.ui.notify(message, level);
4034
- }
4035
4580
  function notifyApplied(ctx, successMsg) {
4036
4581
  const { message, level } = saveAndEmitChanged(snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload));
4037
4582
  ctx.ui.notify(message, level);