@signalridge/pi-subagents 1.11.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  Spacer,
38
38
  Text,
39
39
  } from "@earendil-works/pi-tui";
40
+ import { parseOwnedConversationOpenRequest, replyChannel, WORKFLOW_UI_CHANNELS } from "@signalridge/pi-subagents-protocol";
40
41
  import { wrapCustomUi } from "@signalridge/pi-ui";
41
42
  import { Type } from "@sinclair/typebox";
42
43
  import { abortable } from "./abortable.js";
@@ -57,6 +58,7 @@ import {
57
58
  import {
58
59
  AgentManager,
59
60
  MANAGED_SPAWN_ENTRY_TYPE,
61
+ type ManagedSpawnCallbacks,
60
62
  type ManagedSpawnEntryLike,
61
63
  type ManagedSpawnPolicy,
62
64
  type ManagedSpawnRequest,
@@ -165,7 +167,9 @@ import {
165
167
  streamToOutputFile,
166
168
  writeInitialEntry,
167
169
  } from "./output-file.js";
170
+ import { registerParentQuestions } from "./parent-questions.js";
168
171
  import { captureProjectTrust, isProjectResource } from "./project-trust.js";
172
+ import { captureQuestionOrigin } from "./question-origin.js";
169
173
  import { SubagentScheduler } from "./schedule.js";
170
174
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
171
175
  import {
@@ -211,11 +215,8 @@ import {
211
215
  type Theme,
212
216
  } from "./ui/agent-display.js";
213
217
  import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
214
- import {
215
- ConversationViewer,
216
- VIEWPORT_HEIGHT_PCT,
217
- } from "./ui/conversation-viewer.js";
218
- import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
218
+ import { getConversationOverlayOptions } from "./ui/conversation-viewer.js";
219
+ import { type AgentSelection, captureAgentSelection, FleetList, type FleetUICtx } from "./ui/fleet-list.js";
219
220
  import {
220
221
  safeTerminalText,
221
222
  sanitizeDisplayText,
@@ -228,6 +229,7 @@ import {
228
229
  getAgentStatusLabel,
229
230
  getAgentStatusMark,
230
231
  } from "./ui/status-label.js";
232
+ import { WorkflowUiClient } from "./ui/workflow-ui-client.js";
231
233
  import {
232
234
  addUsage,
233
235
  getLifetimeTotal,
@@ -809,6 +811,12 @@ function activateRootRuntime(
809
811
  // tokens is omitted when nothing was ever produced (e.g. agent errored before
810
812
  // any message_end fired), preserving prior payload shape.
811
813
  const u = record.lifetimeUsage;
814
+ const managedUsage = record.owner ? manager.getManagedUsage(record.id, record.owner) : undefined;
815
+ const managedTokens = managedUsage ? {
816
+ input: managedUsage.input,
817
+ output: managedUsage.output,
818
+ total: getLifetimeTotal(managedUsage),
819
+ } : undefined;
812
820
  const total = getLifetimeTotal(u);
813
821
  const tokens =
814
822
  total > 0 ? { input: u.input, output: u.output, total } : undefined;
@@ -822,6 +830,7 @@ function activateRootRuntime(
822
830
  toolUses: record.toolUses,
823
831
  durationMs,
824
832
  tokens,
833
+ ...(managedTokens ? { managedTokens: { ...managedTokens } } : {}),
825
834
  ...(record.outputFile ? { outputFile: record.outputFile } : {}),
826
835
  ...(record.invocation?.agentTier === undefined
827
836
  ? {}
@@ -917,13 +926,25 @@ function activateRootRuntime(
917
926
  string,
918
927
  { runs: number; usage: LifetimeUsage }
919
928
  >();
929
+ const accountedUsage = new WeakMap<AgentRecord, { usage: LifetimeUsage; completion: string; promise: AgentRecord["promise"] }>();
920
930
  function recordSessionUsage(record: AgentRecord): void {
931
+ const previous = accountedUsage.get(record);
932
+ const completion = JSON.stringify([record.owner, record.startedAt, record.completedAt]);
933
+ if (previous?.completion === completion && previous.promise === record.promise) return;
934
+ const usage = {
935
+ input: Math.max(0, record.lifetimeUsage.input - (previous?.usage.input ?? 0)),
936
+ output: Math.max(0, record.lifetimeUsage.output - (previous?.usage.output ?? 0)),
937
+ cacheWrite: Math.max(0, record.lifetimeUsage.cacheWrite - (previous?.usage.cacheWrite ?? 0)),
938
+ };
939
+ // Records survive native resumes; lifetime totals must be credited only once.
940
+ // Weak ownership releases bookkeeping when the manager evicts the record.
941
+ accountedUsage.set(record, { usage: { ...record.lifetimeUsage }, completion, promise: record.promise });
921
942
  const entry = sessionUsageByType.get(record.type) ?? {
922
943
  runs: 0,
923
944
  usage: { input: 0, output: 0, cacheWrite: 0 },
924
945
  };
925
946
  entry.runs++;
926
- addUsage(entry.usage, record.lifetimeUsage);
947
+ addUsage(entry.usage, usage);
927
948
  sessionUsageByType.set(record.type, entry);
928
949
  }
929
950
 
@@ -933,6 +954,14 @@ function activateRootRuntime(
933
954
  // Nested children report only through their owning parent's scoped tools.
934
955
  // Keep them out of top-level lifecycle, transcript, notification, and UI channels.
935
956
  if (record.parentAgentId || record.detached) return;
957
+ // Native thread resumes do not pass the fresh-spawn stream finalizer.
958
+ // Flush and retire this owned attempt's UI subscription before publishing
959
+ // its terminal payload; the next admission installs a new anchored stream.
960
+ if (record.owner) {
961
+ const cleanup = record.outputCleanup;
962
+ record.outputCleanup = undefined;
963
+ cleanup?.();
964
+ }
936
965
 
937
966
  // Emit lifecycle event based on terminal status
938
967
  const isError =
@@ -1056,6 +1085,10 @@ function activateRootRuntime(
1056
1085
  append: (tombstone) =>
1057
1086
  pi.appendEntry(MANAGED_SPAWN_ENTRY_TYPE, tombstone),
1058
1087
  },
1088
+ (record) => {
1089
+ record.joinMode = "async";
1090
+ refreshAgentSurfaces();
1091
+ },
1059
1092
  );
1060
1093
 
1061
1094
  // Expose the validated root manager via Symbol.for() for cross-package access.
@@ -1194,6 +1227,28 @@ function activateRootRuntime(
1194
1227
  // same session. Invalidate pending off-screen mention turns only when the
1195
1228
  // replacement commits: before-switch/tree handlers can still cancel it.
1196
1229
  let sessionGeneration = 0;
1230
+ const parentQuestions = registerParentQuestions(pi, () => currentCtx);
1231
+ manager.setParentQuestionFactory((record, ctx) => {
1232
+ const generation = sessionGeneration;
1233
+ const sessionId = ctx.sessionManager.getSessionId();
1234
+ const originCurrent = captureQuestionOrigin(ctx);
1235
+ return (question, choices, signal) => {
1236
+ // A retained channel can resume after ordinary native appends. Each
1237
+ // question also owns the LIVE complete prefix at its own invocation.
1238
+ const questionOriginCurrent = currentCtx ? captureQuestionOrigin(currentCtx) : () => false;
1239
+ const controller = record.abortController;
1240
+ const owner = JSON.stringify(record.owner);
1241
+ const isCurrent = () => currentCtx !== undefined &&
1242
+ sessionGeneration === generation && boundSessionId === sessionId &&
1243
+ originCurrent(currentCtx) && questionOriginCurrent(currentCtx) &&
1244
+ manager.getRecordMutable(record.id) === record && !record.detached &&
1245
+ record.isBackground === true && record.status === "running" &&
1246
+ record.abortController === controller && !controller?.signal.aborted &&
1247
+ JSON.stringify(record.owner) === owner;
1248
+ return parentQuestions.broker.request({ agentId: record.id, isCurrent }, question, choices,
1249
+ controller ? AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]) : signal);
1250
+ };
1251
+ });
1197
1252
  // Public synchronous bus preflight works in either extension load order. A
1198
1253
  // filtered-out activation has no bound context and must not veto navigation.
1199
1254
  pi.events.on("pi:navigation-preflight", (raw: unknown) => {
@@ -1259,11 +1314,13 @@ function activateRootRuntime(
1259
1314
  ): Promise<void> {
1260
1315
  // A successful session_start is the commit point for a reused runtime. A
1261
1316
  // cancelled session_before_switch leaves the old context and scheduler live.
1317
+ retireFleetUi();
1262
1318
  if (currentCtx) {
1263
1319
  scheduler.stop();
1264
1320
  manager.detachForBranchChange();
1265
1321
  manager.resetManagedSpawns();
1266
1322
  }
1323
+ parentQuestions.retire();
1267
1324
  sessionGeneration++;
1268
1325
  const replacingConfiguration = currentCtx !== undefined;
1269
1326
  configurationOwner = ctx;
@@ -1377,6 +1434,7 @@ function activateRootRuntime(
1377
1434
  }
1378
1435
  if (ownsRpcRegistry && isSchedulingEnabled() && !scheduler.isActive())
1379
1436
  startScheduler(ctx);
1437
+ bindFleetUi();
1380
1438
  }
1381
1439
 
1382
1440
  // Both before-events are cancellable. A later handler may veto, and tree
@@ -1388,6 +1446,8 @@ function activateRootRuntime(
1388
1446
  const detachCommittedTree = (event: unknown): void => {
1389
1447
  if (!currentCtx || detachedTreeEvent === event) return;
1390
1448
  detachedTreeEvent = event;
1449
+ retireFleetUi();
1450
+ parentQuestions.retire();
1391
1451
  manager.detachForBranchChange();
1392
1452
  };
1393
1453
  // If workflows' committed-tree handler runs first, it must detach our old
@@ -1420,6 +1480,7 @@ function activateRootRuntime(
1420
1480
  manager.clearCompleted(true);
1421
1481
  for (const tombstone of recovered) persistRecoveredManaged(tombstone);
1422
1482
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
1483
+ bindFleetUi();
1423
1484
  });
1424
1485
 
1425
1486
  // Shutdown is idempotent and also serves same-session hot-reload handoff. The
@@ -1446,6 +1507,8 @@ function activateRootRuntime(
1446
1507
  },
1447
1508
  });
1448
1509
  scheduler.stop();
1510
+ parentQuestions.retire();
1511
+ retireFleetUi();
1449
1512
  currentCtx = undefined; // reject new spawns; owned cleanup RPC remains bound
1450
1513
  if (pending.length > 0) {
1451
1514
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -1522,6 +1585,60 @@ function activateRootRuntime(
1522
1585
  // used to carry the same rows, with the same summary repeated again in the
1523
1586
  // footer status; one surface, below the prompt, replaces all three.
1524
1587
  const fleet = new FleetList(manager, agentActivity);
1588
+ let workflowUiClient: WorkflowUiClient | undefined;
1589
+ let ownedConversationUnsubscribe: (() => void) | undefined;
1590
+ const retiredWorkflowSurfaces = new Set<string>();
1591
+
1592
+ function rootUiFence(): () => boolean {
1593
+ const root = currentCtx;
1594
+ const generation = sessionGeneration;
1595
+ const managerIdentity = root?.sessionManager;
1596
+ const sessionId = boundSessionId;
1597
+ return () => !!root && currentCtx === root && generation === sessionGeneration &&
1598
+ currentCtx.sessionManager === managerIdentity && boundSessionId === sessionId &&
1599
+ currentCtx.sessionManager.getSessionId() === sessionId;
1600
+ }
1601
+
1602
+ function retireFleetUi(): void {
1603
+ ownedConversationUnsubscribe?.();
1604
+ ownedConversationUnsubscribe = undefined;
1605
+ workflowUiClient?.dispose();
1606
+ workflowUiClient = undefined;
1607
+ fleet.dispose();
1608
+ }
1609
+
1610
+ function bindFleetUi(): void {
1611
+ if (!currentCtx || !ownsRpcRegistry) return;
1612
+ const ctx = currentCtx;
1613
+ const isCurrent = rootUiFence();
1614
+ ownedConversationUnsubscribe = pi.events.on(WORKFLOW_UI_CHANNELS.openConversation, raw => {
1615
+ let request: ReturnType<typeof parseOwnedConversationOpenRequest>;
1616
+ try { request = parseOwnedConversationOpenRequest(raw); } catch { return; }
1617
+ const channel = replyChannel(WORKFLOW_UI_CHANNELS.openConversation, request.requestId);
1618
+ const retained = manager.getRecordMutable(request.agentId);
1619
+ const selection = retained ? captureAgentSelection(manager, retained, isCurrent) : undefined;
1620
+ const owner = selection?.owner;
1621
+ if (!isCurrent() || ctx.mode !== "tui" || !ctx.hasUI || !selection?.session || !selection.current() ||
1622
+ retained?.parentAgentId || owner?.extension !== request.owner.extension || owner.runId !== request.owner.runId ||
1623
+ owner.nodeId !== request.owner.nodeId || owner.attemptId !== request.owner.attemptId || !fleet.canOpenConversation(selection)) {
1624
+ pi.events.emit(channel, { success: false, error: "Exact retained agent conversation is not available in the active TUI." });
1625
+ return;
1626
+ }
1627
+ // Reserve the exact selection before replying: reply listeners can issue
1628
+ // another request or retire this root synchronously. Only construction yields.
1629
+ if (!fleet.openConversation(selection, true)) {
1630
+ pi.events.emit(channel, { success: false, error: "Exact retained agent conversation is already reserved or unavailable." });
1631
+ return;
1632
+ }
1633
+ pi.events.emit(channel, { success: true, data: { accepted: true } });
1634
+ });
1635
+ if (ctx.mode !== "tui" || !ctx.hasUI) return;
1636
+ fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
1637
+ workflowUiClient = new WorkflowUiClient(pi.events, () => isCurrent() && ctx.mode === "tui" && ctx.hasUI,
1638
+ () => fleet.update(), retiredWorkflowSurfaces);
1639
+ fleet.setWorkflowClient(workflowUiClient);
1640
+ }
1641
+
1525
1642
  let fleetViewEnabled = true;
1526
1643
  function isFleetViewEnabled(): boolean {
1527
1644
  return fleetViewEnabled;
@@ -1543,6 +1660,7 @@ function activateRootRuntime(
1543
1660
  function seatFleet(ctx: ExtensionContext): boolean {
1544
1661
  if (ctx.mode !== "tui" || !ctx.hasUI) return false;
1545
1662
  fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
1663
+ fleet.setWorkflowClient(workflowUiClient);
1546
1664
  return true;
1547
1665
  }
1548
1666
 
@@ -1559,6 +1677,7 @@ function activateRootRuntime(
1559
1677
  // session that has not run a tool yet. Re-seat from the live context so the
1560
1678
  // guard and the draw target agree; the setter is idempotent.
1561
1679
  fleet.setUICtx(currentCtx.ui as unknown as FleetUICtx);
1680
+ fleet.setWorkflowClient(workflowUiClient);
1562
1681
  // Arm the tickers after the draw, not before: both tear their own timer down
1563
1682
  // when they find nothing to show, and the fleet finds nothing until the child
1564
1683
  // session lands a moment later. A spawn that entered through the manager has
@@ -1649,16 +1768,17 @@ function activateRootRuntime(
1649
1768
  },
1650
1769
  };
1651
1770
 
1652
- const { state, callbacks } = createActivityTracker(undefined, () => {
1653
- fleet.update();
1654
- });
1771
+ // Allocate tracking only at a real fresh/reused-thread admission. An
1772
+ // idempotent request replay must not create another tracker at all.
1773
+ let tracker: ReturnType<typeof createActivityTracker> | undefined;
1774
+ const tracking = () => tracker ??= createActivityTracker(undefined, () => fleet.update());
1655
1775
  const outputTranscript =
1656
1776
  getAgentConfig(dispatch.type)?.outputTranscript ??
1657
1777
  getOutputTranscriptDefault();
1658
1778
  let id = "";
1659
1779
  const initializeRecord = (spawnedId: string): void => {
1660
1780
  id = spawnedId;
1661
- agentActivity.set(id, state);
1781
+ agentActivity.set(id, tracking().state);
1662
1782
  const record = manager.getRecordMutable(id);
1663
1783
  if (record && outputTranscript) {
1664
1784
  record.outputFile = createOutputFilePath(
@@ -1676,12 +1796,31 @@ function activateRootRuntime(
1676
1796
  fleet.ensureTimer();
1677
1797
  fleet.update();
1678
1798
  };
1679
- const onSessionCreated = callbacks.onSessionCreated;
1680
- const managedCallbacks = {
1681
- ...callbacks,
1799
+ const managedCallbacks: ManagedSpawnCallbacks = {
1800
+ onToolActivity: activity => tracker?.callbacks.onToolActivity(activity),
1801
+ onTextDelta: (delta, text) => tracker?.callbacks.onTextDelta(delta, text),
1802
+ onTurnEnd: count => tracker?.callbacks.onTurnEnd(count),
1803
+ onAssistantUsage: usage => tracker?.callbacks.onAssistantUsage(usage),
1682
1804
  onSpawned: initializeRecord,
1805
+ onResumeStarting: (record: AgentRecord, startMessageIndex: number) => {
1806
+ id = record.id;
1807
+ const { state } = tracking();
1808
+ state.session = record.session;
1809
+ state.toolUses = record.toolUses;
1810
+ state.lifetimeUsage = { ...record.lifetimeUsage };
1811
+ agentActivity.set(id, state);
1812
+ record.outputCleanup?.();
1813
+ record.outputCleanup = undefined;
1814
+ if (outputTranscript && record.session) {
1815
+ record.outputFile ??= createOutputFilePath(ctxRef.cwd, id, ctxRef.sessionManager.getSessionId());
1816
+ ensureOutputFile(record.outputFile);
1817
+ record.outputCleanup = streamToOutputFile(record.session, record.outputFile, id, ctxRef.cwd, startMessageIndex);
1818
+ }
1819
+ fleet.ensureTimer();
1820
+ fleet.update();
1821
+ },
1683
1822
  onSessionCreated: (session: AgentSession) => {
1684
- onSessionCreated(session);
1823
+ tracking().callbacks.onSessionCreated(session);
1685
1824
  const record = manager.getRecordMutable(id);
1686
1825
  if (record?.outputFile) {
1687
1826
  record.outputCleanup = streamToOutputFile(
@@ -1707,11 +1846,9 @@ function activateRootRuntime(
1707
1846
  // transcript, timer, or callback chain. The original Agent record owns all
1708
1847
  // first-class UI and streaming state.
1709
1848
  if (!managedResult.created) return managedResult;
1710
- if (!id) {
1711
- const record = manager.getRecordMutable(managedResult.id);
1712
- if (record?.status === "queued" || record?.status === "running")
1713
- initializeRecord(managedResult.id);
1714
- }
1849
+ // New records invoke onSpawned synchronously. Reused threads invoke only
1850
+ // onResumeStarting at actual admission (possibly after waiting for capacity).
1851
+ // Never fall through to the truncating new-record initializer on reentry.
1715
1852
  return managedResult;
1716
1853
  }
1717
1854
 
@@ -2050,7 +2187,7 @@ function activateRootRuntime(
2050
2187
  * prose working. A delivered mention costs no main-model turn; the answer
2051
2188
  * arrives through the ordinary completion notification either way.
2052
2189
  */
2053
- pi.on("input", async (event, ctx) => {
2190
+ pi.on("input", parentQuestions.observeHandledInput(async (event, ctx) => {
2054
2191
  // Never hijack text the extension layer itself submitted (pi.sendMessage,
2055
2192
  // scheduled prompts) — only something a person typed can be a mention.
2056
2193
  if (event.source === "extension" || !isAgentMentionsEnabled())
@@ -2289,7 +2426,7 @@ function activateRootRuntime(
2289
2426
  );
2290
2427
  }
2291
2428
  return { action: "handled" };
2292
- });
2429
+ }));
2293
2430
 
2294
2431
  // ---- Agent tool ----
2295
2432
 
@@ -2489,6 +2626,9 @@ Terse command-style prompts produce shallow, generic work.
2489
2626
  "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
2490
2627
  }),
2491
2628
  ),
2629
+ supervisor_questions: Type.Optional(Type.Union([Type.Literal("human"), Type.Literal("parent"), Type.Literal("off")], {
2630
+ description: 'Explicit question routing, default human. "parent" is ordinary clarification only (never permission/approval), supported only for fresh non-isolated top-level background calls. No schedules or mode-changing resumes.',
2631
+ })),
2492
2632
  resume: Type.Optional(
2493
2633
  Type.String({
2494
2634
  description:
@@ -2723,6 +2863,16 @@ Terse command-style prompts produce shallow, generic work.
2723
2863
  ...params,
2724
2864
  agentTiers: getAgentTiersSettings(),
2725
2865
  });
2866
+ if (params.supervisor_questions === "parent" &&
2867
+ (!resolvedConfig.runInBackground || resolvedConfig.isolated || params.schedule || params.resume || !isSupervisorQuestionsEnabled())) {
2868
+ throw new Error('supervisor_questions: "parent" requires a fresh non-isolated background Agent. Set run_in_background: true, omit schedule/resume, and enable Supervisor questions. Foreground calls cannot answer while blocked.');
2869
+ }
2870
+ if (params.schedule && params.supervisor_questions !== undefined) {
2871
+ throw new Error('Scheduled calls do not support per-call question routing; omit supervisor_questions.');
2872
+ }
2873
+ if (params.resume && params.supervisor_questions !== undefined) {
2874
+ throw new Error('Resume preserves the child question mode; omit supervisor_questions and use run_in_background: true for a parent-mode child.');
2875
+ }
2726
2876
  if (!params.resume && !params.schedule && resolvedConfig.inheritContext) {
2727
2877
  return textResult(INHERIT_CONTEXT_UNAVAILABLE);
2728
2878
  }
@@ -3041,6 +3191,7 @@ Terse command-style prompts produce shallow, generic work.
3041
3191
  : thinking,
3042
3192
  agentTier: resolvedConfig.requestedAgentTier,
3043
3193
  isBackground: true,
3194
+ supervisorQuestions: params.supervisor_questions === "parent" ? "parent" : params.supervisor_questions === "off" ? false : undefined,
3044
3195
  isolation,
3045
3196
  invocation: agentInvocation,
3046
3197
  rootSessionId: ctx.sessionManager.getSessionId(),
@@ -3120,8 +3271,12 @@ Terse command-style prompts produce shallow, generic work.
3120
3271
  let spinnerFrame = 0;
3121
3272
  const startedAt = Date.now();
3122
3273
  let fgId: string | undefined;
3274
+ let foregroundUpdates = true;
3275
+ let handedOff = false;
3123
3276
 
3124
3277
  const streamUpdate = () => {
3278
+ fleet.update();
3279
+ if (!foregroundUpdates) return;
3125
3280
  const details: AgentDetails = {
3126
3281
  ...detailBase,
3127
3282
  toolUses: fgState.toolUses,
@@ -3198,6 +3353,7 @@ Terse command-style prompts produce shallow, generic work.
3198
3353
  : thinking,
3199
3354
  agentTier: resolvedConfig.requestedAgentTier,
3200
3355
  isolation,
3356
+ supervisorQuestions: params.supervisor_questions === "off" ? false : undefined,
3201
3357
  invocation: agentInvocation,
3202
3358
  signal,
3203
3359
  rootSessionId: ctx.sessionManager.getSessionId(),
@@ -3206,21 +3362,30 @@ Terse command-style prompts produce shallow, generic work.
3206
3362
  (fgAgentId) => {
3207
3363
  // onSpawned: called synchronously after spawn, before onSessionCreated fires.
3208
3364
  // Set up the output file so streamToOutputFile can pick it up.
3365
+ fgId = fgAgentId;
3209
3366
  const fgRec = manager.getRecordMutable(fgAgentId);
3367
+ if (fgRec) fgRec.toolCallId = toolCallId;
3210
3368
  attachTranscript(fgRec, fgAgentId);
3211
3369
  },
3212
3370
  );
3213
3371
  record = fgResult.record;
3372
+ handedOff = fgResult.handedOff === true;
3214
3373
  } finally {
3215
- // Always stop the spinner and drop the row, including startup errors
3216
- // that now propagate to pi as failed tool calls.
3374
+ // A transfer ends this tool's spinner, not the child's activity or stream.
3375
+ foregroundUpdates = false;
3217
3376
  clearInterval(spinnerInterval);
3218
- if (fgId) {
3377
+ if (fgId && !handedOff) {
3219
3378
  agentActivity.delete(fgId);
3220
3379
  fleet.onAgentFinished(fgId);
3221
3380
  }
3222
3381
  }
3223
3382
 
3383
+ if (handedOff) {
3384
+ return textResult(`${fallbackNote}Agent continues in background.\nAgent ID: ${record.id}\n` +
3385
+ "You will be notified when it completes. Use get_subagent_result for results or steer_subagent to send messages.",
3386
+ buildDetails(detailBase, record, fgState, { status: "background" }));
3387
+ }
3388
+
3224
3389
  // Get final token count
3225
3390
  const tokenText = formatLifetimeTokens(fgState);
3226
3391
 
@@ -3517,6 +3682,8 @@ Terse command-style prompts produce shallow, generic work.
3517
3682
  }
3518
3683
 
3519
3684
  async function showAgentsMenu(ctx: ExtensionCommandContext) {
3685
+ const isCurrent = rootUiFence();
3686
+ if (!isCurrent()) return;
3520
3687
  reloadCustomAgents();
3521
3688
  const allNames = getAllTypes();
3522
3689
 
@@ -3564,31 +3731,31 @@ Terse command-style prompts produce shallow, generic work.
3564
3731
  }
3565
3732
 
3566
3733
  const choice = await ctx.ui.select("Agents", options);
3567
- if (!choice) return;
3734
+ if (!choice || !isCurrent()) return;
3568
3735
 
3569
3736
  if (choice.startsWith("Agent runs (")) {
3570
3737
  await showAgentRuns(ctx);
3571
- await showAgentsMenu(ctx);
3738
+ if (isCurrent()) await showAgentsMenu(ctx);
3572
3739
  } else if (choice.startsWith("Agent types (")) {
3573
3740
  await showAllAgentsList(ctx);
3574
- await showAgentsMenu(ctx);
3741
+ if (isCurrent()) await showAgentsMenu(ctx);
3575
3742
  } else if (choice.startsWith("Scheduled jobs (")) {
3576
3743
  await showSchedulesMenu(ctx, scheduler);
3577
- await showAgentsMenu(ctx);
3744
+ if (isCurrent()) await showAgentsMenu(ctx);
3578
3745
  } else if (choice.startsWith("Model tiers (")) {
3579
3746
  await showModelTiersMenu(ctx);
3580
- await showAgentsMenu(ctx);
3747
+ if (isCurrent()) await showAgentsMenu(ctx);
3581
3748
  } else if (choice === "Create new agent") {
3582
3749
  await showCreateWizard(ctx);
3583
3750
  } else if (choice === "Settings") {
3584
3751
  await showSettings(ctx);
3585
- await showAgentsMenu(ctx);
3752
+ if (isCurrent()) await showAgentsMenu(ctx);
3586
3753
  } else if (choice.startsWith("Usage (")) {
3587
3754
  showSessionUsage(ctx);
3588
- await showAgentsMenu(ctx);
3755
+ if (isCurrent()) await showAgentsMenu(ctx);
3589
3756
  } else if (choice === "Diagnostics") {
3590
3757
  await showDiagnostics(ctx);
3591
- await showAgentsMenu(ctx);
3758
+ if (isCurrent()) await showAgentsMenu(ctx);
3592
3759
  }
3593
3760
  }
3594
3761
 
@@ -3712,29 +3879,33 @@ Terse command-style prompts produce shallow, generic work.
3712
3879
  }
3713
3880
 
3714
3881
  async function showAgentRuns(ctx: ExtensionCommandContext) {
3715
- const agents = manager.listAgentsMutable().filter((a) => !a.parentAgentId);
3882
+ const isCurrent = rootUiFence();
3883
+ const agents = manager.listAgentsMutable().filter((a) => !a.parentAgentId && !a.detached)
3884
+ .map(record => captureAgentSelection(manager, record, isCurrent));
3716
3885
  if (agents.length === 0) {
3717
3886
  ctx.ui.notify("No agents.", "info");
3718
3887
  return;
3719
3888
  }
3720
3889
 
3721
- const record = await selectItem(ctx.ui, "Agent runs", agents, (agent) => {
3890
+ const selection = await selectItem(ctx.ui, "Agent runs", agents, ({ snapshot: agent }) => {
3722
3891
  const dn = getDisplayName(agent.type);
3723
3892
  const dur = formatDuration(agent.startedAt, agent.completedAt);
3724
3893
  return `${dn} (${sanitizeDisplayText(agent.description)}) · ${agent.toolUses} tools · ${getAgentStatusLabel(agent.status)} · ${dur}`;
3725
3894
  });
3726
- if (!record) return;
3895
+ if (!selection || !isCurrent() || !selection.current()) return;
3727
3896
 
3728
- await viewAgentConversation(ctx, record);
3729
- // Back-navigation: re-show the list
3730
- await showAgentRuns(ctx);
3897
+ await viewAgentConversation(ctx, selection);
3898
+ // Back-navigation stays on the same root/tree, never a replacement.
3899
+ if (isCurrent()) await showAgentRuns(ctx);
3731
3900
  }
3732
3901
 
3733
3902
  async function viewAgentConversation(
3734
3903
  ctx: ExtensionCommandContext,
3735
- record: AgentRecord,
3904
+ selection: AgentSelection,
3736
3905
  ) {
3737
- if (!record.session) {
3906
+ const record = selection.snapshot;
3907
+ if (!selection.current()) return;
3908
+ if (!selection.session) {
3738
3909
  if (record.status === "queued") {
3739
3910
  const handleHint = record.handle
3740
3911
  ? ` Use @${record.handle} <message> to chat while it waits.`
@@ -3743,7 +3914,7 @@ Terse command-style prompts produce shallow, generic work.
3743
3914
  "Queued agent",
3744
3915
  `"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
3745
3916
  );
3746
- if (stop && manager.abort(record.id)) {
3917
+ if (stop && selection.current() && manager.abort(record.id)) {
3747
3918
  ctx.ui.notify(
3748
3919
  `Stopped queued agent "${sanitizeDisplayText(record.description)}".`,
3749
3920
  "info",
@@ -3755,38 +3926,19 @@ Terse command-style prompts produce shallow, generic work.
3755
3926
  return;
3756
3927
  }
3757
3928
 
3758
- const session = record.session;
3759
- const activity = agentActivity.get(record.id);
3760
-
3761
- await wrapCustomUi(ctx.ui).custom<undefined>(
3929
+ seatFleet(ctx);
3930
+ const mode = fleet.conversationMode();
3931
+ // Fullscreen owns its entire native viewport; an extra shared border would
3932
+ // clip its controls. Floating conversations retain the normal treatment.
3933
+ const viewerUi = mode === "fullscreen" ? ctx.ui : wrapCustomUi(ctx.ui);
3934
+ await viewerUi.custom<undefined>(
3762
3935
  (tui, theme, keybindings, done) => {
3763
- return new ConversationViewer(
3764
- tui,
3765
- session,
3766
- record,
3767
- activity,
3768
- theme,
3769
- done,
3770
- () => {
3771
- if (manager.abort(record.id)) {
3772
- ctx.ui.notify(
3773
- `Stopped "${sanitizeDisplayText(record.description)}".`,
3774
- "info",
3775
- );
3776
- }
3777
- },
3778
- keybindings,
3779
- (message: string) => manager.steer(record.id, message),
3780
- () => manager.getRecord(record.id),
3781
- );
3936
+ if (!selection.current()) { done(undefined); return { render: () => [], invalidate: () => {} }; }
3937
+ return fleet.conversation(selection, tui, theme, keybindings, done, message => ctx.ui.notify(message, "info"), mode);
3782
3938
  },
3783
3939
  {
3784
3940
  overlay: true,
3785
- overlayOptions: {
3786
- anchor: "center",
3787
- width: "90%",
3788
- maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
3789
- },
3941
+ overlayOptions: getConversationOverlayOptions({ mode: mode === "fullscreen" ? "fullscreen" : undefined }),
3790
3942
  },
3791
3943
  );
3792
3944
  }
@@ -95,17 +95,32 @@ export function streamToOutputFile(
95
95
  cwd: string,
96
96
  startIndex?: number,
97
97
  ): () => void {
98
- // Index of the first message this stream is responsible for. A spawn writes
99
- // messages[0] as the initial prompt entry, so it starts at 1. A resume hands
100
- // in the session's length as of just before the run: the session already
101
- // holds every prior turn, and re-emitting those would duplicate history that
102
- // is already in the file.
103
- let writtenCount = startIndex ?? 1;
98
+ if (startIndex !== undefined && (!Number.isSafeInteger(startIndex) || startIndex < 0)) {
99
+ throw new RangeError("startIndex must be a nonnegative safe integer");
100
+ }
101
+ // A resume supplies the index past history already on disk. A fresh spawn
102
+ // has seeded its prompt separately, but Pi may add the actual user later or
103
+ // handle the input via a custom-message turn with no user at all. Dedup is
104
+ // independent of the cursor so legitimate pre-user records still stream.
105
+ let writtenCount = startIndex ?? 0;
106
+ let initialUserPending = startIndex === undefined;
107
+ let disposed = false;
104
108
 
105
109
  const flush = () => {
110
+ if (disposed) return;
106
111
  const messages = session.messages;
107
112
  while (writtenCount < messages.length) {
108
- const msg = messages[writtenCount];
113
+ const msg = messages[writtenCount++];
114
+ // Native prompt/tool-loadout records have no Claude Code transcript entry.
115
+ if (msg.role === "system") continue;
116
+ if (initialUserPending && msg.role === "user") {
117
+ initialUserPending = false;
118
+ continue;
119
+ }
120
+ // An actual assistant response proves a turn ran without the seeded user.
121
+ // Its later follow-up must be kept, even if both reach the same flush.
122
+ // Custom guidance alone is not that evidence: the user can still arrive.
123
+ if (msg.role === "assistant") initialUserPending = false;
109
124
  const entry = {
110
125
  isSidechain: true,
111
126
  agentId,
@@ -117,11 +132,11 @@ export function streamToOutputFile(
117
132
  try {
118
133
  appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
119
134
  } catch { /* ignore write errors */ }
120
- writtenCount++;
121
135
  }
122
136
  };
123
137
 
124
138
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
139
+ if (disposed) return;
125
140
  if (event.type === "turn_end") flush();
126
141
  // Compaction replaces session.messages with a shorter, summarized array,
127
142
  // leaving writtenCount past the new end — without re-anchoring, the flush
@@ -135,12 +150,16 @@ export function streamToOutputFile(
135
150
  // session.messages untouched, so only successful ones re-anchor.
136
151
  if (event.type === "compaction_start") flush();
137
152
  if (event.type === "compaction_end" && !event.aborted && event.result) {
138
- queueMicrotask(() => { writtenCount = session.messages.length; });
153
+ queueMicrotask(() => {
154
+ if (!disposed) writtenCount = session.messages.length;
155
+ });
139
156
  }
140
157
  });
141
158
 
142
159
  return () => {
160
+ if (disposed) return;
143
161
  flush();
162
+ disposed = true;
144
163
  unsubscribe();
145
164
  };
146
165
  }