@ian-pascoe/pi-minimal-subagents 0.6.6 → 0.7.1
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 +33 -9
- package/package.json +1 -1
- package/src/minimal-subagents-context.ts +19 -86
- package/src/minimal-subagents-coordinator.ts +9 -1
- package/src/minimal-subagents-extension.ts +89 -0
- package/src/minimal-subagents-registry.ts +1 -1
- package/src/minimal-subagents-rendering.ts +18 -0
- package/src/minimal-subagents-sessions.ts +83 -7
- package/src/minimal-subagents-status-panel.ts +308 -103
- package/src/minimal-subagents-types.ts +6 -4
- package/src/minimal-subagents-ui.ts +2 -1
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.
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
@@ -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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
const
|
|
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:
|
|
153
|
-
|
|
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
|
|
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) {
|
|
@@ -157,7 +157,7 @@ export function createRegistryEvent(
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
case "agent-created":
|
|
160
|
-
return { ...envelope, event, agent: data.agent };
|
|
160
|
+
return { ...envelope, event, agent: structuredClone(data.agent) };
|
|
161
161
|
case "turn-started":
|
|
162
162
|
return {
|
|
163
163
|
...envelope,
|
|
@@ -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
|
|
633
|
-
|
|
634
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
151
|
+
cache: TranscriptRenderCache,
|
|
152
|
+
): TranscriptLayout {
|
|
89
153
|
if (snapshot.messages.length === 0) {
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
:
|
|
154
|
+
return {
|
|
155
|
+
lines: new Text(snapshot.fallback || "No conversation messages yet.", 3, 0).render(width),
|
|
156
|
+
messageStarts: [0],
|
|
157
|
+
};
|
|
93
158
|
}
|
|
94
|
-
const
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
private
|
|
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.
|
|
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.
|
|
247
|
-
this.ensureSelectionVisible = false;
|
|
383
|
+
this.scroll(-this.viewportHeight());
|
|
248
384
|
} else if (this.keybindings.matches(data, "tui.select.pageDown")) {
|
|
249
|
-
this.
|
|
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
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
"
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
321
|
-
|
|
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 =
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
this.
|
|
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.
|
|
586
|
+
this.transcript = this.coordinator.inspectTranscript(agentId);
|
|
398
587
|
} catch (error) {
|
|
399
|
-
this.
|
|
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
|
|
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)
|
|
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>(
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
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);
|