pi-agent-orchestrator 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 opencode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # pi-agent-orchestrator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run shell work and child agents in parallel from Pi, with automatic completion notifications and a
4
+ dock that shows what is running.
5
+
6
+ The design started from [OpenCode](https://github.com/anomalyco/opencode) — its job lifecycle,
7
+ child sessions, and activity grouping are the inspiration — and this package is being developed
8
+ independently from there. Expect it to grow beyond that starting point.
9
+
10
+ ## What it gives you
11
+
12
+ - **`shell`** — run a command in the foreground, or hand it to the background and get on with your
13
+ work. Foreground defaults to a 120-second timeout; background commands have no timeout by default.
14
+ Output streams to a log file as it arrives, and reads are bounded.
15
+ - **`subagent`** — delegate a task to a child agent with its own context. `explore` is a read-only
16
+ file-search specialist; `general` handles multi-step work with full tool access. Call it again with
17
+ the returned `sessionID` to continue the same conversation.
18
+ - **Jobs** — every command and child agent becomes a job you can inspect, wait on, background, or
19
+ cancel. Failed or interrupted work leaves a receipt instead of disappearing.
20
+ - **Completion notifications** — background work wakes the parent session when it finishes. Nothing
21
+ needs to poll; repeated checking is explicitly discouraged in the tool instructions.
22
+ - **Activity dock** — a compact `Subagents` / `Shell` view with a full-screen transcript detail:
23
+ expandable Thought and Explored groups, tool history, mouse wheel scrolling, and interruption.
24
+
25
+ ## Using it
26
+
27
+ Press `↓` with an empty editor — and no active overlay, autocomplete, or history browsing — to open
28
+ the dock, or run `/agents`. Inside the dock, `↑`/`↓` move the selection, `←`/`→` switch tabs, and
29
+ `Enter` opens the detail view. Arrow keys stay with Pi whenever you are typing, browsing history or
30
+ autocomplete, or using a selector, so nothing is taken away from normal editing.
31
+
32
+ Detail view follows the same transcript shape as the reference: `Esc` or `←` returns to the list,
33
+ the mouse wheel scrolls, and Thought/Explored groups toggle on click.
34
+
35
+ Tools: `shell`, `subagent`, `job_status`, `job_wait`, `job_background`, `job_cancel`, `shell_output`.
36
+
37
+ ## Defaults
38
+
39
+ - Foreground `shell` timeout is **120,000 ms**; background runs have no timeout unless you pass one.
40
+ - Agent steps are **unlimited** by default.
41
+ - Subagent nesting depth is limited to **1**.
42
+ - A background launch returns a receipt with the ID and an instruction not to poll.
43
+ - The `explore` agent keeps its read-only file-search restrictions.
44
+ - `general` uses Pi's own system prompt; it is not overridden.
45
+
46
+ ## Where Pi differs from the reference
47
+
48
+ These are host differences, not behavior we chose to change:
49
+
50
+ | Reference | Here |
51
+ |---|---|
52
+ | Custom shell parser and permission service | Pi's `createLocalBashOperations()` and whatever local shell Pi is configured with. Pi exposes no equivalent parser or permission service to extensions. |
53
+ | Parent-linked sessions in a durable store | `SessionManager.create(cwd, undefined, { parentSession })` writes a child session file header-linked to its parent, so a child can be continued later, even in a new Pi process. Ephemeral parents fall back to `SessionManager.inMemory`. |
54
+ | Synthetic notification messages | `pi.sendMessage(..., { triggerTurn: true, deliverAs: 'followUp' })` delivers completion into the parent session. |
55
+ | Long-lived job service | Recovery receipts are Pi custom session entries. Background agents and OS processes are process-local, so nothing resumes automatically after Pi exits — a pending run is reported as interrupted on the next session start. |
56
+ | Solid/OpenTUI activity groups | Pi's `ctx.ui.custom`, theme, and normalized mouse events render the dock. |
57
+ | Agent registry and permissions | Two fixed roles, `explore` and `general`, built from the tools Pi actually exposes. |
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ npm install # dev-only: typescript and @types/node, used for typechecking
63
+ npm run typecheck
64
+ npm test
65
+ ```
66
+
67
+ `npm test` runs `node --test` over `tests/`. Six assertions compare this package against real
68
+ OpenCode sources and need a local clone; set `OPENCODE_UPSTREAM` to its path to enable them.
69
+ Without a clone those checks are skipped and everything else still runs. Tests are development-only
70
+ and are not published.
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pi install npm:pi-agent-orchestrator
76
+ ```
77
+
78
+ Or from a local checkout:
79
+
80
+ ```bash
81
+ pi install ./path/to/pi-agent-orchestrator
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT. Portions of the prompts and lifecycle design derive from
87
+ [OpenCode](https://github.com/anomalyco/opencode), also MIT-licensed; its original copyright notice is
88
+ retained in [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ import type { EditorComponent, TUI } from '@earendil-works/pi-tui';
2
+
3
+ export function shouldInstallEditorHook<T>(currentFactory: T | undefined, installedFactory: T | undefined): boolean {
4
+ return installedFactory === undefined || currentFactory !== installedFactory;
5
+ }
6
+
7
+ export function shouldOpenActivityFromEditorInput(
8
+ isDownArrow: boolean,
9
+ editor: EditorComponent,
10
+ tui: Pick<TUI, 'hasOverlay'>,
11
+ ): boolean {
12
+ if (!isDownArrow || tui.hasOverlay() || editor.getText().trim().length > 0) return false;
13
+ const state = editor as EditorComponent & { isShowingAutocomplete?: () => boolean };
14
+ return !state.isShowingAutocomplete?.();
15
+ }
@@ -0,0 +1,404 @@
1
+ import { Container, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent } from '@earendil-works/pi-tui';
2
+ import type { Theme } from '@earendil-works/pi-coding-agent';
3
+ import type { ChildSessionRecord } from './shell.js';
4
+ import type { ShellRecord } from './types.js';
5
+
6
+ const RESET = '\x1b[0m';
7
+ const DARK_BG = '\x1b[48;2;20;20;22m';
8
+ const PEACH_BG = '\x1b[48;2;254;180;120m\x1b[38;2;15;15;18m\x1b[1m';
9
+ const BORDER_FG = '\x1b[38;2;70;70;75m';
10
+ const BOLD_WHITE = '\x1b[1m\x1b[38;2;250;250;250m';
11
+ const DIM_GRAY = '\x1b[38;2;120;120;130m';
12
+ const MUTED_TEXT = '\x1b[38;2;200;200;210m';
13
+ const PURPLE = '\x1b[38;2;168;85;247m';
14
+ const YELLOW = '\x1b[38;2;234;179;8m';
15
+ const EXPLORED_GRAY = '\x1b[38;2;160;160;170m';
16
+ const GREEN = '\x1b[38;2;74;222;128m';
17
+ const RED = '\x1b[38;2;248;113;113m';
18
+ const MAX_VISIBLE_ROWS = 7;
19
+ const DETAIL_REFRESH_INTERVAL_MS = 200;
20
+
21
+ type ActivityItem = { kind: 'subagent'; value: ChildSessionRecord } | { kind: 'shell'; value: ShellRecord };
22
+
23
+ function duration(ms: number): string {
24
+ const seconds = Math.floor(ms / 1000);
25
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
26
+ }
27
+
28
+ function statusLabel(status: string): string {
29
+ if (status === 'running') return 'Running';
30
+ if (status === 'completed') return 'Done';
31
+ if (status === 'cancelled') return 'Stopped';
32
+ return 'Failed';
33
+ }
34
+
35
+ function opaqueLine(content: string, width: number): string {
36
+ const innerWidth = Math.max(0, width - 1);
37
+ const clipped = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth, '…') : content;
38
+ return `${BORDER_FG}│${RESET}${DARK_BG}${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))}${RESET}`;
39
+ }
40
+
41
+ function rowLine(left: string, right: string, selected: boolean, width: number): string {
42
+ const innerWidth = Math.max(0, width - 1);
43
+ const rightPad = `${right} `;
44
+ const maxLeft = Math.max(0, innerWidth - visibleWidth(rightPad) - 2);
45
+ const clippedLeft = visibleWidth(left) > maxLeft ? truncateToWidth(left, maxLeft, '…') : left;
46
+ const padding = Math.max(1, innerWidth - visibleWidth(clippedLeft) - visibleWidth(rightPad) - 2);
47
+ if (selected) return `${BORDER_FG}│${RESET}${PEACH_BG} ${clippedLeft}${' '.repeat(padding)}${rightPad}${RESET}`;
48
+ return `${BORDER_FG}│${RESET}${DARK_BG} ${MUTED_TEXT}${clippedLeft}${RESET}${DARK_BG}${' '.repeat(padding)}${DIM_GRAY}${rightPad}${RESET}`;
49
+ }
50
+
51
+ function agentName(agent: string): string {
52
+ return agent.length ? `${agent[0]!.toUpperCase()}${agent.slice(1)}` : 'Subagent';
53
+ }
54
+
55
+ function toolCounts(tools: ChildSessionRecord['tools']): { reads: number; searches: number; other: number } {
56
+ let reads = 0;
57
+ let searches = 0;
58
+ let other = 0;
59
+ for (const tool of tools) {
60
+ if (tool.name === 'read') reads++;
61
+ else if (tool.name === 'grep' || tool.name === 'find' || tool.name === 'web_search' || tool.name === 'glob') searches++;
62
+ else other++;
63
+ }
64
+ return { reads, searches, other };
65
+ }
66
+
67
+ function exploredText(child: ChildSessionRecord): string {
68
+ const counts = toolCounts(child.tools);
69
+ const parts = [`${counts.reads} ${counts.reads === 1 ? 'read' : 'reads'}`, `${counts.searches} ${counts.searches === 1 ? 'search' : 'searches'}`];
70
+ if (counts.other) parts.push(`${counts.other} ${counts.other === 1 ? 'tool' : 'tools'}`);
71
+ return parts.join(', ');
72
+ }
73
+
74
+ class ActivityBody extends Container {
75
+ constructor(private entries: Component[] = []) { super(); this.replace(); }
76
+ setEntries(entries: Component[]): void { this.entries = entries; this.replace(); }
77
+ private replace(): void { this.clear(); for (const entry of this.entries) this.addChild(entry); }
78
+ }
79
+
80
+ class GroupContent implements Component {
81
+ readonly id: string;
82
+ private hover = false;
83
+ constructor(id: string, private readonly lines: string[], private expanded: boolean, private readonly onToggle: (value: boolean) => void) { this.id = id; }
84
+ setHover(value: boolean): void { this.hover = value; }
85
+ toggle(): void { this.expanded = !this.expanded; this.onToggle(this.expanded); }
86
+ render(_width: number): string[] {
87
+ const lines = this.expanded ? this.lines : this.lines.slice(0, 1);
88
+ return lines.map((line, index) => opaqueLine(index === 0 && this.hover ? `\x1b[38;2;245;245;245m${line}${RESET}` : line, _width));
89
+ }
90
+ invalidate(): void {}
91
+ }
92
+
93
+ export interface ActivityStore {
94
+ shells(): ShellRecord[];
95
+ children(): ChildSessionRecord[];
96
+ cancel(id: string): void;
97
+ close(): void;
98
+ }
99
+
100
+ export class ActivityDocument implements Component, Focusable {
101
+ private body = new ActivityBody();
102
+ private detailScrollTop = 0;
103
+ private detailBodyHeight = 0;
104
+ private detailContentHeight = 0;
105
+ private content: Component[] = [];
106
+ private expanded = new Map<string, boolean>();
107
+ private hoveredGroup?: string;
108
+ private _focused = true;
109
+ private lastSignature = '';
110
+ private renderWidth = 80;
111
+ private activeTab: 'subagents' | 'shell' = 'subagents';
112
+ private showInactive = false;
113
+ private selectedIndex = 0;
114
+ private detailKey?: string;
115
+ private actionNotice?: string;
116
+ private refreshTimer: NodeJS.Timeout;
117
+
118
+ constructor(private readonly tui: TUI, private readonly theme: Theme, private readonly store: ActivityStore) {
119
+ this.refreshTimer = setInterval(() => {
120
+ this.lastSignature = '';
121
+ this.tui.requestRender();
122
+ }, DETAIL_REFRESH_INTERVAL_MS);
123
+ }
124
+
125
+ get focused(): boolean { return this._focused; }
126
+ set focused(value: boolean) { this._focused = value; }
127
+
128
+ private items(): ActivityItem[] {
129
+ const values: ActivityItem[] = this.activeTab === 'subagents'
130
+ ? this.store.children().map((value) => ({ kind: 'subagent', value }))
131
+ : this.store.shells().map((value) => ({ kind: 'shell', value }));
132
+ if (this.showInactive) return values;
133
+ const running = values.filter((item) => item.value.status === 'running');
134
+ return running.length ? running : values;
135
+ }
136
+
137
+ private selectedItem(): ActivityItem | undefined { return this.items()[this.selectedIndex]; }
138
+
139
+ private scrollDetail(delta: number): void {
140
+ this.detailScrollTop = Math.max(0, Math.min(Math.max(0, this.detailContentHeight - this.detailBodyHeight), this.detailScrollTop + delta));
141
+ }
142
+
143
+ private itemId(item: ActivityItem): string { return item.kind === 'subagent' ? item.value.sessionId : item.value.shellID; }
144
+
145
+ private selectedId(item: ActivityItem | undefined): string | undefined { return item ? `${item.kind}:${this.itemId(item)}` : undefined; }
146
+
147
+ private enterDetail(item = this.selectedItem()): void {
148
+ if (!item) return;
149
+ this.detailKey = this.selectedId(item);
150
+ this.detailScrollTop = 0;
151
+ this.actionNotice = undefined;
152
+ this.lastSignature = '';
153
+ this.tui.requestRender();
154
+ }
155
+
156
+ handleInput(data: string): void {
157
+ const items = this.items();
158
+ if (this.detailKey) {
159
+ if (matchesKey(data, 'escape') || matchesKey(data, 'left') || matchesKey(data, 'backspace') || data === 'q') {
160
+ this.detailKey = undefined;
161
+ this.lastSignature = '';
162
+ } else if (matchesKey(data, 'up')) this.scrollDetail(-1);
163
+ else if (matchesKey(data, 'down')) this.scrollDetail(1);
164
+ else if (matchesKey(data, 'pageUp')) this.scrollDetail(-8);
165
+ else if (matchesKey(data, 'pageDown')) this.scrollDetail(8);
166
+ else if (matchesKey(data, 'ctrl+d') || data === 'x') this.interruptSelected();
167
+ else if (data === 't' || data === 'T') this.toggleGroup('thought:');
168
+ else if (data === 'e' || data === 'E' || data === 'o' || data === 'O') this.toggleGroup('explore:');
169
+ this.tui.requestRender();
170
+ return;
171
+ }
172
+
173
+ if (matchesKey(data, 'escape') || data === 'q') { this.store.close(); return; }
174
+ if (matchesKey(data, 'up')) {
175
+ if (this.selectedIndex === 0 || items.length === 0) { this.store.close(); return; }
176
+ this.selectedIndex--;
177
+ } else if (matchesKey(data, 'down')) {
178
+ if (items.length) this.selectedIndex = Math.min(items.length - 1, this.selectedIndex + 1);
179
+ } else if (matchesKey(data, 'left') || matchesKey(data, 'right') || matchesKey(data, 'tab')) {
180
+ this.activeTab = this.activeTab === 'subagents' ? 'shell' : 'subagents';
181
+ this.selectedIndex = 0;
182
+ } else if (matchesKey(data, 'enter') || data === ' ') this.enterDetail();
183
+ else if (matchesKey(data, 'ctrl+a')) { this.showInactive = !this.showInactive; this.selectedIndex = 0; }
184
+ else if (matchesKey(data, 'ctrl+d') || data === 'x') this.interruptSelected();
185
+ this.lastSignature = '';
186
+ this.tui.requestRender();
187
+ }
188
+
189
+ private interruptSelected(): void {
190
+ const selected = this.detailKey ? this.findItem(this.detailKey) : this.selectedItem();
191
+ if (!selected || selected.value.status !== 'running') return;
192
+ this.store.cancel(this.itemId(selected));
193
+ this.actionNotice = `Interrupted ${selected.kind === 'subagent' ? selected.value.description : selected.value.command}`;
194
+ this.lastSignature = '';
195
+ this.tui.requestRender();
196
+ }
197
+
198
+ private findItem(key: string): ActivityItem | undefined {
199
+ const [kind, id] = key.split(':', 2);
200
+ if (kind === 'subagent') {
201
+ const value = this.store.children().find((child) => child.sessionId === id);
202
+ return value ? { kind: 'subagent', value } : undefined;
203
+ }
204
+ const value = this.store.shells().find((shell) => shell.shellID === id);
205
+ return value ? { kind: 'shell', value } : undefined;
206
+ }
207
+
208
+ private toggleGroup(prefix: string): void {
209
+ if (!this.detailKey) return;
210
+ const key = `${prefix}${this.detailKey.slice(this.detailKey.indexOf(':') + 1)}`;
211
+ this.expanded.set(key, !(this.expanded.get(key) ?? false));
212
+ this.lastSignature = '';
213
+ }
214
+
215
+ handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
216
+ if (event.type === 'wheel') {
217
+ const delta = event.wheelDelta ?? 1;
218
+ if (this.detailKey) this.scrollDetail(-delta);
219
+ else if (this.items().length) this.selectedIndex = Math.max(0, Math.min(this.items().length - 1, this.selectedIndex + (delta < 0 ? 1 : -1)));
220
+ this.lastSignature = '';
221
+ this.tui.requestRender();
222
+ return { handled: true, render: true };
223
+ }
224
+ if (event.type !== 'click' || event.button !== 'left') {
225
+ if (event.type === 'move' && this.detailKey) this.updateHoveredGroup(event.y - this.header.length + this.detailScrollTop);
226
+ return undefined;
227
+ }
228
+
229
+ if (this.detailKey) {
230
+ if (event.y >= this.header.length + this.detailBodyHeight) return undefined;
231
+ if (event.y < this.header.length && event.x < 20) { this.detailKey = undefined; this.lastSignature = ''; this.tui.requestRender(); return { handled: true, render: true }; }
232
+ const group = this.groupAt(event.y - this.header.length + this.detailScrollTop);
233
+ if (group) { group.toggle(); this.lastSignature = ''; this.tui.requestRender(); return { handled: true, render: true }; }
234
+ return undefined;
235
+ }
236
+
237
+ if (event.y === 0) {
238
+ if (event.x < 14) this.activeTab = 'subagents';
239
+ else if (event.x < 25) this.activeTab = 'shell';
240
+ else if (event.x >= this.renderWidth - 6) this.store.close();
241
+ this.selectedIndex = 0;
242
+ this.lastSignature = '';
243
+ this.tui.requestRender();
244
+ return { handled: true, render: true };
245
+ }
246
+ const row = event.y - this.rowStart;
247
+ if (row >= 0 && row < this.visibleItems.length) {
248
+ const index = this.visibleStart + row;
249
+ this.selectedIndex = index;
250
+ if ((event.clickCount ?? 1) > 1) this.enterDetail(this.visibleItems[row]);
251
+ else this.lastSignature = '';
252
+ this.tui.requestRender();
253
+ return { handled: true, render: true };
254
+ }
255
+ return undefined;
256
+ }
257
+
258
+ private visibleItems: ActivityItem[] = [];
259
+ private visibleStart = 0;
260
+ private rowStart = 2;
261
+ private header: string[] = [];
262
+ private footer: string[] = [];
263
+
264
+ private updateHoveredGroup(target: number): void {
265
+ const group = this.groupAt(target);
266
+ if (this.hoveredGroup === group?.id) return;
267
+ if (this.hoveredGroup) {
268
+ const previous = this.content.find((entry) => entry instanceof GroupContent && entry.id === this.hoveredGroup);
269
+ if (previous instanceof GroupContent) previous.setHover(false);
270
+ }
271
+ group?.setHover(true);
272
+ this.hoveredGroup = group?.id;
273
+ this.tui.requestRender();
274
+ }
275
+
276
+ private groupAt(target: number): GroupContent | undefined {
277
+ let row = 0;
278
+ for (const item of this.content) {
279
+ const height = item.render(this.renderWidth).length;
280
+ if (target >= row && target < row + height && item instanceof GroupContent) return item;
281
+ row += height;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ private buildDetailContent(item: ActivityItem): Component[] {
287
+ const lines: Component[] = [];
288
+ if (item.kind === 'subagent') {
289
+ const child = item.value;
290
+ const prompt = child.lastPrompt || 'Subagent task';
291
+ for (const line of prompt.split('\n')) lines.push(new Text(`${PURPLE}│${RESET} ${MUTED_TEXT}${line}${RESET}`, 2, 0));
292
+ lines.push(new Text('', 0, 0));
293
+ const thoughtKey = `thought:${child.sessionId}`;
294
+ const elapsed = Math.max(0, (child.completedAt ?? Date.now()) - child.startedAt);
295
+ const elapsedText = elapsed < 1000 ? `${elapsed}ms` : `${(elapsed / 1000).toFixed(1)}s`;
296
+ const thoughtLines = [` ${YELLOW}${child.status === 'running' ? '●' : (this.expanded.get(thoughtKey) ? '−' : '+')} Thought · ${elapsedText}${RESET}`];
297
+ if (child.thinking) thoughtLines.push(...child.thinking.split('\n').map((line) => ` ${DIM_GRAY}${line}${RESET}`));
298
+ lines.push(new GroupContent(thoughtKey, thoughtLines, this.expanded.get(thoughtKey) ?? false, (value) => this.expanded.set(thoughtKey, value)));
299
+ lines.push(new Text('', 0, 0));
300
+
301
+ const exploredKey = `explore:${child.sessionId}`;
302
+ const exploredLines = [` ${EXPLORED_GRAY}${child.status === 'running' ? '→ Exploring' : (this.expanded.get(exploredKey) ? '▼ Explored' : '→ Explored')}: ${exploredText(child)}${RESET}`];
303
+ for (const tool of child.tools) {
304
+ const args = JSON.stringify(tool.input ?? {}).replace(/\s+/g, ' ').slice(0, 100);
305
+ exploredLines.push(` ${DIM_GRAY}${tool.name}(${args})${tool.status === 'running' ? ' …' : ''}${RESET}`);
306
+ if (tool.output) for (const outputLine of tool.output.split('\n').slice(0, 6)) exploredLines.push(` ${MUTED_TEXT}${outputLine}${RESET}`);
307
+ }
308
+ lines.push(new GroupContent(exploredKey, exploredLines, this.expanded.get(exploredKey) ?? false, (value) => this.expanded.set(exploredKey, value)));
309
+ if (child.status === 'running') lines.push(new Text(` ${GREEN}● Active: ${child.tools.at(-1)?.name ?? 'Preparing subagent'}${RESET}`, 2, 0));
310
+ if (child.result) for (const line of [` ${BOLD_WHITE}Synthesis Findings:${RESET}`, ...child.result.split('\n').map((value) => ` ${MUTED_TEXT}${value}${RESET}`)]) lines.push(new Text(line, 2, 0));
311
+ if (child.error) lines.push(new Text(` ${RED}Error: ${child.error}${RESET}`, 2, 0));
312
+ } else {
313
+ const shell = item.value;
314
+ lines.push(new Text(`${PURPLE}│${RESET} ${BOLD_WHITE}$ ${shell.command}${RESET}`, 2, 0));
315
+ lines.push(new Text('', 0, 0));
316
+ lines.push(new Text(` ${DIM_GRAY}Output${RESET}`, 2, 0));
317
+ for (const line of shell.output.split('\n').slice(-100)) lines.push(new Text(` ${MUTED_TEXT}${line}${RESET}`, 2, 0));
318
+ if (shell.error) lines.push(new Text(` ${RED}${shell.error}${RESET}`, 2, 0));
319
+ }
320
+ return lines;
321
+ }
322
+
323
+ private refresh(): void {
324
+ const children = this.store.children();
325
+ const shells = this.store.shells();
326
+ const detailItem = this.detailKey ? this.findItem(this.detailKey) : undefined;
327
+ const signature = JSON.stringify([
328
+ this.renderWidth, this.detailKey, this.activeTab, this.showInactive, this.selectedIndex, this.actionNotice,
329
+ children.map((c) => [c.sessionId, c.status, c.tools.length, c.thinking?.length, c.result?.length, c.tools.map((t) => [t.name, t.status, t.output?.length])]),
330
+ shells.map((s) => [s.shellID, s.status, s.output.length]),
331
+ ]);
332
+ if (signature === this.lastSignature) return;
333
+ this.lastSignature = signature;
334
+ this.content = [];
335
+ this.renderWidth = Math.max(1, this.renderWidth);
336
+
337
+ if (this.detailKey && !detailItem) this.detailKey = undefined;
338
+ if (detailItem) {
339
+ this.content = this.buildDetailContent(detailItem);
340
+ this.body.setEntries(this.content);
341
+ const active = detailItem.value.status === 'running';
342
+ const label = detailItem.kind === 'subagent' ? detailItem.value.description : detailItem.value.command;
343
+ const status = active ? `${GREEN}● Running${RESET}` : detailItem.value.status === 'completed' ? `${DIM_GRAY}Done${RESET}` : `${RED}${statusLabel(detailItem.value.status)}${RESET}`;
344
+ const rightPad = status;
345
+ const left = ` ${BOLD_WHITE}← Back (esc/←)${RESET} ${DIM_GRAY}${label}${RESET}`;
346
+ const padding = Math.max(1, this.renderWidth - visibleWidth(left) - visibleWidth(rightPad) - 2);
347
+ this.header = [opaqueLine(`${left}${' '.repeat(padding)}${rightPad} `, this.renderWidth), opaqueLine('', this.renderWidth)];
348
+ this.footer = [opaqueLine(` ${BOLD_WHITE}back${RESET} ${DIM_GRAY}esc/←${RESET} ${BOLD_WHITE}scroll${RESET} ${DIM_GRAY}wheel/↑/↓${RESET} ${BOLD_WHITE}thought${RESET} ${DIM_GRAY}t/click${RESET} ${BOLD_WHITE}tools${RESET} ${DIM_GRAY}e/click${RESET} ${BOLD_WHITE}interrupt${RESET} ${DIM_GRAY}ctrl+d${RESET}`, this.renderWidth)];
349
+ return;
350
+ }
351
+
352
+ this.body.setEntries([]);
353
+ const allChildren = children;
354
+ const allShells = shells;
355
+ if (this.activeTab === 'subagents' && !allChildren.length && allShells.length) this.activeTab = 'shell';
356
+ else if (this.activeTab === 'shell' && !allShells.length && allChildren.length) this.activeTab = 'subagents';
357
+ const items = this.items();
358
+ this.selectedIndex = items.length ? Math.max(0, Math.min(this.selectedIndex, items.length - 1)) : 0;
359
+ const rows = Math.max(1, Math.min(MAX_VISIBLE_ROWS, (process.stdout.rows || 24) - 4));
360
+ this.visibleStart = Math.max(0, Math.min(this.selectedIndex - rows + 1, Math.max(0, items.length - rows)));
361
+ this.visibleItems = items.slice(this.visibleStart, this.visibleStart + rows);
362
+ const active = items.filter((item) => item.value.status === 'running').length;
363
+ const subagentsLabel = this.activeTab === 'subagents' ? `${BOLD_WHITE}Subagents${RESET}` : `${DIM_GRAY}Subagents${RESET}`;
364
+ const shellLabel = this.activeTab === 'shell' ? `${BOLD_WHITE}Shell${RESET}` : `${DIM_GRAY}Shell${RESET}`;
365
+ const left = ` ${subagentsLabel} ${shellLabel}`;
366
+ const right = `${DIM_GRAY}esc ${RESET}`;
367
+ const padding = Math.max(0, this.renderWidth - visibleWidth(left) - visibleWidth(right) - 1);
368
+ this.header = [opaqueLine(`${left}${' '.repeat(padding)}${right}`, this.renderWidth), opaqueLine('', this.renderWidth)];
369
+ this.footer = [opaqueLine(` ${BOLD_WHITE}interrupt${RESET} ${DIM_GRAY}ctrl+d${RESET} ${BOLD_WHITE}${this.showInactive ? 'hide inactive' : 'show inactive'}${RESET} ${DIM_GRAY}ctrl+a${RESET} ${BOLD_WHITE}open${RESET} ${DIM_GRAY}enter${RESET} ${BOLD_WHITE}tabs${RESET} ${DIM_GRAY}←/→${RESET}`, this.renderWidth)];
370
+
371
+ const panelHeight = Math.min(process.stdout.rows || 24, Math.max(10, rows + 5));
372
+ const listLines: string[] = [...this.header];
373
+ this.rowStart = listLines.length;
374
+ for (let i = 0; i < rows; i++) {
375
+ const item = this.visibleItems[i];
376
+ if (!item) { listLines.push(opaqueLine('', this.renderWidth)); continue; }
377
+ const label = item.kind === 'subagent' ? `${agentName(item.value.agent)}: ${item.value.description}` : item.value.command.split('\n', 1)[0]!;
378
+ const status = item.value.status === 'running' ? `${GREEN}Running${RESET}` : statusLabel(item.value.status);
379
+ listLines.push(rowLine(label, status, this.visibleStart + i === this.selectedIndex, this.renderWidth));
380
+ }
381
+ if (!items.length) listLines[this.rowStart] = opaqueLine(` ${DIM_GRAY}No ${this.activeTab === 'subagents' ? 'subagents' : 'shell processes'}${this.showInactive ? '' : ' running'}.${RESET}`, this.renderWidth);
382
+ while (listLines.length < panelHeight - 1) listLines.push(opaqueLine(this.actionNotice ? ` ${RED}${this.actionNotice}${RESET}` : '', this.renderWidth));
383
+ listLines.push(this.footer[0]!);
384
+ this.header = listLines;
385
+ }
386
+
387
+ render(width: number): string[] {
388
+ this.renderWidth = width;
389
+ this.refresh();
390
+ if (!this.detailKey) return this.header;
391
+ const height = Math.max(10, process.stdout.rows || 24);
392
+ const bodyHeight = Math.max(1, height - this.header.length - this.footer.length);
393
+ const rendered = this.body.render(width);
394
+ this.detailBodyHeight = bodyHeight;
395
+ this.detailContentHeight = rendered.length;
396
+ this.detailScrollTop = Math.max(0, Math.min(this.detailScrollTop, Math.max(0, rendered.length - bodyHeight)));
397
+ const body = rendered.slice(this.detailScrollTop, this.detailScrollTop + bodyHeight).map((line) => opaqueLine(line, width));
398
+ while (body.length < bodyHeight) body.push(opaqueLine('', width));
399
+ return [...this.header, ...body, ...this.footer];
400
+ }
401
+
402
+ invalidate(): void { this.lastSignature = ''; this.body.invalidate(); }
403
+ dispose(): void { clearInterval(this.refreshTimer); }
404
+ }