@ian-pascoe/pi-minimal-subagents 0.6.6 → 0.7.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/README.md CHANGED
@@ -29,7 +29,7 @@ filter in `~/.pi/agent/settings.json` using the repository-relative path:
29
29
  ```
30
30
 
31
31
  From this package checkout, load the source directly with
32
- `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=0.84.1`.
32
+ `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=0.85.1`.
33
33
 
34
34
  ## Configuration
35
35
 
@@ -251,14 +251,38 @@ source file; it never substitutes the source session's newer head.
251
251
 
252
252
  ## Status and TUI
253
253
 
254
- In TUI mode, `/subagents status` opens a live read-only hierarchy. Rows begin
255
- collapsed; use Up/Down to select, Enter to expand Recent Activity, the configured
256
- tool-expansion key to reveal tool output, page keys to scroll, and Escape to
257
- close. Expanded activity uses Pi's transcript components, updates once per
258
- second, remains bounded, and omits images. The header reports the effective
259
- access source, authored settings, direct running/idle counts, and actual
260
- Coordinator Tool activation. Partial external activation is reported as
261
- `N/6 active`; status does not repair it.
254
+ In TUI mode, `/subagents` or `/subagents status` opens a large, centered,
255
+ framed overlay. Up/Down selects a Child Agent; Enter opens its Child Session
256
+ Transcript. Escape returns to the tree, then Escape closes the overlay. The
257
+ viewer is read-only: Root Agent input and ongoing agent work remain intact.
258
+
259
+ Two consecutive Left Arrow presses within 500 ms open the same viewer when
260
+ the main editor is focused and completely empty. Drafts (including whitespace),
261
+ dialogs, and other overlays retain normal navigation. Explicit key-repeat
262
+ reports are ignored; legacy terminals cannot distinguish holding Left from
263
+ two presses.
264
+
265
+ The viewer and compact widget prioritize sibling subtrees containing running
266
+ Child Agents. Idle ancestors move with active descendants, parents stay above
267
+ their children, and equally active siblings retain their original order. Viewer
268
+ selection follows the Child Agent's identity through live reordering.
269
+
270
+ The transcript includes inherited context, earlier turns, pre-compaction
271
+ messages, and live output on the selected saved branch. Abandoned branches and
272
+ internal bookkeeping are excluded. Saved history can be inspected without
273
+ restoring a runtime; missing or unverified sessions report an explanation rather
274
+ than substituting another branch. Model-facing Recent Activity remains bounded.
275
+
276
+ Transcripts open at the latest output and refresh once per second. Up/Down
277
+ scrolls by line, Page Up/Page Down by page. Scrolling up pauses following; End
278
+ returns to live output. Reading position is retained through resize and tool
279
+ expansion. Reasoning is visible, tool output starts collapsed, and Pi's configured
280
+ tool-expansion key (normally Ctrl+O) reveals it. Images appear as explicit text
281
+ placeholders rather than inline images.
282
+
283
+ The tree header reports the effective access source, authored settings, direct
284
+ running/idle counts, and actual Coordinator Tool activation. Partial external
285
+ activation is reported as `N/6 active`; status does not repair it.
262
286
 
263
287
  RPC mode receives a concise status notification. JSON and print modes produce
264
288
  no observer-only output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -8,8 +8,6 @@ import type {
8
8
  } from "./minimal-subagents-types.js";
9
9
 
10
10
  const RECENT_AGENT_ACTIVITY_LIMIT = 12;
11
- const CHILD_AGENT_TRANSCRIPT_MESSAGE_LIMIT = 24;
12
- const CHILD_AGENT_TRANSCRIPT_PAIR_WINDOW = CHILD_AGENT_TRANSCRIPT_MESSAGE_LIMIT * 2;
13
11
  const RECENT_AGENT_ACTIVITY_MAX_LINES = 20;
14
12
  const RECENT_AGENT_ACTIVITY_MAX_BYTES = 2 * 1024;
15
13
 
@@ -47,110 +45,45 @@ function visibleMessageContent(content: string | readonly (TextContent | ImageCo
47
45
  return contentText(content, "\n\n") || "(no text content)";
48
46
  }
49
47
 
50
- function omitAgentMessageImages(message: AgentMessage): AgentMessage {
48
+ function replaceAgentMessageImages(message: AgentMessage): AgentMessage {
51
49
  if (message.role === "user" || message.role === "custom") {
52
50
  return {
53
51
  ...structuredClone(message),
54
52
  content: Array.isArray(message.content)
55
- ? message.content
56
- .filter((content) => content.type !== "image")
57
- .map((content) => structuredClone(content))
53
+ ? message.content.map((content) =>
54
+ content.type === "image"
55
+ ? { type: "text" as const, text: `[Image: ${content.mimeType}]` }
56
+ : structuredClone(content),
57
+ )
58
58
  : message.content,
59
59
  };
60
60
  }
61
61
  if (message.role === "toolResult") {
62
62
  return {
63
63
  ...structuredClone(message),
64
- content: message.content
65
- .filter((content) => content.type !== "image")
66
- .map((content) => structuredClone(content)),
64
+ content: message.content.map((content) =>
65
+ content.type === "image"
66
+ ? { type: "text" as const, text: `[Image: ${content.mimeType}]` }
67
+ : structuredClone(content),
68
+ ),
67
69
  };
68
70
  }
69
71
  return structuredClone(message);
70
72
  }
71
73
 
72
- interface IndexedTranscriptMessage {
73
- message: AgentMessage;
74
- originalIndex: number;
75
- streaming: boolean;
76
- }
77
-
78
- /** Select at most 48 recent raw messages, retaining cross-cutoff tool pairs and omitting images. */
74
+ /** Clone the complete visible UI transcript, replacing images without bounding conversation history. */
79
75
  export function selectChildAgentTranscript(
80
76
  messages: readonly AgentMessage[],
81
77
  streamingAssistantMessage?: AgentMessage,
82
78
  ): ChildAgentTranscriptSnapshot {
83
- const pairWindow = messages.slice(-CHILD_AGENT_TRANSCRIPT_PAIR_WINDOW);
84
- const firstWindowIndex = messages.length - pairWindow.length;
85
- const indexed: IndexedTranscriptMessage[] = [
86
- ...pairWindow.map((message, windowIndex) => ({
87
- message,
88
- originalIndex: firstWindowIndex + windowIndex,
89
- streaming: false,
90
- })),
91
- ...(streamingAssistantMessage
92
- ? [
93
- {
94
- message: streamingAssistantMessage,
95
- originalIndex: messages.length,
96
- streaming: true,
97
- },
98
- ]
99
- : []),
100
- ];
101
- const tail = indexed.slice(-CHILD_AGENT_TRANSCRIPT_MESSAGE_LIMIT).map((item) => ({
102
- ...item,
103
- message: omitAgentMessageImages(item.message),
104
- }));
105
- const selectedIndexes = new Set(tail.map(({ originalIndex }) => originalIndex));
106
- const calls = new Map<
107
- string,
108
- { originalIndex: number; assistant: Extract<AgentMessage, { role: "assistant" }> }
109
- >();
110
- for (const item of indexed) {
111
- if (item.message.role !== "assistant") continue;
112
- for (const content of item.message.content) {
113
- if (content.type === "toolCall") {
114
- calls.set(content.id, { originalIndex: item.originalIndex, assistant: item.message });
115
- }
116
- }
117
- }
118
-
119
- const retained = tail.filter(
120
- (item) => item.message.role !== "toolResult" || calls.has(item.message.toolCallId),
121
- );
122
- const missingCalls = new Map<
123
- number,
124
- { assistant: Extract<AgentMessage, { role: "assistant" }>; callIds: Set<string> }
125
- >();
126
- for (const item of retained) {
127
- if (item.message.role !== "toolResult") continue;
128
- const call = calls.get(item.message.toolCallId);
129
- if (!call || selectedIndexes.has(call.originalIndex)) continue;
130
- const missing = missingCalls.get(call.originalIndex) ?? {
131
- assistant: call.assistant,
132
- callIds: new Set<string>(),
133
- };
134
- missing.callIds.add(item.message.toolCallId);
135
- missingCalls.set(call.originalIndex, missing);
136
- }
137
- const prefixes: IndexedTranscriptMessage[] = [...missingCalls.entries()]
138
- .sort(([left], [right]) => left - right)
139
- .map(([originalIndex, missing]) => ({
140
- message: {
141
- ...structuredClone(missing.assistant),
142
- content: missing.assistant.content
143
- .filter((content) => content.type === "toolCall" && missing.callIds.has(content.id))
144
- .map((content) => structuredClone(content)),
145
- },
146
- originalIndex,
147
- streaming: false,
148
- }));
149
- const selected = [...prefixes, ...retained];
150
- const streamingAssistantIndex = selected.findIndex(({ streaming }) => streaming);
79
+ const visible = messages.filter((message) => message.role !== "custom" || message.display);
80
+ const streaming = streamingAssistantMessage && !messages.includes(streamingAssistantMessage);
151
81
  return {
152
- messages: selected.map(({ message }) => message),
153
- streamingAssistantIndex: streamingAssistantIndex >= 0 ? streamingAssistantIndex : undefined,
82
+ messages: [
83
+ ...visible.map(replaceAgentMessageImages),
84
+ ...(streaming ? [replaceAgentMessageImages(streamingAssistantMessage)] : []),
85
+ ],
86
+ streamingAssistantIndex: streaming ? visible.length : undefined,
154
87
  toolDefinitions: [],
155
88
  };
156
89
  }
@@ -452,14 +452,22 @@ export class MinimalSubagentsCoordinator {
452
452
  };
453
453
  }
454
454
 
455
- /** Lazily inspect one Child Agent's bounded process-local transcript for trusted UI. */
455
+ /** Lazily inspect one Child Session Transcript without restoring a missing runtime. */
456
456
  inspectTranscript(agentId: string): ChildAgentTranscriptSnapshot {
457
457
  const agent = this.requireAgent(agentId);
458
458
  const runtime = this.runtimes.get(agentId);
459
459
  const liveSnapshot = runtime?.snapshotActivityTranscript?.();
460
460
  if (liveSnapshot) return liveSnapshot;
461
461
  if (runtime) return selectChildAgentTranscript(runtime.snapshotActivityMessages());
462
+ let historyError: string | undefined;
463
+ try {
464
+ const saved = this.dependencies.sessions.readTranscript?.(agent);
465
+ if (saved) return saved;
466
+ } catch (error) {
467
+ historyError = error instanceof Error ? error.message : String(error);
468
+ }
462
469
  const fallback =
470
+ historyError ||
463
471
  agent.unavailable_reason ||
464
472
  agent.latest_result?.error ||
465
473
  agent.latest_result?.output ||
@@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import {
4
4
  buildSessionContext,
5
+ CustomEditor,
5
6
  getAgentDir,
6
7
  SessionManager,
7
8
  SettingsManager,
@@ -9,6 +10,7 @@ import {
9
10
  type ExtensionCommandContext,
10
11
  type ExtensionContext,
11
12
  type ExtensionFactory,
13
+ type ExtensionUIContext,
12
14
  type MessageEndEvent,
13
15
  type SessionBeforeForkEvent,
14
16
  type SessionEntry,
@@ -16,6 +18,7 @@ import {
16
18
  type SessionStartEvent,
17
19
  type SessionTreeEvent,
18
20
  } from "@earendil-works/pi-coding-agent";
21
+ import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
19
22
  import {
20
23
  createSubagentAccessBranchRecord,
21
24
  reconcileCoordinatorToolAccess,
@@ -393,6 +396,80 @@ const productionLifecycleEffects: MinimalSubagentsLifecycleEffects = {
393
396
  createSessionFactory: (options) => new PiAgentSessionFactory(options),
394
397
  };
395
398
 
399
+ /** Compose a focus-local key sequence without taking over the editor's other behavior. */
400
+ function installViewerShortcut(ui: ExtensionUIContext, open: () => void) {
401
+ const previous = ui.getEditorComponent();
402
+ let active = true;
403
+ let previousLeftAt: number | undefined;
404
+ const reset = () => {
405
+ previousLeftAt = undefined;
406
+ };
407
+ const factory: NonNullable<ReturnType<ExtensionUIContext["getEditorComponent"]>> = (
408
+ tui,
409
+ theme,
410
+ keybindings,
411
+ ) => {
412
+ reset();
413
+ const editor =
414
+ previous?.(tui, theme, keybindings) ??
415
+ new CustomEditor(tui, theme, keybindings, { embedWorkingStatus: true });
416
+ let focused = false;
417
+ const handleInput = (data: string) => {
418
+ if (isKeyRelease(data)) {
419
+ editor.handleInput(data);
420
+ return;
421
+ }
422
+ if (
423
+ active &&
424
+ focused &&
425
+ editor.getText() === "" &&
426
+ matchesKey(data, "left") &&
427
+ !isKeyRepeat(data)
428
+ ) {
429
+ const now = performance.now();
430
+ if (previousLeftAt !== undefined && now - previousLeftAt <= 500) {
431
+ reset();
432
+ open();
433
+ return;
434
+ }
435
+ previousLeftAt = now;
436
+ } else {
437
+ reset();
438
+ }
439
+ editor.handleInput(data);
440
+ };
441
+ // Forward the editor's complete interface, including app handlers and custom methods.
442
+ // Binding to the original instance also preserves private fields in custom editors.
443
+ return new Proxy(editor, {
444
+ has: (target, property) => property === "focused" || Reflect.has(target, property),
445
+ get(target, property) {
446
+ if (property === "focused") return focused;
447
+ if (property === "handleInput") return handleInput;
448
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- SAFETY: Forward the SDK editor's open interface, including other extensions' methods, without replacing their receiver.
449
+ const value = Reflect.get(target, property, target);
450
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: Proxy forwarding binds callable members; this is not parsing external input.
451
+ return typeof value === "function" ? value.bind(target) : value;
452
+ },
453
+ set(target, property, value) {
454
+ if (property === "focused") {
455
+ focused = value;
456
+ if (!focused) reset();
457
+ }
458
+ return Reflect.set(target, property, value, target);
459
+ },
460
+ });
461
+ };
462
+ ui.setEditorComponent(factory);
463
+ return {
464
+ reset,
465
+ dispose() {
466
+ active = false;
467
+ reset();
468
+ if (ui.getEditorComponent() === factory) ui.setEditorComponent(previous);
469
+ },
470
+ };
471
+ }
472
+
396
473
  /** Own coordinator, UI, and prepared-fork state for one root Pi session lifecycle. */
397
474
  export class MinimalSubagentsLifecycleController {
398
475
  private coordinator: MinimalSubagentsCoordinator | undefined;
@@ -407,6 +484,7 @@ export class MinimalSubagentsLifecycleController {
407
484
  };
408
485
  private uiController: MinimalSubagentsUiController | undefined;
409
486
  private statusPanelController: MinimalSubagentsStatusPanelController | undefined;
487
+ private viewerShortcut: ReturnType<typeof installViewerShortcut> | undefined;
410
488
  private accessSession: ActiveSubagentAccessSession | undefined;
411
489
  private preparedFork:
412
490
  | { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
@@ -619,6 +697,14 @@ export class MinimalSubagentsLifecycleController {
619
697
  () => this.currentSubagentStatusAccess(),
620
698
  );
621
699
 
700
+ this.viewerShortcut?.dispose();
701
+ this.viewerShortcut =
702
+ context.mode === "tui"
703
+ ? installViewerShortcut(context.ui, () => {
704
+ void this.statusPanelController?.open();
705
+ })
706
+ : undefined;
707
+
622
708
  if (hasHistoricalChildIdentity(context.sessionManager.getBranch())) {
623
709
  context.ui.notify(
624
710
  "Opened a former subagent session directly. It is now an independent root; former descendants and parent messaging were not restored. Concurrent ownership by its original root is unsupported.",
@@ -666,6 +752,7 @@ export class MinimalSubagentsLifecycleController {
666
752
  _event: SessionTreeEvent,
667
753
  context: ExtensionContext,
668
754
  ): Promise<void> {
755
+ this.viewerShortcut?.reset();
669
756
  if (!this.coordinator) return;
670
757
  const snapshot = replayRegistryEntries(
671
758
  context.sessionManager.getBranch(),
@@ -805,6 +892,8 @@ export class MinimalSubagentsLifecycleController {
805
892
  event: SessionShutdownEvent,
806
893
  context: ExtensionContext,
807
894
  ): Promise<void> {
895
+ this.viewerShortcut?.dispose();
896
+ this.viewerShortcut = undefined;
808
897
  this.statusPanelController?.dispose();
809
898
  this.statusPanelController = undefined;
810
899
  if (this.coordinator) {
@@ -39,6 +39,7 @@ import {
39
39
  type WaitRenderDetails,
40
40
  } from "./minimal-subagents-render-contract.js";
41
41
  import { stripCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
42
+ import type { AgentSummary } from "./minimal-subagents-types.js";
42
43
 
43
44
  export type { CoordinatorToolName } from "./minimal-subagents-render-contract.js";
44
45
 
@@ -98,6 +99,23 @@ function toolResultText(result: AgentToolResult<unknown>): string {
98
99
  return text?.type === "text" ? text.text : "";
99
100
  }
100
101
 
102
+ /** Copy the hierarchy with active subtrees first, preserving sibling ties and ancestry. */
103
+ export function orderActiveAgentSubtrees(agents: readonly AgentSummary[]): AgentSummary[] {
104
+ const orderSiblings = (
105
+ siblings: readonly AgentSummary[],
106
+ ): { agent: AgentSummary; active: boolean }[] =>
107
+ siblings
108
+ .map((agent) => {
109
+ const children = orderSiblings(agent.children);
110
+ return {
111
+ agent: { ...agent, children: children.map((child) => child.agent) },
112
+ active: agent.state === "running" || children.some((child) => child.active),
113
+ };
114
+ })
115
+ .sort((left, right) => Number(right.active) - Number(left.active));
116
+ return orderSiblings(agents).map(({ agent }) => agent);
117
+ }
118
+
101
119
  /** Shared unavailable → running → latest-turn → idle status ladder for one subagent. */
102
120
  export function subagentStatusLadder(agent: {
103
121
  readonly availability?: string;
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -12,6 +12,7 @@ import {
12
12
  findCutPoint,
13
13
  generateSummaryWithUsage,
14
14
  ModelRuntime,
15
+ parseSessionEntries,
15
16
  SessionManager,
16
17
  SettingsManager,
17
18
  sessionEntryToContextMessages,
@@ -362,6 +363,9 @@ export function verifyChildSessionIdentity(
362
363
  `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
363
364
  );
364
365
  }
366
+ if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) {
367
+ throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`);
368
+ }
365
369
  const identityBranch = sessionManager.getBranch(agent.session_leaf_id);
366
370
  const generation = findLatestForkGeneration(identityBranch);
367
371
  const identity =
@@ -401,9 +405,6 @@ export function verifyChildSessionIdentity(
401
405
  );
402
406
  }
403
407
  }
404
- if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) {
405
- throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`);
406
- }
407
408
  }
408
409
 
409
410
  /** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
@@ -532,6 +533,10 @@ export async function captureChildTurnOutcome(
532
533
  class PiChildAgentRuntime implements ChildAgentRuntime {
533
534
  private aborted = false;
534
535
  private readonly unsubscribe: () => void;
536
+ private transcriptLeafId: string | null | undefined;
537
+ private readonly transcriptEntries = new WeakMap<SessionEntry, AgentMessage[]>();
538
+ private transcriptMessages: AgentMessage[] = [];
539
+ private transcriptSources = new Set<AgentMessage>();
535
540
 
536
541
  constructor(
537
542
  private readonly session: AgentSession,
@@ -629,10 +634,46 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
629
634
  }
630
635
 
631
636
  snapshotActivityTranscript(): ChildAgentTranscriptSnapshot {
632
- const snapshot = selectChildAgentTranscript(
633
- this.session.messages,
634
- this.session.state.streamingMessage,
637
+ const manager = this.session.sessionManager;
638
+ const leafId = manager.getLeafId();
639
+ if (this.transcriptLeafId !== leafId) {
640
+ this.transcriptSources = new Set();
641
+ this.transcriptMessages = manager.getBranch().flatMap((entry) => {
642
+ const source = sessionEntryToContextMessages(entry);
643
+ for (const message of source) this.transcriptSources.add(message);
644
+ let messages = this.transcriptEntries.get(entry);
645
+ if (!messages) {
646
+ messages = selectChildAgentTranscript(source).messages;
647
+ this.transcriptEntries.set(entry, messages);
648
+ }
649
+ return messages;
650
+ });
651
+ this.transcriptLeafId = leafId;
652
+ }
653
+ const state = this.session.state;
654
+ // Native message_end finalizes agent state before async extension handlers persist it.
655
+ const pending = state.messages.filter(
656
+ (message) =>
657
+ (message.role === "user" ||
658
+ message.role === "assistant" ||
659
+ message.role === "toolResult") &&
660
+ !this.transcriptSources.has(message),
661
+ );
662
+ const streaming = state.streamingMessage;
663
+ const tail = selectChildAgentTranscript(
664
+ pending,
665
+ streaming && !this.transcriptSources.has(streaming) ? streaming : undefined,
635
666
  );
667
+ const snapshot: ChildAgentTranscriptSnapshot = {
668
+ messages: tail.messages.length
669
+ ? [...this.transcriptMessages, ...tail.messages]
670
+ : this.transcriptMessages,
671
+ streamingAssistantIndex:
672
+ tail.streamingAssistantIndex === undefined
673
+ ? undefined
674
+ : this.transcriptMessages.length + tail.streamingAssistantIndex,
675
+ toolDefinitions: [],
676
+ };
636
677
  const toolNames = new Set<string>();
637
678
  for (const message of snapshot.messages) {
638
679
  if (message.role !== "assistant") continue;
@@ -740,6 +781,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
740
781
  private readonly availableToolNames: Set<string>;
741
782
  private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
742
783
  private readonly sessionFileTrash: SessionFileTrashCapability;
784
+ private savedTranscript?: { key: string; snapshot: ChildAgentTranscriptSnapshot };
743
785
 
744
786
  constructor(private readonly options: PiAgentSessionFactoryOptions) {
745
787
  this.modelById = new Map(
@@ -763,6 +805,40 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
763
805
  });
764
806
  }
765
807
 
808
+ /** Read one verified saved Child Session Position without opening a writable runtime. */
809
+ readTranscript(agent: PersistedAgent): ChildAgentTranscriptSnapshot {
810
+ if (!agent.session_file || !agent.session_id || !agent.session_leaf_id) {
811
+ throw new Error(`Child Session Position is unavailable for ${agent.agent_id}.`);
812
+ }
813
+ const sessionFile = canonicalPath(agent.session_file);
814
+ const stat = statSync(sessionFile);
815
+ const key = JSON.stringify([
816
+ sessionFile,
817
+ agent.agent_id,
818
+ agent.parent_id,
819
+ agent.created_at,
820
+ agent.session_id,
821
+ agent.session_leaf_id,
822
+ stat.dev,
823
+ stat.ino,
824
+ stat.size,
825
+ stat.mtimeMs,
826
+ stat.ctimeMs,
827
+ ]);
828
+ if (this.savedTranscript?.key === key) return this.savedTranscript.snapshot;
829
+ const entries = parseSessionEntries(readFileSync(sessionFile, "utf8"));
830
+ if (entries[0]?.type !== "session")
831
+ throw new Error(`Invalid child session file: ${sessionFile}`);
832
+ // SessionManager.open can migrate/rewrite files; an in-memory reader cannot write them.
833
+ const manager = SessionManager.inMemory(this.options.cwd, undefined, entries);
834
+ verifyChildSessionIdentity(manager, agent, this.options.rootSessionId);
835
+ const snapshot = selectChildAgentTranscript(
836
+ manager.getBranch(agent.session_leaf_id).flatMap(sessionEntryToContextMessages),
837
+ );
838
+ this.savedTranscript = { key, snapshot };
839
+ return snapshot;
840
+ }
841
+
766
842
  resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]> {
767
843
  return this.findMissingDependencies(agent, false);
768
844
  }
@@ -1,3 +1,5 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
1
3
  import { contentText } from "@earendil-works/pi-ai";
2
4
  import {
3
5
  AssistantMessageComponent,
@@ -12,10 +14,20 @@ import {
12
14
  type Theme,
13
15
  type TruncationResult,
14
16
  } from "@earendil-works/pi-coding-agent";
15
- import { Container, Text, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
17
+ import {
18
+ Container,
19
+ Text,
20
+ matchesKey,
21
+ truncateToWidth,
22
+ visibleWidth,
23
+ type Component,
24
+ type OverlayHandle,
25
+ type TUI,
26
+ } from "@earendil-works/pi-tui";
16
27
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
17
28
  import {
18
29
  formatSubagentDuration,
30
+ orderActiveAgentSubtrees,
19
31
  renderMinimalSubagentsMessage,
20
32
  renderMinimalSubagentsResult,
21
33
  subagentStatusLadder,
@@ -29,8 +41,7 @@ import type {
29
41
  } from "./minimal-subagents-types.js";
30
42
 
31
43
  const STATUS_PANEL_REFRESH_MS = 1_000;
32
- const STATUS_PANEL_FIXED_LINE_COUNT = 7;
33
- const STATUS_PANEL_MIN_VIEWPORT_LINES = 4;
44
+ const STATUS_PANEL_MARGIN = 1;
34
45
  const COORDINATOR_TOOL_COUNT = COORDINATOR_TOOL_NAMES.length;
35
46
 
36
47
  type StartStatusPanelRefresh = (refresh: () => void) => () => void;
@@ -58,7 +69,7 @@ function flattenStatusAgents(status: HierarchyStatusResult): FlattenedStatusAgen
58
69
  for (const child of agent.children) visit(child, depth + 1);
59
70
  };
60
71
  const roots = "agents" in status ? status.agents : [status.agent];
61
- for (const agent of roots) visit(agent, 0);
72
+ for (const agent of orderActiveAgentSubtrees(roots)) visit(agent, 0);
62
73
  return flattened;
63
74
  }
64
75
 
@@ -79,25 +90,107 @@ function statusAccessSourceLabel(source: SubagentAccessSnapshot["source"]): stri
79
90
  }
80
91
  }
81
92
 
93
+ interface CachedTranscriptMessage {
94
+ container: Container;
95
+ tools: Map<string, ToolExecutionComponent>;
96
+ expanded: boolean;
97
+ streaming: boolean;
98
+ }
99
+
100
+ interface TranscriptLayout {
101
+ lines: string[];
102
+ messageStarts: number[];
103
+ }
104
+
105
+ function transcriptText(line: string): string {
106
+ return stripVTControlCharacters(line).replace(/\s/g, "");
107
+ }
108
+
109
+ function anchoredTranscriptOffset(
110
+ previous: TranscriptLayout,
111
+ next: TranscriptLayout,
112
+ offset: number,
113
+ ): number {
114
+ const index = previous.messageStarts.findLastIndex((start) => start <= offset);
115
+ const previousStart = previous.messageStarts[index] ?? 0;
116
+ const nextStart = next.messageStarts[index] ?? 0;
117
+ const oldLines = previous.lines
118
+ .slice(previousStart, previous.messageStarts[index + 1])
119
+ .map(transcriptText);
120
+ const newLines = next.lines.slice(nextStart, next.messageStarts[index + 1]).map(transcriptText);
121
+ const row = offset - previousStart;
122
+ if (oldLines.slice(0, row + 1).every((line, lineIndex) => line === newLines[lineIndex])) {
123
+ return nextStart + row;
124
+ }
125
+ const text = oldLines[row];
126
+ if (text) {
127
+ const occurrence = oldLines.slice(0, row).filter((line) => line === text).length;
128
+ let seen = 0;
129
+ const match = newLines.findIndex((line) => line === text && seen++ === occurrence);
130
+ if (match >= 0) return nextStart + match;
131
+ }
132
+ let characters = oldLines.slice(0, row).reduce((total, line) => total + line.length, 0);
133
+ for (const [lineIndex, line] of newLines.entries()) {
134
+ if (characters < line.length) return nextStart + lineIndex;
135
+ characters -= line.length;
136
+ }
137
+ return nextStart + Math.max(0, newLines.length - 1);
138
+ }
139
+
140
+ interface TranscriptRenderCache {
141
+ messages: WeakMap<AgentMessage, CachedTranscriptMessage>;
142
+ results: WeakMap<ToolExecutionComponent, AgentMessage>;
143
+ }
144
+
82
145
  function renderTranscriptSnapshot(
83
146
  snapshot: ChildAgentTranscriptSnapshot,
84
147
  tui: TUI,
85
148
  cwd: string,
86
149
  expanded: boolean,
87
150
  width: number,
88
- ): string[] {
151
+ cache: TranscriptRenderCache,
152
+ ): TranscriptLayout {
89
153
  if (snapshot.messages.length === 0) {
90
- return snapshot.fallback
91
- ? new Text(snapshot.fallback, 3, 0).render(width)
92
- : new Text("No Recent Activity", 3, 0).render(width);
154
+ return {
155
+ lines: new Text(snapshot.fallback || "No conversation messages yet.", 3, 0).render(width),
156
+ messageStarts: [0],
157
+ };
93
158
  }
94
- const container = new Container();
159
+ const blocks: Container[] = [];
95
160
  const tools = new Map(
96
161
  snapshot.toolDefinitions.map((definition) => [definition.name, definition]),
97
162
  );
98
163
  const pendingTools = new Map<string, ToolExecutionComponent>();
164
+ const currentMessages = new Set(snapshot.messages);
99
165
 
100
166
  for (const [messageIndex, message] of snapshot.messages.entries()) {
167
+ if (message.role === "toolResult") {
168
+ const paired = pendingTools.get(message.toolCallId);
169
+ if (paired) {
170
+ if (cache.results.get(paired) !== message) {
171
+ paired.updateResult(message);
172
+ cache.results.set(paired, message);
173
+ }
174
+ pendingTools.delete(message.toolCallId);
175
+ blocks.push(new Container());
176
+ continue;
177
+ }
178
+ }
179
+ const streaming = messageIndex === snapshot.streamingAssistantIndex;
180
+ const cached = cache.messages.get(message);
181
+ const staleResult =
182
+ cached &&
183
+ [...cached.tools.values()].some((tool) => {
184
+ const result = cache.results.get(tool);
185
+ return result !== undefined && !currentMessages.has(result);
186
+ });
187
+ if (cached && !staleResult && cached.expanded === expanded && cached.streaming === streaming) {
188
+ blocks.push(cached.container);
189
+ for (const [id, tool] of cached.tools) pendingTools.set(id, tool);
190
+ continue;
191
+ }
192
+ const container = new Container();
193
+ const messageTools = new Map<string, ToolExecutionComponent>();
101
194
  switch (message.role) {
102
195
  case "user": {
103
196
  const text = contentText(message.content, "\n\n");
@@ -115,7 +208,7 @@ function renderTranscriptSnapshot(
115
208
  content.id,
116
209
  content.arguments,
117
210
  { showImages: false },
118
- tools.get(content.name),
211
+ tools.get(content.name) ?? {},
119
212
  tui,
120
213
  cwd,
121
214
  );
@@ -136,16 +229,24 @@ function renderTranscriptSnapshot(
136
229
  });
137
230
  } else {
138
231
  pendingTools.set(content.id, tool);
232
+ messageTools.set(content.id, tool);
139
233
  }
140
234
  }
141
235
  break;
142
236
  }
143
237
  case "toolResult": {
144
- const tool = pendingTools.get(message.toolCallId);
145
- if (tool) {
146
- tool.updateResult(message);
147
- pendingTools.delete(message.toolCallId);
148
- }
238
+ const inherited = new ToolExecutionComponent(
239
+ message.toolName,
240
+ message.toolCallId,
241
+ {},
242
+ { showImages: false },
243
+ {},
244
+ tui,
245
+ cwd,
246
+ );
247
+ inherited.setExpanded(expanded);
248
+ inherited.updateResult(message);
249
+ container.addChild(inherited);
149
250
  break;
150
251
  }
151
252
  case "custom": {
@@ -188,8 +289,26 @@ function renderTranscriptSnapshot(
188
289
  break;
189
290
  }
190
291
  }
292
+ cache.messages.set(message, { container, tools: messageTools, expanded, streaming });
293
+ blocks.push(container);
191
294
  }
192
- return container.render(width);
295
+ let length = 0;
296
+ const messageStarts: number[] = [];
297
+ const lines = blocks.flatMap((block) => {
298
+ messageStarts.push(length);
299
+ // Native user-message prompt zones belong to the main terminal, not an embedded overlay.
300
+ const rendered = block
301
+ .render(width)
302
+ .map((line) =>
303
+ line
304
+ .replaceAll("\x1b]133;A\x07", "")
305
+ .replaceAll("\x1b]133;B\x07", "")
306
+ .replaceAll("\x1b]133;C\x07", ""),
307
+ );
308
+ length += rendered.length;
309
+ return rendered;
310
+ });
311
+ return { lines, messageStarts };
193
312
  }
194
313
 
195
314
  /** Interactive, read-only Child Agent hierarchy and transcript status component. */
@@ -198,9 +317,18 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
198
317
  private access!: MinimalSubagentsStatusAccess;
199
318
  private flattened: FlattenedStatusAgent[] = [];
200
319
  private selectedAgentId?: string;
201
- private readonly expandedAgentIds = new Set<string>();
202
- private readonly transcripts = new Map<string, ChildAgentTranscriptSnapshot>();
320
+ private view: "tree" | "transcript" = "tree";
321
+ private transcript?: ChildAgentTranscriptSnapshot;
322
+ private notice = "";
203
323
  private scrollOffset = 0;
324
+ private following = true;
325
+ private transcriptLineCount = 0;
326
+ private transcriptLayout?: TranscriptLayout;
327
+ private readonly transcriptCache: TranscriptRenderCache = {
328
+ messages: new WeakMap(),
329
+ results: new WeakMap(),
330
+ };
331
+ private bodyHeight = 1;
204
332
  private ensureSelectionVisible = true;
205
333
  private toolOutputExpanded = false;
206
334
  private disposed = false;
@@ -231,7 +359,14 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
231
359
  /** Handle read-only hierarchy navigation and close keys. */
232
360
  handleInput(data: string): void {
233
361
  if (this.keybindings.matches(data, "tui.select.cancel")) {
234
- this.close();
362
+ if (this.view === "tree") this.close();
363
+ else {
364
+ this.view = "tree";
365
+ this.transcript = undefined;
366
+ this.scrollOffset = 0;
367
+ this.ensureSelectionVisible = true;
368
+ this.tui.requestRender();
369
+ }
235
370
  return;
236
371
  }
237
372
  if (this.keybindings.matches(data, "tui.select.up")) {
@@ -239,68 +374,108 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
239
374
  } else if (this.keybindings.matches(data, "tui.select.down")) {
240
375
  this.moveSelection(1);
241
376
  } else if (this.keybindings.matches(data, "tui.select.confirm")) {
242
- this.toggleSelectedAgent();
377
+ this.openSelectedTranscript();
243
378
  } else if (this.keybindings.matches(data, "app.tools.expand")) {
244
379
  this.toolOutputExpanded = !this.toolOutputExpanded;
380
+ } else if (this.view === "transcript" && matchesKey(data, "end")) {
381
+ this.following = true;
245
382
  } else if (this.keybindings.matches(data, "tui.select.pageUp")) {
246
- this.scrollOffset = Math.max(0, this.scrollOffset - this.viewportHeight());
247
- this.ensureSelectionVisible = false;
383
+ this.scroll(-this.viewportHeight());
248
384
  } else if (this.keybindings.matches(data, "tui.select.pageDown")) {
249
- this.scrollOffset += this.viewportHeight();
250
- this.ensureSelectionVisible = false;
385
+ this.scroll(this.viewportHeight());
251
386
  } else {
252
387
  return;
253
388
  }
254
389
  this.tui.requestRender();
255
390
  }
256
391
 
257
- /** Render the fixed access header and scrollable Child Agent hierarchy. */
392
+ /** Render one framed, terminal-bounded tree or Child Session Transcript. */
258
393
  render(width: number): string[] {
259
394
  if (width <= 0) return [];
260
- const header = this.renderHeader(width);
261
- const rowStarts = new Map<string, number>();
262
- const body: string[] = [];
263
- for (const { agent, depth } of this.flattened) {
264
- rowStarts.set(agent.agent_id, body.length);
265
- body.push(this.renderAgentRow(agent, depth, width));
266
- if (!this.expandedAgentIds.has(agent.agent_id)) continue;
267
- const transcript = this.transcripts.get(agent.agent_id);
268
- if (transcript) {
269
- body.push(
270
- ...renderTranscriptSnapshot(
271
- transcript,
272
- this.tui,
273
- this.cwd,
274
- this.toolOutputExpanded,
275
- width,
276
- ),
395
+ const height = Math.max(
396
+ 1,
397
+ Math.min(
398
+ Math.floor(this.tui.terminal.rows * 0.9),
399
+ this.tui.terminal.rows - 2 * STATUS_PANEL_MARGIN,
400
+ ),
401
+ );
402
+ if (width < 6 || height < 5) {
403
+ return new Text("Esc back · Enlarge terminal", 0, 0).render(width).slice(0, height);
404
+ }
405
+ const innerWidth = width - 4;
406
+ const selected = this.flattened.find(({ agent }) => agent.agent_id === this.selectedAgentId);
407
+ const transcriptView = this.view === "transcript" && this.transcript;
408
+ const header = transcriptView
409
+ ? [
410
+ this.theme.bold(`Transcript · ${this.selectedAgentId}`),
411
+ selected ? this.renderAgentRow(selected.agent, 0, innerWidth) : "",
412
+ ]
413
+ : this.renderHeader(innerWidth);
414
+ const toolKey = this.keybindings.getKeys("app.tools.expand").join("/");
415
+ const helpText = transcriptView
416
+ ? `Esc tree · End live · ${toolKey} tools · ↑↓/PgUp/PgDn scroll · ${this.following ? "Following" : "Paused"}`
417
+ : "Esc close · Enter transcript · ↑↓ select · PgUp/PgDn page";
418
+ const help = new Text(this.theme.fg("text", helpText), 0, 0)
419
+ .render(innerWidth)
420
+ .slice(0, Math.min(2, height - 4));
421
+ const visibleHeader = header.slice(0, Math.max(1, height - help.length - 3));
422
+ this.bodyHeight = Math.max(1, height - 2 - visibleHeader.length - help.length);
423
+ let body: string[];
424
+ if (transcriptView) {
425
+ const layout = renderTranscriptSnapshot(
426
+ transcriptView,
427
+ this.tui,
428
+ this.cwd,
429
+ this.toolOutputExpanded,
430
+ innerWidth,
431
+ this.transcriptCache,
432
+ );
433
+ if (!this.following && this.transcriptLayout) {
434
+ this.scrollOffset = anchoredTranscriptOffset(
435
+ this.transcriptLayout,
436
+ layout,
437
+ this.scrollOffset,
277
438
  );
278
439
  }
279
- }
280
- const viewportHeight = this.viewportHeight();
281
- const selectedLine = this.selectedAgentId ? rowStarts.get(this.selectedAgentId) : undefined;
282
- if (this.ensureSelectionVisible && selectedLine !== undefined) {
283
- if (selectedLine < this.scrollOffset) this.scrollOffset = selectedLine;
284
- if (selectedLine >= this.scrollOffset + viewportHeight) {
285
- this.scrollOffset = selectedLine - viewportHeight + 1;
440
+ this.transcriptLayout = layout;
441
+ body = layout.lines;
442
+ this.transcriptLineCount = body.length;
443
+ const maximum = Math.max(0, body.length - this.bodyHeight);
444
+ this.scrollOffset = this.following ? maximum : Math.min(this.scrollOffset, maximum);
445
+ } else {
446
+ body = this.flattened.map(({ agent, depth }) =>
447
+ this.renderAgentRow(agent, depth, innerWidth),
448
+ );
449
+ if (body.length === 0) body.push("No Child Agents yet.");
450
+ const selectedLine = this.flattened.findIndex(
451
+ ({ agent }) => agent.agent_id === this.selectedAgentId,
452
+ );
453
+ if (this.ensureSelectionVisible && selectedLine >= 0) {
454
+ if (selectedLine < this.scrollOffset) this.scrollOffset = selectedLine;
455
+ if (selectedLine >= this.scrollOffset + this.bodyHeight)
456
+ this.scrollOffset = selectedLine - this.bodyHeight + 1;
286
457
  }
458
+ this.ensureSelectionVisible = false;
459
+ this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, body.length - this.bodyHeight));
287
460
  }
288
- this.ensureSelectionVisible = false;
289
- this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, body.length - viewportHeight));
290
- const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + viewportHeight);
291
- const help = truncateToWidth(
292
- this.theme.fg(
293
- "dim",
294
- "↑↓ select Enter Recent Activity configured tool key expands output PgUp/PgDn scroll Esc close",
295
- ),
296
- width,
297
- "…",
298
- );
299
- return [...header, ...visibleBody, help];
461
+ const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyHeight);
462
+ while (visibleBody.length < this.bodyHeight) visibleBody.push("");
463
+ const border = (text: string) => this.theme.fg("border", text);
464
+ const rows = [...visibleHeader, ...visibleBody, ...help].map((line) => {
465
+ const content = truncateToWidth(line, innerWidth, "…");
466
+ return this.theme.bg(
467
+ "customMessageBg",
468
+ `${border("│")} ${content}${" ".repeat(innerWidth - visibleWidth(content))} ${border("│")}`,
469
+ );
470
+ });
471
+ return [border(`╭${"─".repeat(width - 2)}╮`), ...rows, border(`╰${"─".repeat(width - 2)}╯`)];
300
472
  }
301
473
 
302
- /** Invalidate no cached layout because each render derives the current snapshot. */
303
- invalidate(): void {}
474
+ /** Rebuild native transcript components when their theme changes. */
475
+ invalidate(): void {
476
+ this.transcriptCache.messages = new WeakMap();
477
+ this.transcriptCache.results = new WeakMap();
478
+ }
304
479
 
305
480
  /** Release the live refresh owner idempotently. */
306
481
  dispose(): void {
@@ -315,15 +490,16 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
315
490
  this.flattened = flattenStatusAgents(this.status);
316
491
  const liveIds = new Set(this.flattened.map(({ agent }) => agent.agent_id));
317
492
  if (!this.selectedAgentId || !liveIds.has(this.selectedAgentId)) {
493
+ if (this.view === "transcript") {
494
+ this.notice = `${this.selectedAgentId} is no longer available.`;
495
+ this.view = "tree";
496
+ this.transcript = undefined;
497
+ }
498
+ this.ensureSelectionVisible = true;
318
499
  this.selectedAgentId = this.flattened[0]?.agent.agent_id;
319
500
  }
320
- for (const agentId of this.expandedAgentIds) {
321
- if (!liveIds.has(agentId)) {
322
- this.expandedAgentIds.delete(agentId);
323
- this.transcripts.delete(agentId);
324
- continue;
325
- }
326
- this.refreshAgentTranscript(agentId);
501
+ if (this.view === "transcript" && this.selectedAgentId) {
502
+ this.refreshAgentTranscript(this.selectedAgentId);
327
503
  }
328
504
  }
329
505
 
@@ -355,13 +531,13 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
355
531
  ),
356
532
  truncateToWidth(`Coordinator Tools: ${toolState}`, width, "…"),
357
533
  truncateToWidth(`Direct Children: ${running} running · ${idle} idle`, width, "…"),
358
- "",
534
+ this.theme.fg("warning", this.notice),
359
535
  ];
360
536
  }
361
537
 
362
538
  private renderAgentRow(agent: AgentSummary, depth: number, width: number): string {
363
539
  const selected = agent.agent_id === this.selectedAgentId;
364
- const disclosure = this.expandedAgentIds.has(agent.agent_id) ? "▾" : "▸";
540
+ const disclosure = "▸";
365
541
  const status = subagentStatusLadder(agent);
366
542
  const elapsed = formatSubagentDuration(agent.elapsed_ms);
367
543
  const task = agent.task?.replace(/\s+/g, " ").trim();
@@ -371,7 +547,21 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
371
547
  return truncateToWidth(selected ? this.theme.fg("accent", line) : line, width, "…");
372
548
  }
373
549
 
550
+ private scroll(delta: number): void {
551
+ this.scrollOffset = Math.max(0, this.scrollOffset + delta);
552
+ this.ensureSelectionVisible = false;
553
+ if (this.view === "transcript") {
554
+ const maximum = Math.max(0, this.transcriptLineCount - this.viewportHeight());
555
+ this.scrollOffset = Math.min(this.scrollOffset, maximum);
556
+ this.following = this.scrollOffset === maximum;
557
+ }
558
+ }
559
+
374
560
  private moveSelection(delta: number): void {
561
+ if (this.view === "transcript") {
562
+ this.scroll(delta);
563
+ return;
564
+ }
375
565
  if (this.flattened.length === 0) return;
376
566
  const current = this.flattened.findIndex(
377
567
  ({ agent }) => agent.agent_id === this.selectedAgentId,
@@ -381,34 +571,30 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
381
571
  this.ensureSelectionVisible = true;
382
572
  }
383
573
 
384
- private toggleSelectedAgent(): void {
385
- const agentId = this.selectedAgentId;
386
- if (!agentId) return;
387
- if (this.expandedAgentIds.delete(agentId)) {
388
- this.transcripts.delete(agentId);
389
- return;
390
- }
391
- this.expandedAgentIds.add(agentId);
392
- this.refreshAgentTranscript(agentId);
574
+ private openSelectedTranscript(): void {
575
+ if (this.view === "transcript" || !this.selectedAgentId) return;
576
+ this.view = "transcript";
577
+ this.notice = "";
578
+ this.following = true;
579
+ this.scrollOffset = 0;
580
+ this.toolOutputExpanded = false;
581
+ this.refreshAgentTranscript(this.selectedAgentId);
393
582
  }
394
583
 
395
584
  private refreshAgentTranscript(agentId: string): void {
396
585
  try {
397
- this.transcripts.set(agentId, this.coordinator.inspectTranscript(agentId));
586
+ this.transcript = this.coordinator.inspectTranscript(agentId);
398
587
  } catch (error) {
399
- this.transcripts.set(agentId, {
588
+ this.transcript = {
400
589
  messages: [],
401
590
  toolDefinitions: [],
402
591
  fallback: error instanceof Error ? error.message : String(error),
403
- });
592
+ };
404
593
  }
405
594
  }
406
595
 
407
596
  private viewportHeight(): number {
408
- return Math.max(
409
- STATUS_PANEL_MIN_VIEWPORT_LINES,
410
- this.tui.terminal.rows - STATUS_PANEL_FIXED_LINE_COUNT,
411
- );
597
+ return this.bodyHeight;
412
598
  }
413
599
 
414
600
  /** Settle the custom view and release its refresh timer exactly once. */
@@ -423,6 +609,7 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
423
609
  export class MinimalSubagentsStatusPanelController {
424
610
  private activePanel?: MinimalSubagentsStatusPanelComponent;
425
611
  private activePromise?: Promise<void>;
612
+ private overlayHandle?: OverlayHandle;
426
613
 
427
614
  /** Bind the panel owner to one Root Agent session and refresh lifecycle. */
428
615
  constructor(
@@ -434,7 +621,10 @@ export class MinimalSubagentsStatusPanelController {
434
621
 
435
622
  /** Open or focus the single live view; RPC receives a notification and structured modes stay silent. */
436
623
  open(): Promise<void> {
437
- if (this.activePromise) return this.activePromise;
624
+ if (this.activePromise) {
625
+ this.overlayHandle?.focus();
626
+ return this.activePromise;
627
+ }
438
628
  if (this.context.mode === "rpc") {
439
629
  const status = this.coordinator.inspectStatus();
440
630
  const direct = "agents" in status ? status.agents : [status.agent];
@@ -449,20 +639,34 @@ export class MinimalSubagentsStatusPanelController {
449
639
  if (this.context.mode !== "tui") return Promise.resolve();
450
640
 
451
641
  const promise = this.context.ui
452
- .custom<void>((tui, theme, keybindings, done) => {
453
- const panel = new MinimalSubagentsStatusPanelComponent(
454
- this.coordinator,
455
- this.getAccess,
456
- tui,
457
- theme,
458
- keybindings,
459
- this.context.cwd,
460
- () => done(undefined),
461
- this.startRefresh,
462
- );
463
- this.activePanel = panel;
464
- return panel;
465
- })
642
+ .custom<void>(
643
+ (tui, theme, keybindings, done) => {
644
+ const panel = new MinimalSubagentsStatusPanelComponent(
645
+ this.coordinator,
646
+ this.getAccess,
647
+ tui,
648
+ theme,
649
+ keybindings,
650
+ this.context.cwd,
651
+ () => done(undefined),
652
+ this.startRefresh,
653
+ );
654
+ this.activePanel = panel;
655
+ return panel;
656
+ },
657
+ {
658
+ overlay: true,
659
+ overlayOptions: {
660
+ anchor: "center",
661
+ width: "90%",
662
+ maxHeight: "90%",
663
+ margin: STATUS_PANEL_MARGIN,
664
+ },
665
+ onHandle: (handle) => {
666
+ this.overlayHandle = handle;
667
+ },
668
+ },
669
+ )
466
670
  .catch(() => {
467
671
  this.context.ui.notify("Subagents status view failed.", "error");
468
672
  })
@@ -470,6 +674,7 @@ export class MinimalSubagentsStatusPanelController {
470
674
  this.activePanel?.dispose();
471
675
  this.activePanel = undefined;
472
676
  this.activePromise = undefined;
677
+ this.overlayHandle = undefined;
473
678
  });
474
679
  this.activePromise = promise;
475
680
  return promise;
@@ -123,14 +123,14 @@ export interface RecentAgentActivity {
123
123
  truncated: boolean;
124
124
  }
125
125
 
126
- /** Holds a bounded, image-free process-local Child Agent transcript for trusted status UI. */
126
+ /** Holds the complete selected-branch Child Session Transcript for trusted status UI. */
127
127
  export interface ChildAgentTranscriptSnapshot {
128
128
  messages: AgentMessage[];
129
- /** Index of the current streaming assistant message when it remains in the bounded tail. */
129
+ /** Index of the current streaming assistant message, when not yet committed. */
130
130
  streamingAssistantIndex?: number;
131
131
  /** Real Child Agent tool definitions referenced by visible tool calls. */
132
132
  toolDefinitions: ToolDefinition[];
133
- /** Best-known status or result text when no live Child Agent runtime exists. */
133
+ /** Explanation when neither live nor verified saved history is available. */
134
134
  fallback?: string;
135
135
  }
136
136
 
@@ -241,7 +241,7 @@ export interface ChildAgentRuntime {
241
241
  snapshotCommittedMessages(): AgentMessage[];
242
242
  /** Clone child transcript messages including the current streaming assistant tail. */
243
243
  snapshotActivityMessages(): AgentMessage[];
244
- /** Select the bounded process-local transcript and its real visible tool definitions. */
244
+ /** Snapshot the full selected branch and streaming output with its real tool definitions. */
245
245
  snapshotActivityTranscript?(): ChildAgentTranscriptSnapshot;
246
246
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean;
247
247
  getUsage(): Usage | undefined;
@@ -259,6 +259,8 @@ export interface AgentSessionFactory {
259
259
  createIdentity(agent: PersistedAgent, importedMessages: AgentMessage[]): PersistedSessionIdentity;
260
260
  /** Open one verified persisted Child Agent runtime for launch or restoration. */
261
261
  openRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime>;
262
+ /** Read verified saved history independently of runtime restoration dependencies. */
263
+ readTranscript?(agent: PersistedAgent): ChildAgentTranscriptSnapshot;
262
264
  resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]>;
263
265
  resolveRestorationMissingDependencies(agent: PersistedAgent): Promise<string[]>;
264
266
  resolveThinkingLevel(modelId: string, requested: ThinkingLevel): ThinkingLevel;
@@ -13,6 +13,7 @@ import {
13
13
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
14
14
  import {
15
15
  formatSubagentDuration,
16
+ orderActiveAgentSubtrees,
16
17
  renderSubagentStatusLabel,
17
18
  renderSubagentStatusSymbol,
18
19
  subagentStatusLadder,
@@ -128,7 +129,7 @@ export function buildMinimalSubagentsWidgetView(
128
129
  if (chosenIds.size + additions.length > MINIMAL_SUBAGENTS_WIDGET_ROW_LIMIT) continue;
129
130
  for (const item of additions) chosenIds.add(item.agent.agent_id);
130
131
  }
131
- const rows = flattened
132
+ const rows = flattenAgentHierarchy(orderActiveAgentSubtrees(agents))
132
133
  .filter((item) => chosenIds.has(item.agent.agent_id))
133
134
  .map((item): MinimalSubagentsWidgetRow => {
134
135
  const structural = !meaningfulIds.has(item.agent.agent_id);