@signalridge/pi-subagents 1.12.0 → 1.14.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,
@@ -213,11 +215,8 @@ import {
213
215
  type Theme,
214
216
  } from "./ui/agent-display.js";
215
217
  import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
216
- import {
217
- ConversationViewer,
218
- VIEWPORT_HEIGHT_PCT,
219
- } from "./ui/conversation-viewer.js";
220
- 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";
221
220
  import {
222
221
  safeTerminalText,
223
222
  sanitizeDisplayText,
@@ -230,6 +229,7 @@ import {
230
229
  getAgentStatusLabel,
231
230
  getAgentStatusMark,
232
231
  } from "./ui/status-label.js";
232
+ import { WorkflowUiClient } from "./ui/workflow-ui-client.js";
233
233
  import {
234
234
  addUsage,
235
235
  getLifetimeTotal,
@@ -811,6 +811,12 @@ function activateRootRuntime(
811
811
  // tokens is omitted when nothing was ever produced (e.g. agent errored before
812
812
  // any message_end fired), preserving prior payload shape.
813
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;
814
820
  const total = getLifetimeTotal(u);
815
821
  const tokens =
816
822
  total > 0 ? { input: u.input, output: u.output, total } : undefined;
@@ -824,6 +830,7 @@ function activateRootRuntime(
824
830
  toolUses: record.toolUses,
825
831
  durationMs,
826
832
  tokens,
833
+ ...(managedTokens ? { managedTokens: { ...managedTokens } } : {}),
827
834
  ...(record.outputFile ? { outputFile: record.outputFile } : {}),
828
835
  ...(record.invocation?.agentTier === undefined
829
836
  ? {}
@@ -919,13 +926,25 @@ function activateRootRuntime(
919
926
  string,
920
927
  { runs: number; usage: LifetimeUsage }
921
928
  >();
929
+ const accountedUsage = new WeakMap<AgentRecord, { usage: LifetimeUsage; completion: string; promise: AgentRecord["promise"] }>();
922
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 });
923
942
  const entry = sessionUsageByType.get(record.type) ?? {
924
943
  runs: 0,
925
944
  usage: { input: 0, output: 0, cacheWrite: 0 },
926
945
  };
927
946
  entry.runs++;
928
- addUsage(entry.usage, record.lifetimeUsage);
947
+ addUsage(entry.usage, usage);
929
948
  sessionUsageByType.set(record.type, entry);
930
949
  }
931
950
 
@@ -935,6 +954,14 @@ function activateRootRuntime(
935
954
  // Nested children report only through their owning parent's scoped tools.
936
955
  // Keep them out of top-level lifecycle, transcript, notification, and UI channels.
937
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
+ }
938
965
 
939
966
  // Emit lifecycle event based on terminal status
940
967
  const isError =
@@ -1058,6 +1085,10 @@ function activateRootRuntime(
1058
1085
  append: (tombstone) =>
1059
1086
  pi.appendEntry(MANAGED_SPAWN_ENTRY_TYPE, tombstone),
1060
1087
  },
1088
+ (record) => {
1089
+ record.joinMode = "async";
1090
+ refreshAgentSurfaces();
1091
+ },
1061
1092
  );
1062
1093
 
1063
1094
  // Expose the validated root manager via Symbol.for() for cross-package access.
@@ -1283,6 +1314,7 @@ function activateRootRuntime(
1283
1314
  ): Promise<void> {
1284
1315
  // A successful session_start is the commit point for a reused runtime. A
1285
1316
  // cancelled session_before_switch leaves the old context and scheduler live.
1317
+ retireFleetUi();
1286
1318
  if (currentCtx) {
1287
1319
  scheduler.stop();
1288
1320
  manager.detachForBranchChange();
@@ -1402,6 +1434,7 @@ function activateRootRuntime(
1402
1434
  }
1403
1435
  if (ownsRpcRegistry && isSchedulingEnabled() && !scheduler.isActive())
1404
1436
  startScheduler(ctx);
1437
+ bindFleetUi();
1405
1438
  }
1406
1439
 
1407
1440
  // Both before-events are cancellable. A later handler may veto, and tree
@@ -1413,6 +1446,7 @@ function activateRootRuntime(
1413
1446
  const detachCommittedTree = (event: unknown): void => {
1414
1447
  if (!currentCtx || detachedTreeEvent === event) return;
1415
1448
  detachedTreeEvent = event;
1449
+ retireFleetUi();
1416
1450
  parentQuestions.retire();
1417
1451
  manager.detachForBranchChange();
1418
1452
  };
@@ -1446,6 +1480,7 @@ function activateRootRuntime(
1446
1480
  manager.clearCompleted(true);
1447
1481
  for (const tombstone of recovered) persistRecoveredManaged(tombstone);
1448
1482
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
1483
+ bindFleetUi();
1449
1484
  });
1450
1485
 
1451
1486
  // Shutdown is idempotent and also serves same-session hot-reload handoff. The
@@ -1473,6 +1508,7 @@ function activateRootRuntime(
1473
1508
  });
1474
1509
  scheduler.stop();
1475
1510
  parentQuestions.retire();
1511
+ retireFleetUi();
1476
1512
  currentCtx = undefined; // reject new spawns; owned cleanup RPC remains bound
1477
1513
  if (pending.length > 0) {
1478
1514
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -1549,6 +1585,60 @@ function activateRootRuntime(
1549
1585
  // used to carry the same rows, with the same summary repeated again in the
1550
1586
  // footer status; one surface, below the prompt, replaces all three.
1551
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
+
1552
1642
  let fleetViewEnabled = true;
1553
1643
  function isFleetViewEnabled(): boolean {
1554
1644
  return fleetViewEnabled;
@@ -1570,6 +1660,7 @@ function activateRootRuntime(
1570
1660
  function seatFleet(ctx: ExtensionContext): boolean {
1571
1661
  if (ctx.mode !== "tui" || !ctx.hasUI) return false;
1572
1662
  fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
1663
+ fleet.setWorkflowClient(workflowUiClient);
1573
1664
  return true;
1574
1665
  }
1575
1666
 
@@ -1586,6 +1677,7 @@ function activateRootRuntime(
1586
1677
  // session that has not run a tool yet. Re-seat from the live context so the
1587
1678
  // guard and the draw target agree; the setter is idempotent.
1588
1679
  fleet.setUICtx(currentCtx.ui as unknown as FleetUICtx);
1680
+ fleet.setWorkflowClient(workflowUiClient);
1589
1681
  // Arm the tickers after the draw, not before: both tear their own timer down
1590
1682
  // when they find nothing to show, and the fleet finds nothing until the child
1591
1683
  // session lands a moment later. A spawn that entered through the manager has
@@ -1676,16 +1768,17 @@ function activateRootRuntime(
1676
1768
  },
1677
1769
  };
1678
1770
 
1679
- const { state, callbacks } = createActivityTracker(undefined, () => {
1680
- fleet.update();
1681
- });
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());
1682
1775
  const outputTranscript =
1683
1776
  getAgentConfig(dispatch.type)?.outputTranscript ??
1684
1777
  getOutputTranscriptDefault();
1685
1778
  let id = "";
1686
1779
  const initializeRecord = (spawnedId: string): void => {
1687
1780
  id = spawnedId;
1688
- agentActivity.set(id, state);
1781
+ agentActivity.set(id, tracking().state);
1689
1782
  const record = manager.getRecordMutable(id);
1690
1783
  if (record && outputTranscript) {
1691
1784
  record.outputFile = createOutputFilePath(
@@ -1703,12 +1796,31 @@ function activateRootRuntime(
1703
1796
  fleet.ensureTimer();
1704
1797
  fleet.update();
1705
1798
  };
1706
- const onSessionCreated = callbacks.onSessionCreated;
1707
- const managedCallbacks = {
1708
- ...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),
1709
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
+ },
1710
1822
  onSessionCreated: (session: AgentSession) => {
1711
- onSessionCreated(session);
1823
+ tracking().callbacks.onSessionCreated(session);
1712
1824
  const record = manager.getRecordMutable(id);
1713
1825
  if (record?.outputFile) {
1714
1826
  record.outputCleanup = streamToOutputFile(
@@ -1734,11 +1846,9 @@ function activateRootRuntime(
1734
1846
  // transcript, timer, or callback chain. The original Agent record owns all
1735
1847
  // first-class UI and streaming state.
1736
1848
  if (!managedResult.created) return managedResult;
1737
- if (!id) {
1738
- const record = manager.getRecordMutable(managedResult.id);
1739
- if (record?.status === "queued" || record?.status === "running")
1740
- initializeRecord(managedResult.id);
1741
- }
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.
1742
1852
  return managedResult;
1743
1853
  }
1744
1854
 
@@ -3161,8 +3271,12 @@ Terse command-style prompts produce shallow, generic work.
3161
3271
  let spinnerFrame = 0;
3162
3272
  const startedAt = Date.now();
3163
3273
  let fgId: string | undefined;
3274
+ let foregroundUpdates = true;
3275
+ let handedOff = false;
3164
3276
 
3165
3277
  const streamUpdate = () => {
3278
+ fleet.update();
3279
+ if (!foregroundUpdates) return;
3166
3280
  const details: AgentDetails = {
3167
3281
  ...detailBase,
3168
3282
  toolUses: fgState.toolUses,
@@ -3248,21 +3362,30 @@ Terse command-style prompts produce shallow, generic work.
3248
3362
  (fgAgentId) => {
3249
3363
  // onSpawned: called synchronously after spawn, before onSessionCreated fires.
3250
3364
  // Set up the output file so streamToOutputFile can pick it up.
3365
+ fgId = fgAgentId;
3251
3366
  const fgRec = manager.getRecordMutable(fgAgentId);
3367
+ if (fgRec) fgRec.toolCallId = toolCallId;
3252
3368
  attachTranscript(fgRec, fgAgentId);
3253
3369
  },
3254
3370
  );
3255
3371
  record = fgResult.record;
3372
+ handedOff = fgResult.handedOff === true;
3256
3373
  } finally {
3257
- // Always stop the spinner and drop the row, including startup errors
3258
- // 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;
3259
3376
  clearInterval(spinnerInterval);
3260
- if (fgId) {
3377
+ if (fgId && !handedOff) {
3261
3378
  agentActivity.delete(fgId);
3262
3379
  fleet.onAgentFinished(fgId);
3263
3380
  }
3264
3381
  }
3265
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
+
3266
3389
  // Get final token count
3267
3390
  const tokenText = formatLifetimeTokens(fgState);
3268
3391
 
@@ -3559,6 +3682,8 @@ Terse command-style prompts produce shallow, generic work.
3559
3682
  }
3560
3683
 
3561
3684
  async function showAgentsMenu(ctx: ExtensionCommandContext) {
3685
+ const isCurrent = rootUiFence();
3686
+ if (!isCurrent()) return;
3562
3687
  reloadCustomAgents();
3563
3688
  const allNames = getAllTypes();
3564
3689
 
@@ -3606,31 +3731,31 @@ Terse command-style prompts produce shallow, generic work.
3606
3731
  }
3607
3732
 
3608
3733
  const choice = await ctx.ui.select("Agents", options);
3609
- if (!choice) return;
3734
+ if (!choice || !isCurrent()) return;
3610
3735
 
3611
3736
  if (choice.startsWith("Agent runs (")) {
3612
3737
  await showAgentRuns(ctx);
3613
- await showAgentsMenu(ctx);
3738
+ if (isCurrent()) await showAgentsMenu(ctx);
3614
3739
  } else if (choice.startsWith("Agent types (")) {
3615
3740
  await showAllAgentsList(ctx);
3616
- await showAgentsMenu(ctx);
3741
+ if (isCurrent()) await showAgentsMenu(ctx);
3617
3742
  } else if (choice.startsWith("Scheduled jobs (")) {
3618
3743
  await showSchedulesMenu(ctx, scheduler);
3619
- await showAgentsMenu(ctx);
3744
+ if (isCurrent()) await showAgentsMenu(ctx);
3620
3745
  } else if (choice.startsWith("Model tiers (")) {
3621
3746
  await showModelTiersMenu(ctx);
3622
- await showAgentsMenu(ctx);
3747
+ if (isCurrent()) await showAgentsMenu(ctx);
3623
3748
  } else if (choice === "Create new agent") {
3624
3749
  await showCreateWizard(ctx);
3625
3750
  } else if (choice === "Settings") {
3626
3751
  await showSettings(ctx);
3627
- await showAgentsMenu(ctx);
3752
+ if (isCurrent()) await showAgentsMenu(ctx);
3628
3753
  } else if (choice.startsWith("Usage (")) {
3629
3754
  showSessionUsage(ctx);
3630
- await showAgentsMenu(ctx);
3755
+ if (isCurrent()) await showAgentsMenu(ctx);
3631
3756
  } else if (choice === "Diagnostics") {
3632
3757
  await showDiagnostics(ctx);
3633
- await showAgentsMenu(ctx);
3758
+ if (isCurrent()) await showAgentsMenu(ctx);
3634
3759
  }
3635
3760
  }
3636
3761
 
@@ -3754,29 +3879,33 @@ Terse command-style prompts produce shallow, generic work.
3754
3879
  }
3755
3880
 
3756
3881
  async function showAgentRuns(ctx: ExtensionCommandContext) {
3757
- 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));
3758
3885
  if (agents.length === 0) {
3759
3886
  ctx.ui.notify("No agents.", "info");
3760
3887
  return;
3761
3888
  }
3762
3889
 
3763
- const record = await selectItem(ctx.ui, "Agent runs", agents, (agent) => {
3890
+ const selection = await selectItem(ctx.ui, "Agent runs", agents, ({ snapshot: agent }) => {
3764
3891
  const dn = getDisplayName(agent.type);
3765
3892
  const dur = formatDuration(agent.startedAt, agent.completedAt);
3766
3893
  return `${dn} (${sanitizeDisplayText(agent.description)}) · ${agent.toolUses} tools · ${getAgentStatusLabel(agent.status)} · ${dur}`;
3767
3894
  });
3768
- if (!record) return;
3895
+ if (!selection || !isCurrent() || !selection.current()) return;
3769
3896
 
3770
- await viewAgentConversation(ctx, record);
3771
- // Back-navigation: re-show the list
3772
- 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);
3773
3900
  }
3774
3901
 
3775
3902
  async function viewAgentConversation(
3776
3903
  ctx: ExtensionCommandContext,
3777
- record: AgentRecord,
3904
+ selection: AgentSelection,
3778
3905
  ) {
3779
- if (!record.session) {
3906
+ const record = selection.snapshot;
3907
+ if (!selection.current()) return;
3908
+ if (!selection.session) {
3780
3909
  if (record.status === "queued") {
3781
3910
  const handleHint = record.handle
3782
3911
  ? ` Use @${record.handle} <message> to chat while it waits.`
@@ -3785,7 +3914,7 @@ Terse command-style prompts produce shallow, generic work.
3785
3914
  "Queued agent",
3786
3915
  `"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
3787
3916
  );
3788
- if (stop && manager.abort(record.id)) {
3917
+ if (stop && selection.current() && manager.abort(record.id)) {
3789
3918
  ctx.ui.notify(
3790
3919
  `Stopped queued agent "${sanitizeDisplayText(record.description)}".`,
3791
3920
  "info",
@@ -3797,38 +3926,19 @@ Terse command-style prompts produce shallow, generic work.
3797
3926
  return;
3798
3927
  }
3799
3928
 
3800
- const session = record.session;
3801
- const activity = agentActivity.get(record.id);
3802
-
3803
- 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>(
3804
3935
  (tui, theme, keybindings, done) => {
3805
- return new ConversationViewer(
3806
- tui,
3807
- session,
3808
- record,
3809
- activity,
3810
- theme,
3811
- done,
3812
- () => {
3813
- if (manager.abort(record.id)) {
3814
- ctx.ui.notify(
3815
- `Stopped "${sanitizeDisplayText(record.description)}".`,
3816
- "info",
3817
- );
3818
- }
3819
- },
3820
- keybindings,
3821
- (message: string) => manager.steer(record.id, message),
3822
- () => manager.getRecord(record.id),
3823
- );
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);
3824
3938
  },
3825
3939
  {
3826
3940
  overlay: true,
3827
- overlayOptions: {
3828
- anchor: "center",
3829
- width: "90%",
3830
- maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
3831
- },
3941
+ overlayOptions: getConversationOverlayOptions({ mode: mode === "fullscreen" ? "fullscreen" : undefined }),
3832
3942
  },
3833
3943
  );
3834
3944
  }
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { defineTool, type ExtensionContext, type ExtensionToolContext, type SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { type ApprovalService, createApprovalToolDefinitions } from "@signalridge/pi-approval-core";
4
+ import { captureQuestionOrigin } from "./question-origin.js";
5
+
6
+ /** Human service capture and native context leases live for the entire child session. */
7
+ export function createChildApprovalTools(options: {
8
+ service: ApprovalService;
9
+ assertServiceCurrent: () => void;
10
+ root: ExtensionContext;
11
+ rootOrigin: (current: ExtensionContext) => boolean;
12
+ agentId?: string;
13
+ settings: SettingsManager;
14
+ waiting: (toolCallId: string) => () => void;
15
+ isBound: () => boolean;
16
+ isToolCurrent: (name: string) => boolean;
17
+ }): ToolDefinition[] {
18
+ const leases = new WeakMap<ExtensionToolContext, () => boolean>();
19
+ const [edit, write, bash] = createApprovalToolDefinitions({
20
+ source: `child:${options.agentId ?? randomUUID()}`,
21
+ shellPath: options.settings.getShellPath(),
22
+ commandPrefix: options.settings.getShellCommandPrefix(),
23
+ isToolCurrent: (_ctx, name) => options.isToolCurrent(name),
24
+ isCurrent: (ctx) => {
25
+ let lease = leases.get(ctx);
26
+ if (!lease) {
27
+ const childOrigin = captureQuestionOrigin(ctx);
28
+ const invocationRoot = captureQuestionOrigin(options.root);
29
+ lease = () => {
30
+ if (ctx.signal?.aborted || !options.isBound() || !options.rootOrigin(options.root) ||
31
+ !invocationRoot(options.root) || !childOrigin(ctx)) return false;
32
+ options.assertServiceCurrent();
33
+ return true;
34
+ };
35
+ leases.set(ctx, lease);
36
+ }
37
+ return lease();
38
+ },
39
+ request: async (operation, owner) => {
40
+ const resume = options.waiting(operation.toolCallId);
41
+ try {
42
+ await options.service.request(operation, owner);
43
+ } finally {
44
+ resume();
45
+ }
46
+ },
47
+ });
48
+ return [defineTool(edit), defineTool(write), defineTool(bash)];
49
+ }
@@ -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
  }
package/src/types.ts CHANGED
@@ -180,8 +180,9 @@ export interface AgentRecord {
180
180
  /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
181
181
  compactionCount: number;
182
182
  /**
183
- * Whether this agent was spawned to run in the background. Tri-state, set at
184
- * spawn from `SpawnOptions.isBackground`: `true` = background, `false` =
183
+ * Whether this agent currently runs in the background. Tri-state, initially
184
+ * set from `SpawnOptions.isBackground`; a foreground handoff sets it to true.
185
+ * `true` = background, `false` =
185
186
  * foreground (has an inline Agent tool-result surface), `undefined` = the
186
187
  * caller never declared it (e.g. a cross-extension RPC spawn, which is detached
187
188
  * and has no inline surface). The widget's background-only filter keys off this
@@ -190,7 +191,7 @@ export interface AgentRecord {
190
191
  * which only the Agent-tool path populates.
191
192
  */
192
193
  isBackground?: boolean;
193
- /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
194
+ /** Resolved spawn params for UI display; handoff updates runInBackground. */
194
195
  invocation?: AgentInvocation;
195
196
  /** Nesting depth: top-level subagent = 1. */
196
197
  depth?: number;