@ian-pascoe/pi-minimal-subagents 0.5.0 → 0.6.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.
@@ -0,0 +1,483 @@
1
+ import { contentText } from "@earendil-works/pi-ai";
2
+ import {
3
+ AssistantMessageComponent,
4
+ BashExecutionComponent,
5
+ BranchSummaryMessageComponent,
6
+ CompactionSummaryMessageComponent,
7
+ CustomMessageComponent,
8
+ ToolExecutionComponent,
9
+ UserMessageComponent,
10
+ type ExtensionContext,
11
+ type KeybindingsManager,
12
+ type Theme,
13
+ type TruncationResult,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { Container, Text, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
16
+ import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
17
+ import {
18
+ formatSubagentDuration,
19
+ renderMinimalSubagentsMessage,
20
+ renderMinimalSubagentsResult,
21
+ subagentStatusLadder,
22
+ } from "./minimal-subagents-rendering.js";
23
+ import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
24
+ import type { SubagentAccessSnapshot } from "./minimal-subagents-access.js";
25
+ import type {
26
+ AgentSummary,
27
+ ChildAgentTranscriptSnapshot,
28
+ HierarchyStatusResult,
29
+ } from "./minimal-subagents-types.js";
30
+
31
+ const STATUS_PANEL_REFRESH_MS = 1_000;
32
+ const STATUS_PANEL_FIXED_LINE_COUNT = 7;
33
+ const STATUS_PANEL_MIN_VIEWPORT_LINES = 4;
34
+ const COORDINATOR_TOOL_COUNT = COORDINATOR_TOOL_NAMES.length;
35
+
36
+ type StartStatusPanelRefresh = (refresh: () => void) => () => void;
37
+
38
+ function startStatusPanelRefresh(refresh: () => void): () => void {
39
+ const interval = setInterval(refresh, STATUS_PANEL_REFRESH_MS);
40
+ interval.unref?.();
41
+ return () => clearInterval(interval);
42
+ }
43
+
44
+ /** Supplies read-only Subagent Access data without coupling the panel to persistence mechanics. */
45
+ export type MinimalSubagentsStatusAccess = SubagentAccessSnapshot & {
46
+ readonly projectTrusted: boolean;
47
+ };
48
+
49
+ interface FlattenedStatusAgent {
50
+ agent: AgentSummary;
51
+ depth: number;
52
+ }
53
+
54
+ function flattenStatusAgents(status: HierarchyStatusResult): FlattenedStatusAgent[] {
55
+ const flattened: FlattenedStatusAgent[] = [];
56
+ const visit = (agent: AgentSummary, depth: number): void => {
57
+ flattened.push({ agent, depth });
58
+ for (const child of agent.children) visit(child, depth + 1);
59
+ };
60
+ const roots = "agents" in status ? status.agents : [status.agent];
61
+ for (const agent of roots) visit(agent, 0);
62
+ return flattened;
63
+ }
64
+
65
+ function authoredAccessValue(value: boolean | undefined): string {
66
+ return value === undefined ? "unset" : value ? "enabled" : "disabled";
67
+ }
68
+
69
+ function statusAccessSourceLabel(source: SubagentAccessSnapshot["source"]): string {
70
+ switch (source) {
71
+ case "branch":
72
+ return "branch override";
73
+ case "project":
74
+ return "project setting";
75
+ case "global":
76
+ return "global setting";
77
+ case "default":
78
+ return "built-in default";
79
+ }
80
+ }
81
+
82
+ function renderTranscriptSnapshot(
83
+ snapshot: ChildAgentTranscriptSnapshot,
84
+ tui: TUI,
85
+ cwd: string,
86
+ expanded: boolean,
87
+ width: number,
88
+ ): string[] {
89
+ 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);
93
+ }
94
+ const container = new Container();
95
+ const tools = new Map(
96
+ snapshot.toolDefinitions.map((definition) => [definition.name, definition]),
97
+ );
98
+ const pendingTools = new Map<string, ToolExecutionComponent>();
99
+
100
+ for (const [messageIndex, message] of snapshot.messages.entries()) {
101
+ switch (message.role) {
102
+ case "user": {
103
+ const text = contentText(message.content, "\n\n");
104
+ if (text) container.addChild(new UserMessageComponent(text));
105
+ break;
106
+ }
107
+ case "assistant": {
108
+ const assistant = new AssistantMessageComponent(message);
109
+ assistant.updateContent(message, messageIndex === snapshot.streamingAssistantIndex);
110
+ container.addChild(assistant);
111
+ for (const content of message.content) {
112
+ if (content.type !== "toolCall") continue;
113
+ const tool = new ToolExecutionComponent(
114
+ content.name,
115
+ content.id,
116
+ content.arguments,
117
+ { showImages: false },
118
+ tools.get(content.name),
119
+ tui,
120
+ cwd,
121
+ );
122
+ tool.setExpanded(expanded);
123
+ container.addChild(tool);
124
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
125
+ tool.updateResult({
126
+ content: [
127
+ {
128
+ type: "text",
129
+ text:
130
+ message.stopReason === "aborted"
131
+ ? "Operation aborted"
132
+ : (message.errorMessage ?? "Error"),
133
+ },
134
+ ],
135
+ isError: true,
136
+ });
137
+ } else {
138
+ pendingTools.set(content.id, tool);
139
+ }
140
+ }
141
+ break;
142
+ }
143
+ case "toolResult": {
144
+ const tool = pendingTools.get(message.toolCallId);
145
+ if (tool) {
146
+ tool.updateResult(message);
147
+ pendingTools.delete(message.toolCallId);
148
+ }
149
+ break;
150
+ }
151
+ case "custom": {
152
+ if (!message.display) break;
153
+ const renderer =
154
+ message.customType === "minimal-subagents.message"
155
+ ? renderMinimalSubagentsMessage
156
+ : message.customType === "minimal-subagents.result"
157
+ ? renderMinimalSubagentsResult
158
+ : undefined;
159
+ const custom = new CustomMessageComponent(message, renderer);
160
+ custom.setExpanded(expanded);
161
+ container.addChild(custom);
162
+ break;
163
+ }
164
+ case "bashExecution": {
165
+ const bash = new BashExecutionComponent(message.command, tui, message.excludeFromContext);
166
+ if (message.output) bash.appendOutput(message.output);
167
+ bash.setComplete(
168
+ message.exitCode,
169
+ message.cancelled,
170
+ // SAFETY: Persisted bash messages retain only the truncation flag; BashExecutionComponent reads that flag here.
171
+ message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
172
+ message.fullOutputPath,
173
+ );
174
+ bash.setExpanded(expanded);
175
+ container.addChild(bash);
176
+ break;
177
+ }
178
+ case "branchSummary": {
179
+ const summary = new BranchSummaryMessageComponent(message);
180
+ summary.setExpanded(expanded);
181
+ container.addChild(summary);
182
+ break;
183
+ }
184
+ case "compactionSummary": {
185
+ const summary = new CompactionSummaryMessageComponent(message);
186
+ summary.setExpanded(expanded);
187
+ container.addChild(summary);
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ return container.render(width);
193
+ }
194
+
195
+ /** Interactive, read-only Child Agent hierarchy and transcript status component. */
196
+ export class MinimalSubagentsStatusPanelComponent implements Component {
197
+ private status!: HierarchyStatusResult;
198
+ private access!: MinimalSubagentsStatusAccess;
199
+ private flattened: FlattenedStatusAgent[] = [];
200
+ private selectedAgentId?: string;
201
+ private readonly expandedAgentIds = new Set<string>();
202
+ private readonly transcripts = new Map<string, ChildAgentTranscriptSnapshot>();
203
+ private scrollOffset = 0;
204
+ private ensureSelectionVisible = true;
205
+ private toolOutputExpanded = false;
206
+ private disposed = false;
207
+ private readonly stopRefresh: () => void;
208
+
209
+ /** Bind one live status component to its coordinator, terminal, and explicit refresh owner. */
210
+ constructor(
211
+ private readonly coordinator: MinimalSubagentsCoordinator,
212
+ private readonly getAccess: () => MinimalSubagentsStatusAccess,
213
+ private readonly tui: TUI,
214
+ private readonly theme: Theme,
215
+ private readonly keybindings: KeybindingsManager,
216
+ private readonly cwd: string,
217
+ private readonly onClose: () => void,
218
+ startRefresh: StartStatusPanelRefresh = startStatusPanelRefresh,
219
+ ) {
220
+ this.refreshData();
221
+ this.stopRefresh = startRefresh(() => {
222
+ try {
223
+ this.refreshData();
224
+ this.tui.requestRender();
225
+ } catch {
226
+ this.close();
227
+ }
228
+ });
229
+ }
230
+
231
+ /** Handle read-only hierarchy navigation and close keys. */
232
+ handleInput(data: string): void {
233
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
234
+ this.close();
235
+ return;
236
+ }
237
+ if (this.keybindings.matches(data, "tui.select.up")) {
238
+ this.moveSelection(-1);
239
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
240
+ this.moveSelection(1);
241
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
242
+ this.toggleSelectedAgent();
243
+ } else if (this.keybindings.matches(data, "app.tools.expand")) {
244
+ this.toolOutputExpanded = !this.toolOutputExpanded;
245
+ } else if (this.keybindings.matches(data, "tui.select.pageUp")) {
246
+ this.scrollOffset = Math.max(0, this.scrollOffset - this.viewportHeight());
247
+ this.ensureSelectionVisible = false;
248
+ } else if (this.keybindings.matches(data, "tui.select.pageDown")) {
249
+ this.scrollOffset += this.viewportHeight();
250
+ this.ensureSelectionVisible = false;
251
+ } else {
252
+ return;
253
+ }
254
+ this.tui.requestRender();
255
+ }
256
+
257
+ /** Render the fixed access header and scrollable Child Agent hierarchy. */
258
+ render(width: number): string[] {
259
+ 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
+ ),
277
+ );
278
+ }
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;
286
+ }
287
+ }
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];
300
+ }
301
+
302
+ /** Invalidate no cached layout because each render derives the current snapshot. */
303
+ invalidate(): void {}
304
+
305
+ /** Release the live refresh owner idempotently. */
306
+ dispose(): void {
307
+ if (this.disposed) return;
308
+ this.disposed = true;
309
+ this.stopRefresh();
310
+ }
311
+
312
+ private refreshData(): void {
313
+ this.status = this.coordinator.inspectStatus();
314
+ this.access = this.getAccess();
315
+ this.flattened = flattenStatusAgents(this.status);
316
+ const liveIds = new Set(this.flattened.map(({ agent }) => agent.agent_id));
317
+ if (!this.selectedAgentId || !liveIds.has(this.selectedAgentId)) {
318
+ this.selectedAgentId = this.flattened[0]?.agent.agent_id;
319
+ }
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);
327
+ }
328
+ }
329
+
330
+ private renderHeader(width: number): string[] {
331
+ const direct = "agents" in this.status ? this.status.agents : [this.status.agent];
332
+ const running = direct.filter((agent) => agent.state === "running").length;
333
+ const idle = direct.length - running;
334
+ const accessState = this.access.enabled ? "enabled" : "disabled";
335
+ const activeCoordinatorToolCount = this.access.coordinatorTools.activeCount;
336
+ const toolState = `${activeCoordinatorToolCount}/${COORDINATOR_TOOL_COUNT} active${
337
+ activeCoordinatorToolCount > 0 && activeCoordinatorToolCount < COORDINATOR_TOOL_COUNT
338
+ ? " (inconsistent)"
339
+ : ""
340
+ }`;
341
+ const projectValue = this.access.projectTrusted
342
+ ? authoredAccessValue(this.access.projectEnabled)
343
+ : "unavailable (untrusted)";
344
+ return [
345
+ truncateToWidth(this.theme.bold("Subagents status"), width, "…"),
346
+ truncateToWidth(
347
+ `Access: ${accessState} · ${statusAccessSourceLabel(this.access.source)}`,
348
+ width,
349
+ "…",
350
+ ),
351
+ truncateToWidth(
352
+ `Defaults: branch ${this.access.branchOverride} · project ${projectValue} · global ${authoredAccessValue(this.access.globalEnabled)}`,
353
+ width,
354
+ "…",
355
+ ),
356
+ truncateToWidth(`Coordinator Tools: ${toolState}`, width, "…"),
357
+ truncateToWidth(`Direct Children: ${running} running · ${idle} idle`, width, "…"),
358
+ "",
359
+ ];
360
+ }
361
+
362
+ private renderAgentRow(agent: AgentSummary, depth: number, width: number): string {
363
+ const selected = agent.agent_id === this.selectedAgentId;
364
+ const disclosure = this.expandedAgentIds.has(agent.agent_id) ? "▾" : "▸";
365
+ const status = subagentStatusLadder(agent);
366
+ const elapsed = formatSubagentDuration(agent.elapsed_ms);
367
+ const task = agent.task?.replace(/\s+/g, " ").trim();
368
+ const line = `${selected ? ">" : " "} ${" ".repeat(depth)}${disclosure} ${agent.agent_id} · ${status}${
369
+ elapsed ? ` ${elapsed}` : ""
370
+ } · ${agent.model}:${agent.thinking_level}${task ? ` · ${task}` : ""}`;
371
+ return truncateToWidth(selected ? this.theme.fg("accent", line) : line, width, "…");
372
+ }
373
+
374
+ private moveSelection(delta: number): void {
375
+ if (this.flattened.length === 0) return;
376
+ const current = this.flattened.findIndex(
377
+ ({ agent }) => agent.agent_id === this.selectedAgentId,
378
+ );
379
+ const next = Math.max(0, Math.min(this.flattened.length - 1, current + delta));
380
+ this.selectedAgentId = this.flattened[next]?.agent.agent_id;
381
+ this.ensureSelectionVisible = true;
382
+ }
383
+
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);
393
+ }
394
+
395
+ private refreshAgentTranscript(agentId: string): void {
396
+ try {
397
+ this.transcripts.set(agentId, this.coordinator.inspectTranscript(agentId));
398
+ } catch (error) {
399
+ this.transcripts.set(agentId, {
400
+ messages: [],
401
+ toolDefinitions: [],
402
+ fallback: error instanceof Error ? error.message : String(error),
403
+ });
404
+ }
405
+ }
406
+
407
+ private viewportHeight(): number {
408
+ return Math.max(
409
+ STATUS_PANEL_MIN_VIEWPORT_LINES,
410
+ this.tui.terminal.rows - STATUS_PANEL_FIXED_LINE_COUNT,
411
+ );
412
+ }
413
+
414
+ /** Settle the custom view and release its refresh timer exactly once. */
415
+ close(): void {
416
+ if (this.disposed) return;
417
+ this.dispose();
418
+ this.onClose();
419
+ }
420
+ }
421
+
422
+ /** Owns one live status custom view and its non-TUI observer behavior. */
423
+ export class MinimalSubagentsStatusPanelController {
424
+ private activePanel?: MinimalSubagentsStatusPanelComponent;
425
+ private activePromise?: Promise<void>;
426
+
427
+ /** Bind the panel owner to one Root Agent session and refresh lifecycle. */
428
+ constructor(
429
+ private readonly coordinator: MinimalSubagentsCoordinator,
430
+ private readonly context: ExtensionContext,
431
+ private readonly getAccess: () => MinimalSubagentsStatusAccess,
432
+ private readonly startRefresh: StartStatusPanelRefresh = startStatusPanelRefresh,
433
+ ) {}
434
+
435
+ /** Open or focus the single live view; RPC receives a notification and structured modes stay silent. */
436
+ open(): Promise<void> {
437
+ if (this.activePromise) return this.activePromise;
438
+ if (this.context.mode === "rpc") {
439
+ const status = this.coordinator.inspectStatus();
440
+ const direct = "agents" in status ? status.agents : [status.agent];
441
+ const running = direct.filter((agent) => agent.state === "running").length;
442
+ const access = this.getAccess();
443
+ this.context.ui.notify(
444
+ `Subagent Access ${access.enabled ? "enabled" : "disabled"} (${statusAccessSourceLabel(access.source)}); Coordinator Tools ${access.coordinatorTools.activeCount}/${COORDINATOR_TOOL_COUNT}; direct Children ${running} running, ${direct.length - running} idle`,
445
+ "info",
446
+ );
447
+ return Promise.resolve();
448
+ }
449
+ if (this.context.mode !== "tui") return Promise.resolve();
450
+
451
+ 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
+ })
466
+ .catch(() => {
467
+ this.context.ui.notify("Subagents status view failed.", "error");
468
+ })
469
+ .finally(() => {
470
+ this.activePanel?.dispose();
471
+ this.activePanel = undefined;
472
+ this.activePromise = undefined;
473
+ });
474
+ this.activePromise = promise;
475
+ return promise;
476
+ }
477
+
478
+ /** Close the live status view and release its refresh timer during session shutdown. */
479
+ dispose(): void {
480
+ this.activePanel?.close();
481
+ this.activePanel = undefined;
482
+ }
483
+ }
@@ -3,7 +3,10 @@ import { Type } from "typebox";
3
3
  import { THINKING_LEVELS } from "./minimal-subagents-capabilities.js";
4
4
 
5
5
  const SessionContextSchema = StringEnum(["inherit", "compact", "omit"] as const);
6
- const ProjectContextSchema = StringEnum(["inherit", "omit"] as const);
6
+ const ProjectContextSchema = StringEnum(["inherit", "omit"] as const, {
7
+ description:
8
+ "Whether to include project-scoped AGENTS.md instructions and skills. Settings, extensions, providers, and tools remain available.",
9
+ });
7
10
  const DelegationSchema = StringEnum(["none", "fanout"] as const);
8
11
  const ThinkingLevelSchema = StringEnum(THINKING_LEVELS);
9
12
  const ToolSelectionSchema = Type.Union(
@@ -1,5 +1,6 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Usage } from "@earendil-works/pi-ai";
3
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
3
4
 
4
5
  /** Controls how much committed caller conversation enters a new child session. */
5
6
  export type SessionContextMode = "inherit" | "compact" | "omit";
@@ -122,6 +123,17 @@ export interface RecentAgentActivity {
122
123
  truncated: boolean;
123
124
  }
124
125
 
126
+ /** Holds a bounded, image-free process-local Child Agent transcript for trusted status UI. */
127
+ export interface ChildAgentTranscriptSnapshot {
128
+ messages: AgentMessage[];
129
+ /** Index of the current streaming assistant message when it remains in the bounded tail. */
130
+ streamingAssistantIndex?: number;
131
+ /** Real Child Agent tool definitions referenced by visible tool calls. */
132
+ toolDefinitions: ToolDefinition[];
133
+ /** Best-known status or result text when no live Child Agent runtime exists. */
134
+ fallback?: string;
135
+ }
136
+
125
137
  /** Extends summary status with launch, dependency, recent-message, and recent-work diagnostics. */
126
138
  export interface AgentDetail extends AgentSummary {
127
139
  session_file?: string;
@@ -223,10 +235,14 @@ export interface ChildAgentRuntime {
223
235
  dispose(): void;
224
236
  /** Return the live Runtime Profile, or undefined when the SDK session has no model. */
225
237
  getRuntimeProfile(): RuntimeProfile | undefined;
238
+ /** Return the effective ordinary tools after child extensions apply runtime adapters. */
239
+ getActiveToolNames?(): string[];
226
240
  /** Clone committed child transcript messages while excluding the streaming assistant tail. */
227
241
  snapshotCommittedMessages(): AgentMessage[];
228
242
  /** Clone child transcript messages including the current streaming assistant tail. */
229
243
  snapshotActivityMessages(): AgentMessage[];
244
+ /** Select the bounded process-local transcript and its real visible tool definitions. */
245
+ snapshotActivityTranscript?(): ChildAgentTranscriptSnapshot;
230
246
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean;
231
247
  getUsage(): Usage | undefined;
232
248
  }