@kushalkhemka/opensec-pi-subagents 0.20.2 → 0.20.3

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 (66) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +125 -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 +827 -252
  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 +26 -0
  22. package/dist/settings.js +10 -0
  23. package/dist/types.d.ts +24 -2
  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 +104 -3
  27. package/dist/ui/conversation-viewer.js +425 -52
  28. package/dist/ui/fleet-list.d.ts +30 -9
  29. package/dist/ui/fleet-list.js +37 -12
  30. package/dist/workflow/collisions.d.ts +3 -1
  31. package/dist/workflow/collisions.js +4 -1
  32. package/dist/workflow/host.js +30 -7
  33. package/dist/workflow/journal.d.ts +16 -8
  34. package/dist/workflow/journal.js +12 -9
  35. package/dist/workflow/runtime.d.ts +7 -1
  36. package/dist/workflow/runtime.js +38 -8
  37. package/dist/workflow/task.d.ts +11 -1
  38. package/dist/workflow/task.js +13 -1
  39. package/dist/workflow/tool-description.d.ts +9 -1
  40. package/dist/workflow/tool-description.js +21 -1
  41. package/docs/rpc.md +10 -10
  42. package/docs/workflows.md +4 -4
  43. package/examples/agent-tool-description.md +1 -3
  44. package/package.json +16 -11
  45. package/src/agent-manager.ts +288 -55
  46. package/src/agent-runner.ts +100 -21
  47. package/src/completion-outbox.ts +121 -0
  48. package/src/cross-extension-rpc.ts +8 -0
  49. package/src/env.ts +3 -2
  50. package/src/exec-tree.ts +11 -2
  51. package/src/index.ts +810 -237
  52. package/src/invocation-config.ts +44 -4
  53. package/src/nested-tools.ts +10 -4
  54. package/src/schedule.ts +10 -4
  55. package/src/session-handoff.ts +188 -0
  56. package/src/settings.ts +34 -0
  57. package/src/types.ts +24 -2
  58. package/src/ui/agent-widget.ts +8 -0
  59. package/src/ui/conversation-viewer.ts +419 -35
  60. package/src/ui/fleet-list.ts +34 -11
  61. package/src/workflow/collisions.ts +4 -1
  62. package/src/workflow/host.ts +30 -7
  63. package/src/workflow/journal.ts +18 -9
  64. package/src/workflow/runtime.ts +50 -8
  65. package/src/workflow/task.ts +14 -0
  66. 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
  *
@@ -432,6 +549,14 @@ export default function (pi) {
432
549
  let viewerMarkdown = "assistant";
433
550
  function getViewerMarkdown() { return viewerMarkdown; }
434
551
  function setViewerMarkdown(mode) { viewerMarkdown = mode; }
552
+ /** The `backgroundKey` setting as written; undefined means the default. Persisted as written. */
553
+ let backgroundKeySetting;
554
+ function setBackgroundKey(key) { backgroundKeySetting = key; }
555
+ /** The key that hands foreground `Agent` calls to the background, or undefined when it is off. */
556
+ function getBackgroundKey() {
557
+ const key = backgroundKeySetting ?? DEFAULT_BACKGROUND_KEY;
558
+ return key === "off" ? undefined : key;
559
+ }
435
560
  /**
436
561
  * The viewer's `m` key, from either entry point: set the mode and persist it,
437
562
  * so the key and `/agents → Settings` stay one setting rather than one per
@@ -483,82 +608,86 @@ export default function (pi) {
483
608
  }
484
609
  catch { /* stale ctx */ }
485
610
  }
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}` : '';
611
+ // ---- Completion delivery ----
612
+ // Every completion notification goes through the outbox, which sends what is
613
+ // pending as one message at the parent's next boundary (see
614
+ // completion-outbox.ts). Each is built when the agent finishes, and reading
615
+ // the result (get_subagent_result, rpc consume) cancels it before it is sent.
616
+ const outbox = new CompletionOutbox((message) => {
617
+ // A torn-down session cannot take a message (`pi` is stale).
618
+ if (disposed)
619
+ return;
517
620
  pi.sendMessage({
518
621
  customType: "subagent-notification",
519
- content: notification + footer,
622
+ content: message.content,
520
623
  display: true,
521
- details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
522
- }, { deliverAs: "followUp", triggerTurn: true });
624
+ details: message.details,
625
+ }, { deliverAs: "steer", triggerTurn: true });
626
+ }, () => parentTurnActive());
627
+ /** Whether the parent has a run in flight, so a `turn_end` is coming. A stale ctx has none. */
628
+ function parentTurnActive() {
629
+ try {
630
+ return currentCtx?.signal !== undefined;
631
+ }
632
+ catch {
633
+ return false;
634
+ }
523
635
  }
636
+ // A queued result wait must observe completion before its held notification
637
+ // can fire, so successful waits can still suppress that redundant nudge.
638
+ const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
524
639
  function sendIndividualNudge(record) {
525
640
  agentActivity.delete(record.id);
526
641
  widget.markFinished(record.id);
527
642
  fleet.onAgentFinished(record.id);
528
- scheduleNudge(record.id, () => emitIndividualNudge(record));
643
+ // Built now, so a resume before the send cannot rewrite it. A record that
644
+ // cannot be formatted is skipped, as a failed send always was.
645
+ try {
646
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
647
+ const message = {
648
+ content: formatTaskNotification(record, 500, showCost) + footer,
649
+ details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
650
+ };
651
+ outbox.add({ ids: [record.id], build: () => message });
652
+ }
653
+ catch { /* ignore stale completion side-effect errors */ }
529
654
  widget.update();
530
655
  }
531
- // ---- Group join manager ----
656
+ // ---- Group join manager (`group` join mode only) ----
532
657
  const groupJoin = new GroupJoinManager((records, partial) => {
533
658
  for (const r of records) {
534
659
  agentActivity.delete(r.id);
535
660
  widget.markFinished(r.id);
536
661
  fleet.onAgentFinished(r.id);
537
662
  }
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;
663
+ const members = records.filter(r => !r.resultConsumed).flatMap(r => {
664
+ try {
665
+ return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
545
666
  }
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)));
667
+ catch {
668
+ return []; /* as in sendIndividualNudge */
554
669
  }
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
670
  });
671
+ if (members.length > 0) {
672
+ outbox.add({
673
+ ids: members.map(m => m.id),
674
+ build: (ids) => {
675
+ const unread = members.filter(m => ids.includes(m.id));
676
+ const notifications = unread.map(m => m.text).join('\n\n');
677
+ const label = partial
678
+ ? `${unread.length} agent(s) finished (partial — others still running)`
679
+ : `${unread.length} agent(s) finished`;
680
+ const [first, ...rest] = unread;
681
+ const details = first.details;
682
+ if (rest.length > 0)
683
+ details.others = rest.map(m => m.details);
684
+ return {
685
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
686
+ details,
687
+ };
688
+ },
689
+ });
690
+ }
562
691
  widget.update();
563
692
  }, 30_000);
564
693
  /** Helper: build event data for lifecycle events from an AgentRecord. */
@@ -598,38 +727,86 @@ export default function (pi) {
598
727
  usage,
599
728
  };
600
729
  }
601
- // Background completion: route through group join or send individual nudge
602
730
  /**
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.
731
+ * The session this activation belongs to: its session file, or undefined for
732
+ * one kept in memory. Agents spawned here carry it as `ownerSession`; a
733
+ * background agent that outlives a session switch (session-handoff.ts)
734
+ * reports to its owner, not to whichever session is open.
608
735
  */
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
- });
736
+ let currentSession;
737
+ const ownedHere = (record) => record.ownerSession === currentSession;
738
+ /**
739
+ * Write a custom entry into the session `owner`: through `pi` while that is
740
+ * the session this instance has open (Pi keeps it in memory), otherwise by
741
+ * appending to the owner's file, which Pi reads when it is opened again.
742
+ * Guarded: it also runs while the session is shutting down, and after.
743
+ */
744
+ function writeEntry(owner, customType, data) {
745
+ if (owner === currentSession) {
746
+ try {
747
+ pi.appendEntry(customType, data);
748
+ return true;
749
+ }
750
+ catch { /* torn down: the session is no longer open, so its file is safe to append to */ }
617
751
  }
618
- catch { /* a session already torn down cannot record anything */ }
752
+ return appendToSessionFile(owner, customType, data);
753
+ }
754
+ // Background completion: route through group join or send individual nudge
755
+ /**
756
+ * Write a top-level agent's final record into the session it belongs to.
757
+ * `resume` carries what reopening it needs (session file, handle, model,
758
+ * directory, worktree, isolation, turn limit); `session_start` reads it back,
759
+ * so the agent stays resumable after a restart, `/resume`, `/fork` or
760
+ * `/reload`. `pending` marks a result its session has not been told about
761
+ * yet (it finished while another session was open); that session delivers it
762
+ * when it starts.
763
+ */
764
+ function appendRecordEntry(record, pending = false) {
765
+ writeEntry(record.ownerSession, RECORD_ENTRY_TYPE, recordEntryData(record, pending));
766
+ }
767
+ /**
768
+ * Write an owned child's resume state (a nested agent's, a workflow's) into
769
+ * the session it belongs to, so `session_start` can rebuild it after a
770
+ * restart, `/resume`, `/fork` or `/reload`: its owner can then reopen it from
771
+ * its session file — the parent agent's nested tools, or a workflow's
772
+ * `agent({ resume })`. Only the resume state, never the result: the owner
773
+ * already has that, and the entry stays small. Nothing for an agent without
774
+ * a session file.
775
+ */
776
+ function appendResumeEntry(record) {
777
+ const data = resumeEntryData(record);
778
+ if (data)
779
+ writeEntry(record.ownerSession, RESUME_ENTRY_TYPE, data);
780
+ }
781
+ /**
782
+ * A top-level agent of another session finished while this one is open: its
783
+ * result belongs to that session. Record it there, marked for delivery; no
784
+ * notification, event or row here.
785
+ */
786
+ function completeForeignAgent(record) {
787
+ agentActivity.delete(record.id);
788
+ appendRecordEntry(record, !record.resultConsumed);
619
789
  }
620
- const manager = new AgentManager((record) => {
790
+ function onAgentComplete(record) {
621
791
  // After shutdown `pi` is stale, and the shutdown handler records the
622
792
  // agents it interrupted itself.
623
793
  if (disposed)
624
794
  return;
625
- // Spend that landed after the parent's last subagent tool result.
626
- flushPendingUsageIfIdle();
627
795
  // Owned children — nested, or a workflow's — report only through their
628
796
  // owner: the parent's scoped tools, or the workflow's card, notification
629
797
  // and dialog. Keep them out of top-level lifecycle, transcript,
630
- // notification, and UI channels.
631
- if (!isTopLevelAgent(record))
798
+ // notification, and UI channels. Their resume state is still recorded.
799
+ if (!isTopLevelAgent(record)) {
800
+ flushPendingUsageIfIdle();
801
+ appendResumeEntry(record);
632
802
  return;
803
+ }
804
+ if (!ownedHere(record)) {
805
+ completeForeignAgent(record);
806
+ return;
807
+ }
808
+ // Spend that landed after the parent's last subagent tool result.
809
+ flushPendingUsageIfIdle();
633
810
  // Emit lifecycle event based on terminal status
634
811
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
635
812
  const eventData = buildEventData(record);
@@ -650,9 +827,9 @@ export default function (pi) {
650
827
  widget.update();
651
828
  return;
652
829
  }
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)) {
830
+ // A `group` spawn whose turn has not ended yet: its group is registered at
831
+ // that turn's end, which picks this completion up retroactively.
832
+ if (turnGroupSpawns.includes(record.id)) {
656
833
  widget.update();
657
834
  return;
658
835
  }
@@ -663,8 +840,9 @@ export default function (pi) {
663
840
  // 'held' → do nothing, group will fire later
664
841
  // 'delivered' → group callback already fired
665
842
  widget.update();
666
- }, undefined, (record) => {
667
- if (disposed || !isTopLevelAgent(record))
843
+ }
844
+ function onAgentStart(record) {
845
+ if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
668
846
  return;
669
847
  // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
670
848
  // and scheduler spawns enter through the manager directly.
@@ -680,8 +858,9 @@ export default function (pi) {
680
858
  type: record.type,
681
859
  description: record.description,
682
860
  });
683
- }, (record, info) => {
684
- if (disposed || !isTopLevelAgent(record))
861
+ }
862
+ function onAgentCompact(record, info) {
863
+ if (disposed || !isTopLevelAgent(record) || !ownedHere(record))
685
864
  return;
686
865
  // Emit compacted event when agent's session compacts (preserves count on record).
687
866
  pi.events.emit("subagents:compacted", {
@@ -692,14 +871,28 @@ export default function (pi) {
692
871
  tokensBefore: info.tokensBefore,
693
872
  compactionCount: record.compactionCount,
694
873
  });
695
- }, (_record, usage) => {
874
+ }
875
+ function onAgentUsage(record, usage) {
696
876
  // Every assistant message from every agent — nested included, exactly once.
697
877
  // Parked here until a tool result can carry it back to the parent session;
698
878
  // 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)
879
+ // pool grows in a session that will never drain it. Another session's
880
+ // agents bill that session, not this one.
881
+ if (reportUsage && ownedHere(record))
701
882
  pendingUsage.add(usage);
702
- });
883
+ }
884
+ const managerObservers = {
885
+ onComplete: onAgentComplete,
886
+ onStart: onAgentStart,
887
+ onCompact: onAgentCompact,
888
+ onUsage: onAgentUsage,
889
+ };
890
+ /**
891
+ * Replaced at `session_start` by a manager the previous session parked, so
892
+ * its background agents keep running (see session-handoff.ts). Everything
893
+ * reads it through this binding, never a copy.
894
+ */
895
+ let manager = new AgentManager(onAgentComplete, undefined, onAgentStart, onAgentCompact, onAgentUsage);
703
896
  // Expose manager via Symbol.for() global registry for cross-package access.
704
897
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
705
898
  // Documented for callers in docs/rpc.md ("The manager registry").
@@ -770,6 +963,14 @@ export default function (pi) {
770
963
  delete safeOptions.reclaim;
771
964
  // Internal to a reopen, like `reclaim`: it seeds an agent's lifetime figures.
772
965
  delete safeOptions.carry;
966
+ // A caller's model and thinking level are per-call overrides, which
967
+ // `allowModelOverride` (off by default) refuses on every spawn path: the
968
+ // agent then runs on the parent's current model and level, or its agent
969
+ // file's.
970
+ if (!isModelOverrideAllowed()) {
971
+ delete safeOptions.model;
972
+ delete safeOptions.thinkingLevel;
973
+ }
773
974
  // Every spawn through here is DETACHED — the caller gets an id back and
774
975
  // awaits nothing. A forged `blocking` would charge it to the foreground
775
976
  // pool and could defer it behind a queue whose gate nobody is holding.
@@ -785,8 +986,9 @@ export default function (pi) {
785
986
  */
786
987
  const resolveAgentRef = (ref) => {
787
988
  const byId = manager.getRecord(ref);
989
+ // Another session's agent is not this session's to read or steer.
788
990
  if (byId)
789
- return byId;
991
+ return manager.isVisible(byId) ? byId : undefined;
790
992
  const resolved = manager.resolveMention(ref);
791
993
  return resolved?.kind === "live" ? resolved.record : undefined;
792
994
  };
@@ -796,7 +998,7 @@ export default function (pi) {
796
998
  spawn: spawnTopLevel,
797
999
  getRecord: (id) => {
798
1000
  const record = manager.getRecord(id);
799
- return record !== undefined && isTopLevelAgent(record) ? record : undefined;
1001
+ return record !== undefined && isTopLevelAgent(record) && manager.isVisible(record) ? record : undefined;
800
1002
  },
801
1003
  };
802
1004
  const ownsManagerRegistry = globalThis[MANAGER_KEY] === undefined;
@@ -842,17 +1044,26 @@ export default function (pi) {
842
1044
  pi.on("session_start", async (_event, ctx) => {
843
1045
  disposed = false;
844
1046
  currentCtx = ctx;
1047
+ currentSession = sessionFileOf(ctx);
1048
+ // First: everything below works on `manager`, which this may replace.
1049
+ adoptParkedAgents();
1050
+ manager.setVisibleOwner(currentSession);
845
1051
  syncProjectTrust(ctx);
846
1052
  void tidyAgentWorktrees(ctx);
847
1053
  if (ctx.hasUI) {
848
1054
  widget.setUICtx(ctx.ui);
849
1055
  fleet.setUICtx(ctx.ui);
1056
+ watchHandoffKey(ctx.ui);
850
1057
  }
851
1058
  manager.clearCompleted(true);
852
1059
  // Agents of this session from before a restart, `/resume`, `/fork` or
853
1060
  // `/reload`: resumable again, by the model and by `@handle`. The branch,
854
1061
  // not every entry: a fork keeps only the agents on its own path.
855
- manager.restoreTombstones(resumeStatesFromEntries(ctx.sessionManager?.getBranch?.() ?? []));
1062
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
1063
+ manager.restoreTombstones(resumeStatesFromEntries(branch));
1064
+ // Results of this session's agents that finished while another session
1065
+ // was open, recorded here for delivery now.
1066
+ deliverPendingResults(branch);
856
1067
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
857
1068
  // fires once per activation, but a double-bind must not leak listeners.
858
1069
  if (!rpcHandle) {
@@ -863,7 +1074,10 @@ export default function (pi) {
863
1074
  manager: {
864
1075
  spawn: spawnTopLevel,
865
1076
  awaitStartup: (id) => manager.awaitStartup(id),
866
- getRecord: (id) => manager.getRecord(id),
1077
+ getRecord: (id) => {
1078
+ const record = manager.getRecord(id);
1079
+ return record && manager.isVisible(record) ? record : undefined;
1080
+ },
867
1081
  // Unguarded on purpose: the stop handler now runs the top-level check
868
1082
  // itself off `getRecord`, and reports the refusal instead of the
869
1083
  // "Agent not found" a false from here used to be read as.
@@ -880,7 +1094,7 @@ export default function (pi) {
880
1094
  if (manager.isRunActive(record.id))
881
1095
  return false;
882
1096
  record.resultConsumed = true;
883
- cancelNudge(record.id);
1097
+ outbox.cancel(record.id);
884
1098
  return true;
885
1099
  },
886
1100
  },
@@ -1143,9 +1357,130 @@ export default function (pi) {
1143
1357
  // it returns. Pi confirms replacement by emitting session_shutdown; keep
1144
1358
  // the current schedules and agent records intact until then.
1145
1359
  });
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 () => {
1360
+ /**
1361
+ * Take over the manager the previous session parked at its switch, if it was
1362
+ * parked for this session by this same version: its background agents keep
1363
+ * running and show here when they are this session's. Another version cannot
1364
+ * share the manager's code, so it stops its agents itself, as a quit would.
1365
+ * See session-handoff.ts.
1366
+ */
1367
+ function adoptParkedAgents() {
1368
+ const parked = takeParkedSubagents(currentSession);
1369
+ if (!parked)
1370
+ return;
1371
+ if (!isCompatible(parked)) {
1372
+ void parked.stop(writeEntry).catch(() => { });
1373
+ return;
1374
+ }
1375
+ const adopted = parked.manager;
1376
+ const fresh = manager;
1377
+ // New work runs with this instance's modules (agent registry, settings).
1378
+ adopted.useServices(managerServices());
1379
+ adopted.setObservers(managerObservers);
1380
+ adopted.setMaxConcurrent(fresh.getMaxConcurrent());
1381
+ adopted.setMaxConcurrentForeground(fresh.getMaxConcurrentForeground());
1382
+ manager = adopted;
1383
+ widget.setManager(adopted);
1384
+ fleet.setManager(adopted);
1385
+ for (const [id, state] of parked.activity) {
1386
+ if (!agentActivity.has(id))
1387
+ agentActivity.set(id, state);
1388
+ }
1389
+ void fresh.dispose();
1390
+ // Runs that settled between the two instances, handled as if just now —
1391
+ // once this start has finished setting up.
1392
+ const settled = parked.completed;
1393
+ if (settled.length > 0)
1394
+ setTimeout(() => { for (const record of settled)
1395
+ onAgentComplete(record); }, 0);
1396
+ }
1397
+ /**
1398
+ * At a session switch: leave the background agents running in a manager
1399
+ * parked for the next instance (session-handoff.ts). Nothing parked here
1400
+ * may touch this instance's `pi` or ctx, which Pi invalidates next: until
1401
+ * adopted, completions are queued in the entry.
1402
+ */
1403
+ function parkAgents(reason, targetSessionFile) {
1404
+ const parkedManager = manager;
1405
+ const entry = {
1406
+ protocol: HANDOFF_PROTOCOL,
1407
+ version: PACKAGE_VERSION,
1408
+ reason,
1409
+ sessionFile: currentSession,
1410
+ targetSessionFile,
1411
+ manager: parkedManager,
1412
+ activity: new Map(agentActivity),
1413
+ completed: [],
1414
+ stop: (write) => stopParkedAgents(parkedManager, entry, write),
1415
+ };
1416
+ parkedManager.setObservers({ onComplete: (record) => { entry.completed.push(record); } });
1417
+ parkedManager.detachPendingContexts();
1418
+ parkedManager.setVisibleOwner(null);
1419
+ parkSubagents(entry, () => recordParkedAgentsAtExit(parkedManager, entry));
1420
+ }
1421
+ /**
1422
+ * Notify this session of its agents that finished while another session was
1423
+ * open (`pending` record entries), once, after this start: a turn follows,
1424
+ * as for any background completion. Marked delivered at once, so a second
1425
+ * start does not repeat it.
1426
+ */
1427
+ function deliverPendingResults(branch) {
1428
+ const pending = pendingResultsFromEntries(branch);
1429
+ if (pending.length === 0)
1430
+ return;
1431
+ const ids = pending.map(r => r.id);
1432
+ try {
1433
+ pi.appendEntry(DELIVERED_ENTRY_TYPE, { ids });
1434
+ }
1435
+ catch {
1436
+ return;
1437
+ }
1438
+ // The record still in memory carries more (context, compactions) than its entry.
1439
+ const records = pending.map(entry => {
1440
+ const live = manager.getRecord(entry.id);
1441
+ return live && manager.isVisible(live) ? live : entry;
1442
+ });
1443
+ // Through the completion outbox, like every other completion: delivered at
1444
+ // this session's next boundary (a turn when idle), coalesced with anything
1445
+ // else that finished, and cancelled per agent by get_subagent_result.
1446
+ // An entry is all there is of an evicted agent, and get_subagent_result
1447
+ // cannot fetch more, so its whole result goes in.
1448
+ const maxLen = (r) => (manager.getRecord(r.id) === r ? 500 : 20_000);
1449
+ for (const r of records) {
1450
+ agentActivity.delete(r.id);
1451
+ widget.markFinished(r.id);
1452
+ fleet.onAgentFinished(r.id);
1453
+ }
1454
+ const parts = records.flatMap(r => {
1455
+ try {
1456
+ return [{ id: r.id, text: formatTaskNotification(r, maxLen(r), showCost), details: buildNotificationDetails(r, 300) }];
1457
+ }
1458
+ catch {
1459
+ return []; /* as in sendIndividualNudge */
1460
+ }
1461
+ });
1462
+ if (parts.length > 0) {
1463
+ outbox.add({
1464
+ ids: parts.map(p => p.id),
1465
+ build: (unreadIds) => {
1466
+ const unread = parts.filter(p => unreadIds.includes(p.id));
1467
+ const [first, ...rest] = unread;
1468
+ const details = { ...first.details };
1469
+ if (rest.length > 0)
1470
+ details.others = rest.map(p => p.details);
1471
+ const label = unread.length === 1 ? "An agent" : `${unread.length} agents`;
1472
+ return {
1473
+ content: `${label} of this session finished while another session was open.\n\n${unread.map(p => p.text).join("\n\n")}`,
1474
+ details,
1475
+ };
1476
+ },
1477
+ });
1478
+ }
1479
+ widget.update();
1480
+ }
1481
+ // On shutdown: on a session switch, background agents keep running (parked
1482
+ // for the next instance); on quit, every agent is stopped and recorded.
1483
+ pi.on("session_shutdown", async (event) => {
1149
1484
  // Spend no tool result carried back yet: record it while the session can.
1150
1485
  flushPendingUsage();
1151
1486
  disposed = true;
@@ -1171,26 +1506,73 @@ export default function (pi) {
1171
1506
  for (const task of workflowTasks.values())
1172
1507
  task.abortController.abort();
1173
1508
  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();
1509
+ const live = manager.listAllAgents().filter(r => r.status === "running" || r.status === "queued");
1510
+ // Pi says why (Pi >= 1.0): `new`, `resume`, `fork` and `reload` replace the
1511
+ // session within this process; `quit` (also /quit and SIGTERM/SIGHUP) or no
1512
+ // reason ends it. On a switch, a background agent whose session is on disk
1513
+ // keeps running — its results can still be recorded there — along with
1514
+ // the agents it owns. Foreground agents (Pi aborted their tool call before
1515
+ // this), workflows and agents of a session kept in memory stop as before.
1516
+ const { reason, targetSessionFile } = (event ?? {});
1517
+ // The next instance finds the parked manager by the session it starts, so
1518
+ // a switch must name it (Pi 1.0 does; `/reload` keeps this one).
1519
+ const switching = isSessionSwitch(reason)
1520
+ && (reason === "reload" ? currentSession !== undefined : typeof targetSessionFile === "string");
1521
+ const kept = new Set(switching
1522
+ ? live.filter(r => isTopLevelAgent(r) && r.isBackground !== false && isSessionOnDisk(r.ownerSession)).map(r => r.id)
1523
+ : []);
1524
+ const rootOf = (record) => {
1525
+ let current = record;
1526
+ for (let parent = current.parentAgentId; parent !== undefined; parent = current.parentAgentId) {
1527
+ const next = manager.getRecord(parent);
1528
+ if (!next)
1529
+ break;
1530
+ current = next;
1531
+ }
1532
+ return current.id;
1533
+ };
1534
+ const interrupted = live.filter(r => !kept.has(rootOf(r)));
1535
+ // Also worth keeping: another session's agents that finished while this
1536
+ // one was open, so their session shows them in full when it is reopened.
1537
+ const carried = switching && manager.listAllAgents().some(r => isTopLevelAgent(r) && !ownedHere(r) && !r.resultConsumed && !manager.isRunActive(r.id) && isSessionOnDisk(r.ownerSession));
1538
+ const parking = kept.size > 0 || carried;
1539
+ if (kept.size > 0) {
1540
+ for (const record of interrupted)
1541
+ manager.abort(record.id);
1542
+ }
1543
+ else {
1544
+ manager.abortAll();
1545
+ }
1179
1546
  // 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();
1547
+ // settles; dispose() does not wait for that, and the process may exit next.
1548
+ await manager.awaitWorktreeCleanup(15_000, kept.size > 0 ? new Set(interrupted.map(r => r.id)) : undefined);
1549
+ // Each recorded as stopped and resumable, in the session it belongs to.
1550
+ for (const record of interrupted) {
1551
+ if (isTopLevelAgent(record))
1552
+ appendRecordEntry(record);
1553
+ else
1554
+ appendResumeEntry(record);
1555
+ }
1556
+ if (parking)
1557
+ parkAgents(reason, targetSessionFile);
1558
+ // Completions still waiting for this session's next boundary (a busy or
1559
+ // interrupted parent): marked pending in the session they belong to, so
1560
+ // its next start (a switch back, a restart) delivers them like any result
1561
+ // that landed while another session was open.
1562
+ for (const id of outbox.pendingIds()) {
1563
+ const record = manager.getRecord(id);
1564
+ if (record && isTopLevelAgent(record) && !record.resultConsumed && !manager.isRunActive(id))
1565
+ appendRecordEntry(record, true);
1566
+ }
1187
1567
  // Neither may fire into a torn-down instance (or hold `pi -p` open).
1568
+ outbox.dispose();
1188
1569
  groupJoin.dispose();
1189
- if (batchFinalizeTimer)
1190
- clearTimeout(batchFinalizeTimer);
1191
- batchFinalizeTimer = undefined;
1192
- currentBatchAgents = [];
1570
+ turnGroupSpawns = [];
1193
1571
  fleet.dispose();
1572
+ unsubHandoffKey?.();
1573
+ unsubHandoffKey = undefined;
1574
+ handoffKeyUi = undefined;
1575
+ foregroundHandoffs.clear();
1194
1576
  // Its 80 ms refresh interval otherwise outlives the session across /reload.
1195
1577
  // The interval is cleared first; the UI calls after it may meet a context
1196
1578
  // that is already gone.
@@ -1199,6 +1581,9 @@ export default function (pi) {
1199
1581
  }
1200
1582
  catch { /* stale UI context at shutdown */ }
1201
1583
  refreshTicker.clear();
1584
+ // A parked manager lives on, with its agents' sessions.
1585
+ if (parking)
1586
+ return;
1202
1587
  // Awaited: it emits `session_shutdown` into every retained child session so
1203
1588
  // extensions bound there can release what they armed in `session_start` (#242).
1204
1589
  // pi awaits this handler, and the process exits right after — unawaited, those
@@ -1217,10 +1602,102 @@ export default function (pi) {
1217
1602
  const refreshTicker = new RefreshTicker();
1218
1603
  const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled, refreshTicker);
1219
1604
  function setWidgetMode(m) { widgetMode = m; widget.update(); }
1605
+ /**
1606
+ * Foreground `Agent` calls whose run can be handed to the background, by
1607
+ * agent id. Each entry wakes its blocked tool call, which then returns as a
1608
+ * background spawn would. Only the Agent tool registers here, so a spawn with
1609
+ * no tool call to return — the agent generator, a workflow's agents, nested
1610
+ * children — never offers the handoff.
1611
+ */
1612
+ const foregroundHandoffs = new Map();
1613
+ /** Hand these runs to the background and wake their tool calls; returns how many took. */
1614
+ function sendForegroundToBackground(ids) {
1615
+ let sent = 0;
1616
+ for (const id of [...ids]) {
1617
+ const wake = foregroundHandoffs.get(id);
1618
+ if (!wake || !manager.sendToBackground(id))
1619
+ continue;
1620
+ foregroundHandoffs.delete(id);
1621
+ wake();
1622
+ sent++;
1623
+ // It is a background agent from here on, so announce it as one — as a
1624
+ // detached resume re-announces an existing id.
1625
+ const record = manager.getRecord(id);
1626
+ if (record) {
1627
+ pi.events.emit("subagents:created", {
1628
+ id,
1629
+ type: record.type,
1630
+ description: record.description,
1631
+ isBackground: true,
1632
+ });
1633
+ }
1634
+ }
1635
+ if (sent > 0) {
1636
+ widget.ensureTimer();
1637
+ widget.update();
1638
+ fleet.ensureTimer();
1639
+ fleet.update();
1640
+ }
1641
+ return sent;
1642
+ }
1643
+ /** The conversation viewer's `b`: hand just this run to the background. */
1644
+ function backgroundActionFor(record, notify) {
1645
+ return {
1646
+ // Registered while the run is live, removed the moment its call returns.
1647
+ available: () => foregroundHandoffs.has(record.id),
1648
+ send: () => {
1649
+ if (sendForegroundToBackground([record.id]) > 0)
1650
+ notify(`Sent "${record.description}" to the background.`);
1651
+ },
1652
+ };
1653
+ }
1654
+ // Claude Code's ctrl+b (`task:background`), configurable as `backgroundKey`:
1655
+ // hand every running foreground `Agent` call to the background. Claimed only
1656
+ // while one is pending and the prompt editor has focus, so the rest of the
1657
+ // time the key keeps its editor meaning (ctrl+b is pi's cursor-left).
1658
+ let handoffKeyUi;
1659
+ let unsubHandoffKey;
1660
+ function watchHandoffKey(ui) {
1661
+ if (ui === handoffKeyUi)
1662
+ return;
1663
+ unsubHandoffKey?.();
1664
+ handoffKeyUi = ui;
1665
+ unsubHandoffKey = ui.onTerminalInput(data => {
1666
+ // Cheapest checks first: this listener sees every key the user types.
1667
+ if (foregroundHandoffs.size === 0)
1668
+ return undefined;
1669
+ const key = getBackgroundKey();
1670
+ // Kitty reports a release too, and matchesKey matches it — act on the
1671
+ // press only, or a refused handoff would be retried by its own release.
1672
+ if (key === undefined || isKeyRelease(data) || !matchesKey(data, key))
1673
+ return undefined;
1674
+ // Listeners run before the focused component, so a dialog or the
1675
+ // viewer's composer would lose the key. The viewer has its own `b`.
1676
+ if (!fleet.editorHasFocus())
1677
+ return undefined;
1678
+ const sent = sendForegroundToBackground(foregroundHandoffs.keys());
1679
+ if (sent > 0)
1680
+ ui.notify(`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`, "info");
1681
+ return { consume: true };
1682
+ });
1683
+ }
1684
+ /**
1685
+ * Pi's fullscreen scrollbar preference, read from the main session's live,
1686
+ * trust-aware settings and normalized as Pi's own `getFullscreenScrollbar()` does.
1687
+ */
1688
+ function getFullscreenScrollbar() {
1689
+ try {
1690
+ const mode = pi.getSettings().fullscreenScrollbar;
1691
+ return mode === "always" || mode === "hidden" ? mode : "auto";
1692
+ }
1693
+ catch {
1694
+ return "auto";
1695
+ }
1696
+ }
1220
1697
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
1221
1698
  // The last two arguments keep a conversation overlay opened here identical to
1222
1699
  // 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);
1700
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, currentCtx), 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,
@@ -1535,6 +2016,7 @@ export default function (pi) {
1535
2016
  setShowCost,
1536
2017
  setShowModel,
1537
2018
  setViewerMarkdown,
2019
+ setBackgroundKey,
1538
2020
  };
1539
2021
  applyAndEmitLoaded(settingsAppliers, (event, payload) => pi.events.emit(event, payload), process.cwd(), projectTrusted);
1540
2022
  /**
@@ -1609,6 +2091,22 @@ export default function (pi) {
1609
2091
  const isolationGuideline = isWorktreeIsolationEnabled()
1610
2092
  ? `\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
2093
  : "";
2094
+ // Same trade again, for `allowModelOverride`: with overrides off the `model`
2095
+ // and `thinking` parameters are not in the schema, so the prose that teaches
2096
+ // them goes too — a model told it may pick a model will keep trying.
2097
+ const modelOverrideAllowed = isModelOverrideAllowed();
2098
+ const modelGuideline = modelOverrideAllowed
2099
+ ? `- 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`
2100
+ : "";
2101
+ const modelParamShape = {
2102
+ model: Type.Optional(Type.String({
2103
+ description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
2104
+ })),
2105
+ thinking: Type.Optional(Type.String({
2106
+ description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
2107
+ })),
2108
+ };
2109
+ const modelParams = modelOverrideAllowed ? modelParamShape : {};
1612
2110
  const isolationCompactGuideline = isWorktreeIsolationEnabled()
1613
2111
  ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1614
2112
  : "";
@@ -1652,9 +2150,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1652
2150
  - Use steer_subagent to send mid-run messages to a running background agent.
1653
2151
  - 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
2152
  - 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}
2153
+ ${modelGuideline}- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1658
2154
 
1659
2155
  ## Writing the prompt
1660
2156
 
@@ -1679,6 +2175,7 @@ Terse command-style prompts produce shallow, generic work.
1679
2175
  agentDir: getAgentDir,
1680
2176
  isolationGuideline: () => isolationGuideline,
1681
2177
  scheduleGuideline: () => scheduleGuideline,
2178
+ modelGuideline: () => modelGuideline,
1682
2179
  };
1683
2180
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
1684
2181
  return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
@@ -1746,12 +2243,7 @@ Terse command-style prompts produce shallow, generic work.
1746
2243
  subagent_type: Type.String({
1747
2244
  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
2245
  }),
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
- })),
2246
+ ...modelParams,
1755
2247
  max_turns: Type.Optional(Type.Number({
1756
2248
  description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
1757
2249
  minimum: 1,
@@ -1826,7 +2318,14 @@ Terse command-style prompts produce shallow, generic work.
1826
2318
  if (isPartial || details.status === "running") {
1827
2319
  const frame = SPINNER[details.spinnerFrame ?? 0];
1828
2320
  const s = stats(details);
1829
- return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
2321
+ const status = renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
2322
+ // Claude Code's hint, after the same 2s: a call that finishes quickly
2323
+ // never flashes it.
2324
+ const backgroundKey = getBackgroundKey();
2325
+ if (details.backgroundHint && backgroundKey && details.durationMs >= BACKGROUND_HINT_DELAY_MS) {
2326
+ status.addChild(new Text(theme.fg("dim", ` (${backgroundKey} to run in background)`), 0, 0));
2327
+ }
2328
+ return status;
1830
2329
  }
1831
2330
  // ---- Background agent launched ----
1832
2331
  if (details.status === "background") {
@@ -2066,7 +2565,9 @@ Terse command-style prompts produce shallow, generic work.
2066
2565
  // at fire time, and the original is what a user edits.
2067
2566
  subagent_type: requestedType,
2068
2567
  prompt: params.prompt,
2069
- model: params.model,
2568
+ // Only when overrides are on: a job keeps the call's model choice
2569
+ // for every later fire, which is exactly what the setting refuses.
2570
+ model: isModelOverrideAllowed() ? params.model : undefined,
2070
2571
  thinking: thinking,
2071
2572
  max_turns: effectiveMaxTurns,
2072
2573
  isolated: isolated,
@@ -2193,6 +2694,21 @@ Terse command-style prompts produce shallow, generic work.
2193
2694
  (record.result?.trim() || "No output.") +
2194
2695
  agentIdLine(record.id), buildDetails(detailBaseFor(record), record));
2195
2696
  }
2697
+ // What the model sees for a run it no longer waits on — spawned in the
2698
+ // background, or handed there mid-run (background key / viewer `b`). One
2699
+ // text for both, as in Claude Code: from here on they are the same kind of agent.
2700
+ const backgroundResult = (id, record) => {
2701
+ const isQueued = record?.status === "queued";
2702
+ return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2703
+ `Agent ID: ${id}\n` +
2704
+ `Type: ${displayName}\n` +
2705
+ `Description: ${params.description}\n` +
2706
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2707
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2708
+ `\nYou will be notified when this agent completes.\n` +
2709
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2710
+ `Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
2711
+ };
2196
2712
  // Background execution
2197
2713
  if (runInBackground) {
2198
2714
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
@@ -2200,12 +2716,15 @@ Terse command-style prompts produce shallow, generic work.
2200
2716
  // The callback lazily reads record.outputFile (set right after spawn)
2201
2717
  // rather than closing over a value that doesn't exist yet.
2202
2718
  let id;
2719
+ // Read now: a queued agent may start after a session switch, when this
2720
+ // ctx is stale and every read of it throws.
2721
+ const bgCwd = ctx.cwd;
2203
2722
  const origBgOnSession = bgCallbacks.onSessionCreated;
2204
2723
  bgCallbacks.onSessionCreated = (session) => {
2205
2724
  origBgOnSession(session);
2206
2725
  const rec = manager.getRecord(id);
2207
2726
  if (rec?.outputFile) {
2208
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
2727
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, bgCwd);
2209
2728
  }
2210
2729
  };
2211
2730
  // A throw here means the agent never started. Let it out: pi marks a
@@ -2239,18 +2758,7 @@ Terse command-style prompts produce shallow, generic work.
2239
2758
  // wiring above, so a strict-isolation failure still fails THIS tool
2240
2759
  // call instead of being reported as a subagent that ran (#179).
2241
2760
  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
- }
2761
+ joinTurnGroup(id, joinMode);
2254
2762
  agentActivity.set(id, bgState);
2255
2763
  widget.ensureTimer();
2256
2764
  widget.update();
@@ -2263,26 +2771,26 @@ Terse command-style prompts produce shallow, generic work.
2263
2771
  description: params.description,
2264
2772
  isBackground: true,
2265
2773
  });
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 });
2774
+ return backgroundResult(id, record);
2276
2775
  }
2277
2776
  // Foreground (synchronous) execution — stream progress via onUpdate
2278
2777
  let spinnerFrame = 0;
2279
2778
  const startedAt = Date.now();
2280
2779
  let fgId;
2780
+ // Set once the run is live and can be handed to the background — see
2781
+ // `foregroundHandoffs`. Unset while queued: there is no run to hand over.
2782
+ let handoffId;
2783
+ // Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
2784
+ // updates once `execute` has returned), so `streamUpdate` stops building
2785
+ // them; the tracker keeps `fgState` live for the widget and FleetView.
2786
+ let backgrounded = false;
2281
2787
  // Set only while the spawn is parked on a foreground concurrency slot
2282
2788
  // (maxConcurrentForeground); undefined the rest of the time, including
2283
2789
  // always when the limit is unset.
2284
2790
  let queuedAhead;
2285
2791
  const streamUpdate = () => {
2792
+ if (backgrounded)
2793
+ return;
2286
2794
  // Spend from the record, everything else from the live tracker. `fgId`
2287
2795
  // is set in onSessionCreated below, which fires before the first
2288
2796
  // assistant message — so nothing is spent while this reads zero.
@@ -2305,6 +2813,9 @@ Terse command-style prompts produce shallow, generic work.
2305
2813
  ? describeActivity(fgState.activeTools, fgState.responseText)
2306
2814
  : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
2307
2815
  spinnerFrame: spinnerFrame % SPINNER.length,
2816
+ // One hint per batch, as in Claude Code: on the earliest call still
2817
+ // pending (a Map iterates in insertion order). The key takes them all.
2818
+ backgroundHint: handoffId !== undefined && foregroundHandoffs.keys().next().value === handoffId,
2308
2819
  };
2309
2820
  onUpdate?.({
2310
2821
  content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
@@ -2353,44 +2864,76 @@ Terse command-style prompts produce shallow, generic work.
2353
2864
  streamUpdate();
2354
2865
  });
2355
2866
  streamUpdate();
2356
- let record;
2867
+ // The background key or the viewer's `b` wakes this with the run already
2868
+ // handed over (`sendForegroundToBackground`), raced against the run
2869
+ // below. The manager only accepts a handoff of a run that has not
2870
+ // settled, and the wake-up resolves synchronously with it, so whichever
2871
+ // promise wins is the truth: the result is never also delivered as a
2872
+ // notification.
2873
+ let wakeBackgrounded;
2874
+ const handedOff = new Promise(resolve => { wakeBackgrounded = () => resolve(undefined); });
2875
+ let fgResult;
2357
2876
  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;
2877
+ fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2878
+ description: params.description,
2879
+ name: params.name,
2880
+ model,
2881
+ maxTurns: effectiveMaxTurns,
2882
+ isolated,
2883
+ inheritContext,
2884
+ thinkingLevel: thinking,
2885
+ isolation,
2886
+ invocation: agentInvocation,
2887
+ signal,
2888
+ rootSessionId: ctx.sessionManager.getSessionId(),
2889
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2890
+ // widget and the `finally` cleanup below, none of which should see an
2891
+ // agent that has no session and may never get one.
2892
+ onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2893
+ ...fgCallbacks,
2894
+ }, (fgAgentId) => {
2895
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2896
+ // Set up the output file so streamToOutputFile can pick it up.
2897
+ const fgRec = manager.getRecord(fgAgentId);
2898
+ attachTranscript(fgRec, fgAgentId);
2899
+ // Fires once the run promise exists, i.e. the agent is running. From
2900
+ // here until this call returns, the run can be handed off.
2901
+ handoffId = fgAgentId;
2902
+ foregroundHandoffs.set(fgAgentId, () => {
2903
+ // What a background spawn sets up front: the notification names
2904
+ // this tool call, and it reports alone — its turn's batch is long
2905
+ // closed.
2906
+ if (fgRec) {
2907
+ fgRec.toolCallId = toolCallId;
2908
+ fgRec.joinMode = "async";
2909
+ }
2910
+ backgrounded = true;
2911
+ wakeBackgrounded();
2912
+ });
2913
+ }), handedOff]);
2382
2914
  }
2383
2915
  finally {
2384
2916
  // Runs on both paths, so a startup throw — which now propagates, see
2385
2917
  // the background spawn above (#179) — no longer leaves the spinner
2386
2918
  // ticking or a finished agent on the widget.
2387
2919
  refreshTicker.delete(spinnerOwner);
2388
- if (fgId) {
2920
+ if (handoffId !== undefined)
2921
+ foregroundHandoffs.delete(handoffId);
2922
+ // A handed-off run is still working: its row stays, and the
2923
+ // background settle in `onComplete` tears it down when it really ends.
2924
+ if (fgId && !backgrounded) {
2389
2925
  agentActivity.delete(fgId);
2390
2926
  widget.markFinished(fgId);
2391
2927
  fleet.onAgentFinished(fgId);
2392
2928
  }
2393
2929
  }
2930
+ if (fgResult === undefined) {
2931
+ // Shown as a background agent from here on (the widget's default mode
2932
+ // hides foreground rows).
2933
+ widget.update();
2934
+ return backgroundResult(handoffId, manager.getRecord(handoffId));
2935
+ }
2936
+ const record = fgResult.record;
2394
2937
  // Get final token count — from the record, like the cost below it, so the
2395
2938
  // two describe the same work when the agent delegated to nested children.
2396
2939
  const tokenText = formatLifetimeTokens(record);
@@ -2516,33 +3059,30 @@ Terse command-style prompts produce shallow, generic work.
2516
3059
  }
2517
3060
  /**
2518
3061
  * 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.
3062
+ * agent uses — the completion outbox, which delivers at the parent's next
3063
+ * boundary — rendered by the existing `subagent-notification` renderer.
2521
3064
  */
2522
3065
  function notifyWorkflowFinished(task) {
2523
3066
  widget.update();
2524
3067
  fleet.update();
2525
3068
  flushPendingUsageIfIdle();
2526
3069
  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
- });
3070
+ const message = {
3071
+ content: formatWorkflowNotification(task),
3072
+ details: {
3073
+ id: task.id,
3074
+ description: `Workflow ${task.workflowName ?? task.id}`,
3075
+ status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
3076
+ toolUses: task.totalToolCalls,
3077
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
3078
+ turnCount: 0,
3079
+ totalTokens: task.totalTokens,
3080
+ durationMs: elapsedMs(task, Date.now()),
3081
+ error: task.error,
3082
+ resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
3083
+ },
3084
+ };
3085
+ outbox.add({ ids: [task.id], build: () => message });
2546
3086
  }
2547
3087
  // Defined unconditionally, registered only when the feature is on — the same
2548
3088
  // shape the Agent tool uses. Keeping the definition out of the `if` means the
@@ -2550,7 +3090,7 @@ Terse command-style prompts produce shallow, generic work.
2550
3090
  const workflowTool = defineTool({
2551
3091
  name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2552
3092
  label: "SubagentWorkflow",
2553
- description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
3093
+ description: renderToolDescriptionTemplate(workflowToolDescription(isModelOverrideAllowed())),
2554
3094
  promptSnippet: "Run a deterministic script that orchestrates many subagents",
2555
3095
  promptGuidelines: [
2556
3096
  "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 +3151,21 @@ Terse command-style prompts produce shallow, generic work.
2611
3151
  }, theme);
2612
3152
  },
2613
3153
  execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2614
- const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
3154
+ // A run from before a restart, `/resume` or `/reload` is found by its
3155
+ // files: its journal and saved script sit in this session's task dir.
3156
+ const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks, (id) => {
3157
+ try {
3158
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
3159
+ const journal = join(dir, `${id}.workflow.jsonl`);
3160
+ if (!existsSync(journal))
3161
+ return undefined;
3162
+ const script = join(dir, `${id}.workflow.js`);
3163
+ return { journalPath: journal, ...(existsSync(script) ? { scriptPath: script } : {}) };
3164
+ }
3165
+ catch {
3166
+ return undefined;
3167
+ }
3168
+ });
2615
3169
  if (resumeFrom !== undefined && !resumeFrom.ok)
2616
3170
  return textResult(resumeFrom.message);
2617
3171
  // A resume with no source of its own re-runs what that run ran. The
@@ -2921,7 +3475,7 @@ Terse command-style prompts produce shallow, generic work.
2921
3475
  // Mark result as consumed — suppresses the completion notification
2922
3476
  if (!active) {
2923
3477
  record.resultConsumed = true;
2924
- cancelNudge(record.id);
3478
+ outbox.cancel(record.id);
2925
3479
  }
2926
3480
  // Verbose: include full conversation
2927
3481
  if (params.verbose && record.session) {
@@ -3167,18 +3721,19 @@ Terse command-style prompts produce shallow, generic work.
3167
3721
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
3168
3722
  return;
3169
3723
  }
3170
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
3171
3724
  const session = record.session;
3172
3725
  const activity = agentActivity.get(record.id);
3726
+ let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
3173
3727
  await ctx.ui.custom((tui, theme, keybindings, done) => {
3728
+ overlayOptions = getConversationOverlayOptions(tui);
3174
3729
  return new ConversationViewer(tui, session, record, activity, theme, done, () => {
3175
3730
  if (manager.abort(record.id)) {
3176
3731
  ctx.ui.notify(`Stopped "${record.description}".`, "info");
3177
3732
  }
3178
- }, keybindings, (message) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx));
3733
+ }, keybindings, (message) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), backgroundActionFor(record, message => ctx.ui.notify(message, "info")), getFullscreenScrollbar());
3179
3734
  }, {
3180
3735
  overlay: true,
3181
- overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
3736
+ overlayOptions: () => overlayOptions,
3182
3737
  });
3183
3738
  }
3184
3739
  async function showAgentDetail(ctx, name) {
@@ -3540,6 +4095,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3540
4095
  backgroundByDefault: getBackgroundByDefault(),
3541
4096
  schedulingEnabled: isSchedulingEnabled(),
3542
4097
  scopeModels: isScopeModelsEnabled(),
4098
+ allowModelOverride: isModelOverrideAllowed(),
3543
4099
  strictAgentFiles,
3544
4100
  disableDefaultAgents: isDefaultsDisabled(),
3545
4101
  toolDescriptionMode: getToolDescriptionMode(),
@@ -3567,6 +4123,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
3567
4123
  showCost: isShowCostEnabled(),
3568
4124
  showModel: isShowModelEnabled(),
3569
4125
  viewerMarkdown: getViewerMarkdown(),
4126
+ // As written: undefined (the default) stays out of the file.
4127
+ backgroundKey: backgroundKeySetting,
3570
4128
  };
3571
4129
  }
3572
4130
  const _settingsSnapshotIsComplete = true;
@@ -3635,9 +4193,10 @@ Write the file using the write tool. Only write the file, nothing else.`;
3635
4193
  {
3636
4194
  id: "joinMode",
3637
4195
  label: "Join mode",
3638
- description: "Default join mode for background agents",
3639
- currentValue: getDefaultJoinMode(),
3640
- values: ["smart", "async", "group"],
4196
+ description: "smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
4197
+ // `async` is an older name for `smart`; settings files may still hold it.
4198
+ currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
4199
+ values: ["smart", "group"],
3641
4200
  },
3642
4201
  {
3643
4202
  id: "backgroundByDefault",
@@ -3668,6 +4227,15 @@ Write the file using the write tool. Only write the file, nothing else.`;
3668
4227
  currentValue: isScopeModelsEnabled() ? "on" : "off",
3669
4228
  values: ["on", "off"],
3670
4229
  },
4230
+ {
4231
+ id: "allowModelOverride",
4232
+ label: "Model override",
4233
+ description: "Let Agent calls, workflows and scheduled jobs pick a subagent's model and thinking level; "
4234
+ + "off runs every subagent on your current model and thinking level unless its agent file sets one "
4235
+ + "(the tool parameters change on next pi session)",
4236
+ currentValue: isModelOverrideAllowed() ? "on" : "off",
4237
+ values: ["on", "off"],
4238
+ },
3671
4239
  {
3672
4240
  id: "strictAgentFiles",
3673
4241
  label: "Strict agent files",
@@ -3861,6 +4429,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3861
4429
  setScopeModelsEnabled(enabled);
3862
4430
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
3863
4431
  }
4432
+ else if (id === "allowModelOverride") {
4433
+ const enabled = value === "on";
4434
+ setModelOverrideAllowed(enabled);
4435
+ notifyApplied(ctx, enabled
4436
+ ? "Subagent calls may pick a model and thinking level. Tool spec change takes effect on next pi session."
4437
+ : "Subagents run on your current model and thinking level. Tool spec change takes effect on next pi session.");
4438
+ }
3864
4439
  else if (id === "strictAgentFiles") {
3865
4440
  const enabled = value === "on";
3866
4441
  strictAgentFiles = enabled;