@gotgenes/pi-subagents 17.1.0 → 17.3.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.
@@ -20,7 +20,7 @@ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
20
20
  import { NotificationState } from "#src/observation/notification-state";
21
21
  import { subscribeSubagentObserver } from "#src/observation/record-observer";
22
22
  import type { RunConfig } from "#src/runtime";
23
- import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
23
+ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
24
24
 
25
25
  /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
26
26
  export interface SubagentLifecycleObserver {
@@ -162,6 +162,11 @@ export class Subagent {
162
162
  return this.subagentSession?.messages ?? [];
163
163
  }
164
164
 
165
+ /** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
166
+ get agentMessages(): readonly SessionMessage[] {
167
+ return this.subagentSession?.agentMessages ?? [];
168
+ }
169
+
165
170
  constructor(init: SubagentInit) {
166
171
  // Identity
167
172
  this.id = init.id;
package/src/types.ts CHANGED
@@ -3,13 +3,22 @@
3
3
  */
4
4
 
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
- import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@earendil-works/pi-coding-agent";
7
7
  import type { ModelRegistry } from "#src/session/model-resolver";
8
8
 
9
9
 
10
10
  export { Subagent } from "#src/lifecycle/subagent";
11
11
  export type { AgentSessionEvent, ThinkingLevel };
12
12
 
13
+ /**
14
+ * One message in a child session's history, typed from Pi's `SessionContext`.
15
+ *
16
+ * Derived from the barrel-exported `SessionContext` (whose `messages` field is
17
+ * `AgentMessage[]`) so the package needs no direct dependency on
18
+ * `@earendil-works/pi-agent-core`, which is not re-exported from the public barrel.
19
+ */
20
+ export type SessionMessage = SdkSessionContext["messages"][number];
21
+
13
22
  /**
14
23
  * Narrow session interface for event subscription.
15
24
  * Used by record-observer — only the subscribe method is needed.
@@ -148,6 +148,16 @@ export class AgentWidget implements SubagentManagerObserver {
148
148
  return age < maxAge;
149
149
  }
150
150
 
151
+ /**
152
+ * Background agents only — the widget's sole audience (ADR-0004 Decision A).
153
+ * Foreground runs are rendered by the `subagent` tool's inline `onUpdate` stream,
154
+ * so funneling both `listAgents()` call sites through this accessor applies the
155
+ * background predicate exactly once at the source.
156
+ */
157
+ private listBackgroundAgents(): Subagent[] {
158
+ return this.manager.listAgents().filter(record => record.invocation?.runInBackground === true);
159
+ }
160
+
151
161
  /** Project a live Subagent record onto a pure-data WidgetAgent snapshot. */
152
162
  private toWidgetAgent(record: Subagent): WidgetAgent {
153
163
  return {
@@ -172,7 +182,7 @@ export class AgentWidget implements SubagentManagerObserver {
172
182
  /** Delegate rendering to the pure widget-renderer module. */
173
183
  private renderWidget(tui: any, theme: Theme): string[] {
174
184
  return renderWidgetLines({
175
- agents: this.manager.listAgents().map(r => this.toWidgetAgent(r)),
185
+ agents: this.listBackgroundAgents().map(r => this.toWidgetAgent(r)),
176
186
  registry: this.registry,
177
187
  spinnerFrame: this.widgetFrame,
178
188
  terminalWidth: tui.terminal.columns,
@@ -183,10 +193,10 @@ export class AgentWidget implements SubagentManagerObserver {
183
193
 
184
194
  /**
185
195
  * Unregister the widget, clear the status bar, stop the interval timer, and
186
- * purge stale `finishedTurnAge` entries for agents no longer in `allAgents`.
196
+ * purge stale `finishedTurnAge` entries for agents no longer in `backgroundAgents`.
187
197
  * Called only from `update`'s idle path — not from `dispose`.
188
198
  */
189
- private clearWidget(allAgents: readonly AgentSummary[]): void {
199
+ private clearWidget(backgroundAgents: readonly AgentSummary[]): void {
190
200
  if (this.widgetRegistered) {
191
201
  this.uiCtx!.setWidget("agents", undefined);
192
202
  this.widgetRegistered = false;
@@ -198,7 +208,7 @@ export class AgentWidget implements SubagentManagerObserver {
198
208
  }
199
209
  if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
200
210
  for (const [id] of this.finishedTurnAge) {
201
- if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
211
+ if (!backgroundAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
202
212
  }
203
213
  }
204
214
 
@@ -240,12 +250,12 @@ export class AgentWidget implements SubagentManagerObserver {
240
250
  update() {
241
251
  if (!this.uiCtx) return;
242
252
 
243
- const allAgents = this.manager.listAgents();
244
- this.seedFinishedAgents(allAgents);
245
- const state = assembleWidgetState(allAgents, (id, status) => this.shouldShowFinished(id, status));
253
+ const backgroundAgents = this.listBackgroundAgents();
254
+ this.seedFinishedAgents(backgroundAgents);
255
+ const state = assembleWidgetState(backgroundAgents, (id, status) => this.shouldShowFinished(id, status));
246
256
 
247
257
  if (!state.hasActive && !state.hasFinished) {
248
- this.clearWidget(allAgents);
258
+ this.clearWidget(backgroundAgents);
249
259
  return;
250
260
  }
251
261
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
3
+ *
4
+ * Splits the unit-testable core of the `/subagent-sessions` command from its TUI
5
+ * wiring (`session-navigator.ts`): which subagents are navigable, how a picked
6
+ * agent's transcript is sourced (live, in this slice), and how the transcript
7
+ * renders to plain text via Pi's own `serializeConversation`.
8
+ *
9
+ * The `TranscriptSource` seam decouples *how messages are sourced* (live record
10
+ * here; a file snapshot in a follow-up) from *how they render* (text here; Pi's
11
+ * per-entry components in a follow-up). The renderer talks only to this seam.
12
+ */
13
+
14
+ import { serializeConversation } from "@earendil-works/pi-coding-agent";
15
+ import type { AgentConfigLookup } from "#src/config/agent-types";
16
+ import type { SubagentStatus } from "#src/lifecycle/subagent-state";
17
+ import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
18
+ import { describeActivity, formatDuration, getDisplayName } from "#src/ui/display";
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ /** The record fields the navigator reads to label and live-source a transcript. */
23
+ export interface NavigableSubagent {
24
+ readonly id: string;
25
+ readonly type: SubagentType;
26
+ readonly description: string;
27
+ readonly status: SubagentStatus;
28
+ readonly startedAt: number;
29
+ readonly completedAt: number | undefined;
30
+ readonly toolUses: number;
31
+ readonly activeTools: ReadonlyMap<string, string>;
32
+ readonly responseText: string;
33
+ readonly agentMessages: readonly SessionMessage[];
34
+ isSessionReady(): boolean;
35
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
36
+ }
37
+
38
+ /** A navigable entry: a record plus the label shown in the picker. */
39
+ export interface NavigationEntry {
40
+ readonly record: NavigableSubagent;
41
+ readonly label: string;
42
+ }
43
+
44
+ /** Running-agent streaming state, surfaced by a live source. */
45
+ export interface StreamingState {
46
+ readonly activeTools: ReadonlyMap<string, string>;
47
+ readonly responseText: string;
48
+ }
49
+
50
+ /** Liveness-agnostic transcript source consumed by the renderer. */
51
+ export interface TranscriptSource {
52
+ /** Current message history. */
53
+ getMessages(): readonly SessionMessage[];
54
+ /** Subscribe to changes; returns an unsubscribe, or undefined for a static snapshot. */
55
+ subscribe(onChange: () => void): (() => void) | undefined;
56
+ /** Running-agent streaming state, or undefined when not streaming. */
57
+ streaming(): StreamingState | undefined;
58
+ }
59
+
60
+ /** Filter the agents to those with a viewable session and label each for the picker. */
61
+ export function listNavigableAgents(
62
+ agents: readonly NavigableSubagent[],
63
+ registry: AgentConfigLookup,
64
+ ): NavigationEntry[] {
65
+ return agents
66
+ .filter((record) => record.isSessionReady())
67
+ .map((record) => ({ record, label: buildLabel(record, registry) }));
68
+ }
69
+
70
+ /** Source a transcript live from an in-memory record (this slice's only source). */
71
+ export function liveSource(record: NavigableSubagent): TranscriptSource {
72
+ return {
73
+ getMessages: () => record.agentMessages,
74
+ subscribe: (onChange) => record.subscribeToUpdates(() => onChange()),
75
+ streaming: () =>
76
+ record.status === "running"
77
+ ? { activeTools: record.activeTools, responseText: record.responseText }
78
+ : undefined,
79
+ };
80
+ }
81
+
82
+ /** Render a source's transcript to plain text lines via Pi's `serializeConversation`. */
83
+ export function renderTranscriptLines(source: TranscriptSource): string[] {
84
+ const messages = source.getMessages();
85
+ const lines =
86
+ messages.length === 0 ? ["(no messages yet)"] : serializeConversation(toMessages(messages)).split("\n");
87
+
88
+ const streaming = source.streaming();
89
+ if (streaming) {
90
+ lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
91
+ }
92
+ return lines;
93
+ }
94
+
95
+ /**
96
+ * Bridge the session's `AgentMessage[]` to `serializeConversation`'s `Message[]`.
97
+ *
98
+ * `AgentMessage` is a superset of `Message` (it adds session-display variants such
99
+ * as `BashExecutionMessage`); `serializeConversation` renders the shared shape and
100
+ * best-effort text for the rest. `Message` is not re-exported from the public
101
+ * `@earendil-works/pi-ai` barrel, so the parameter type is referenced via the
102
+ * function signature rather than imported by name.
103
+ */
104
+ function toMessages(
105
+ messages: readonly SessionMessage[],
106
+ ): Parameters<typeof serializeConversation>[0] {
107
+ return messages as unknown as Parameters<typeof serializeConversation>[0];
108
+ }
109
+
110
+ function buildLabel(record: NavigableSubagent, registry: AgentConfigLookup): string {
111
+ const name = getDisplayName(record.type, registry);
112
+ const duration = formatDuration(record.startedAt, record.completedAt);
113
+ return `${name} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${duration}`;
114
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * session-navigator.ts — The `/subagent-sessions` command: pick a subagent and
3
+ * read its transcript through Pi's own session-rendering text.
4
+ *
5
+ * SDK/TUI consumer half of native session navigation. The unit-testable core
6
+ * (selection, sourcing, text rendering) lives in `session-navigation.ts`; this
7
+ * module wires that core to the command picker and a read-only scrollable overlay.
8
+ *
9
+ * The overlay is strictly read-only — steering stays in the `steer_subagent` tool
10
+ * and the widget. It consumes a `TranscriptSource`, so the renderer-upgrade and
11
+ * evicted-agent-source follow-ups swap the source/renderer without touching it.
12
+ */
13
+
14
+ import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
+ import type { AgentConfigLookup } from "#src/config/agent-types";
16
+ import type { Theme } from "#src/ui/display";
17
+ import {
18
+ listNavigableAgents,
19
+ liveSource,
20
+ type NavigableSubagent,
21
+ renderTranscriptLines,
22
+ type TranscriptSource,
23
+ } from "#src/ui/session-navigation";
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ /** Chrome lines: top border + header + header sep + footer sep + footer + bottom border. */
28
+ const CHROME_LINES = 6;
29
+ const MIN_VIEWPORT = 3;
30
+ const VIEWPORT_HEIGHT_PCT = 70;
31
+
32
+ /** Component factory shape Pi's `ui.custom` invokes to mount an overlay. */
33
+ export type OverlayComponentFactory<R> = (
34
+ tui: TUI,
35
+ theme: Theme,
36
+ keybindings: unknown,
37
+ done: (result: R) => void,
38
+ ) => Component;
39
+
40
+ /** Narrow UI interface — only the `ctx.ui` methods the navigator calls. */
41
+ export interface SessionNavigatorUI {
42
+ select(title: string, options: string[]): Promise<string | undefined>;
43
+ notify(message: string, level: "info" | "warning" | "error"): void;
44
+ custom<R>(component: OverlayComponentFactory<R>, options?: unknown): Promise<R>;
45
+ }
46
+
47
+ /** Parameters for one `/subagent-sessions` invocation. */
48
+ export interface SessionNavigatorParams {
49
+ ui: SessionNavigatorUI;
50
+ agents: readonly NavigableSubagent[];
51
+ registry: AgentConfigLookup;
52
+ }
53
+
54
+ /** Options for the read-only transcript overlay. */
55
+ export interface TranscriptOverlayOptions {
56
+ tui: TUI;
57
+ theme: Theme;
58
+ source: TranscriptSource;
59
+ done: (result: undefined) => void;
60
+ wrapText: (text: string, width: number) => string[];
61
+ }
62
+
63
+ /**
64
+ * Handler for the `/subagent-sessions` slash command.
65
+ *
66
+ * Lists navigable subagents, lets the operator pick one, and opens its transcript
67
+ * read-only. Receives the agent snapshot (`manager.listAgents()`) rather than the
68
+ * manager, so it stays a reactive consumer with no inbound call into the core.
69
+ */
70
+ export class SessionNavigatorHandler {
71
+ async handle({ ui, agents, registry }: SessionNavigatorParams): Promise<void> {
72
+ const entries = listNavigableAgents(agents, registry);
73
+ if (entries.length === 0) {
74
+ ui.notify("No subagent sessions to view.", "info");
75
+ return;
76
+ }
77
+
78
+ const choice = await ui.select(
79
+ "Subagent sessions",
80
+ entries.map((entry) => entry.label),
81
+ );
82
+ const entry = entries.find((candidate) => candidate.label === choice);
83
+ if (!entry) return;
84
+
85
+ const source = liveSource(entry.record);
86
+ await ui.custom<undefined>(
87
+ (tui, theme, _keybindings, done) =>
88
+ new TranscriptOverlay({ tui, theme, source, done, wrapText: wrapTextWithAnsi }),
89
+ {
90
+ overlay: true,
91
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
92
+ },
93
+ );
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Read-only scrollable transcript overlay.
99
+ *
100
+ * Re-renders on every source update (live agents); the transcript text is Pi's
101
+ * `serializeConversation` output from `renderTranscriptLines` — this class owns
102
+ * only scroll state and chrome, no message formatting.
103
+ */
104
+ export class TranscriptOverlay implements Component {
105
+ private scrollOffset = 0;
106
+ private autoScroll = true;
107
+ private unsubscribe: (() => void) | undefined;
108
+ private closed = false;
109
+
110
+ private readonly tui: TUI;
111
+ private readonly theme: Theme;
112
+ private readonly source: TranscriptSource;
113
+ private readonly done: (result: undefined) => void;
114
+ private readonly wrapText: (text: string, width: number) => string[];
115
+
116
+ constructor({ tui, theme, source, done, wrapText }: TranscriptOverlayOptions) {
117
+ this.tui = tui;
118
+ this.theme = theme;
119
+ this.source = source;
120
+ this.done = done;
121
+ this.wrapText = wrapText;
122
+ this.unsubscribe = source.subscribe(() => {
123
+ if (this.closed) return;
124
+ this.tui.requestRender();
125
+ });
126
+ }
127
+
128
+ // fallow-ignore-next-line unused-class-member
129
+ handleInput(data: string): void {
130
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
131
+ this.closed = true;
132
+ this.done(undefined);
133
+ return;
134
+ }
135
+
136
+ const totalLines = this.buildContentLines(this.innerWidth()).length;
137
+ const viewportHeight = this.viewportHeight();
138
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
139
+
140
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
141
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
142
+ this.autoScroll = this.scrollOffset >= maxScroll;
143
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
144
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
145
+ this.autoScroll = this.scrollOffset >= maxScroll;
146
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
147
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
148
+ this.autoScroll = false;
149
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
150
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
151
+ this.autoScroll = this.scrollOffset >= maxScroll;
152
+ } else if (matchesKey(data, "home")) {
153
+ this.scrollOffset = 0;
154
+ this.autoScroll = false;
155
+ } else if (matchesKey(data, "end")) {
156
+ this.scrollOffset = maxScroll;
157
+ this.autoScroll = true;
158
+ }
159
+ }
160
+
161
+ render(width: number): string[] {
162
+ if (width < 6) return [];
163
+ const th = this.theme;
164
+ const innerW = width - 4;
165
+ const lines: string[] = [];
166
+
167
+ const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
168
+ const row = (content: string): string =>
169
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
170
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
171
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
172
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
173
+
174
+ lines.push(hrTop);
175
+ lines.push(row(th.bold("Subagent session")));
176
+ lines.push(hrMid);
177
+
178
+ const contentLines = this.buildContentLines(innerW);
179
+ const viewportHeight = this.viewportHeight();
180
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
181
+ if (this.autoScroll) this.scrollOffset = maxScroll;
182
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
183
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
184
+ for (let i = 0; i < viewportHeight; i++) lines.push(row(visible[i] ?? ""));
185
+
186
+ lines.push(hrMid);
187
+ const scrollPct =
188
+ contentLines.length <= viewportHeight
189
+ ? "100%"
190
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
191
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
192
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
193
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
194
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
195
+ lines.push(hrBot);
196
+
197
+ return lines;
198
+ }
199
+
200
+ // fallow-ignore-next-line unused-class-member
201
+ invalidate(): void {
202
+ /* no cached state to clear */
203
+ }
204
+
205
+ // fallow-ignore-next-line unused-class-member
206
+ dispose(): void {
207
+ this.closed = true;
208
+ if (this.unsubscribe) {
209
+ this.unsubscribe();
210
+ this.unsubscribe = undefined;
211
+ }
212
+ }
213
+
214
+ // ---- Private ----
215
+
216
+ private innerWidth(): number {
217
+ return Math.max(0, this.tui.terminal.columns - 4);
218
+ }
219
+
220
+ private viewportHeight(): number {
221
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
222
+ return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
223
+ }
224
+
225
+ private buildContentLines(innerW: number): string[] {
226
+ if (innerW <= 0) return [];
227
+ const wrapped: string[] = [];
228
+ for (const line of renderTranscriptLines(this.source)) {
229
+ if (line === "") {
230
+ wrapped.push("");
231
+ continue;
232
+ }
233
+ wrapped.push(...this.wrapText(line, innerW));
234
+ }
235
+ return wrapped.map((l) => truncateToWidth(l, innerW));
236
+ }
237
+ }