@signalridge/pi-subagents 1.11.1 → 1.12.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,24 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Native ancestry, including metadata that does not participate in model context. */
5
+ export function captureQuestionOrigin(ctx: ExtensionContext): (current: ExtensionContext | undefined) => boolean {
6
+ const manager = ctx.sessionManager;
7
+ const sessionId = manager.getSessionId();
8
+ const prefix = manager.getBranch().map(entry => createHash("sha256").update(JSON.stringify(entry)).digest("hex"));
9
+ let retired = false;
10
+ return current => {
11
+ if (retired) return false;
12
+ try {
13
+ const branch = manager.getBranch();
14
+ const valid = current !== undefined && current.sessionManager === manager &&
15
+ manager.getSessionId() === sessionId && branch.length >= prefix.length &&
16
+ prefix.every((hash, index) => createHash("sha256").update(JSON.stringify(branch[index])).digest("hex") === hash);
17
+ if (!valid) retired = true;
18
+ return valid;
19
+ } catch {
20
+ retired = true;
21
+ return false;
22
+ }
23
+ };
24
+ }
package/src/supervisor.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * subagent that hit a genuine fork in the road could only guess, finish wrong,
6
6
  * and have the guess discovered later in its result.
7
7
  *
8
- * The answer comes from the HUMAN, not from a supervising model. Our subagents
8
+ * By default the answer comes from the HUMAN. Our subagents
9
9
  * share the parent's `ExtensionContext`, so the parent's UI is directly
10
10
  * reachable — asking the person who is sitting there is both cheaper and more
11
11
  * correct than delegating the judgement to another model, and it is the same
@@ -13,10 +13,14 @@
13
13
  *
14
14
  * That reachability is also why this is ~80 lines rather than a filesystem
15
15
  * channel with a poller on each end: in-process, the question is one promise.
16
+ * Explicit background-only parent mode delegates ordinary clarification to the
17
+ * existing main session through parent-questions.ts; permission/approval tools
18
+ * still reach the human and no separate supervising model is created.
16
19
  */
17
20
 
18
21
  import { defineTool } from "@earendil-works/pi-coding-agent";
19
22
  import { Type } from "@sinclair/typebox";
23
+ import type { ParentQuestionHandler } from "./parent-questions.js";
20
24
  import { sanitizeDisplayText, truncateCodePoints } from "./ui/safe-text.js";
21
25
 
22
26
  /** Longest question a child may put to the parent, in characters. */
@@ -38,6 +42,8 @@ export interface SupervisorToolContext {
38
42
  ask?: SupervisorAsk;
39
43
  /** Actual type/name of the asking agent, for the prompt title. */
40
44
  agentLabel: string;
45
+ /** Explicit background-only ordinary-clarification channel, not approval. */
46
+ parentQuestion?: ParentQuestionHandler;
41
47
  }
42
48
 
43
49
  function textResult(text: string, isError = false) {
@@ -45,26 +51,30 @@ function textResult(text: string, isError = false) {
45
51
  }
46
52
 
47
53
  /**
48
- * Build the `contact_supervisor` tool, or nothing when no human can answer.
54
+ * Build `contact_supervisor`, or nothing without a human or explicit parent channel.
49
55
  *
50
56
  * Returning an empty array rather than a tool that always fails is deliberate,
51
57
  * and matches how nested tools are handled: injecting a tool whose every call
52
58
  * is an error spends context to teach the model an affordance it does not have.
53
- * Headless and RPC sessions therefore see no such tool at all.
59
+ * Default headless sessions therefore see no such tool. An explicit parent
60
+ * channel can answer ordinary clarification without a human UI.
54
61
  */
55
62
  export function createSupervisorTool(context: SupervisorToolContext) {
56
- if (!context.ask) return [];
63
+ if (!context.ask && !context.parentQuestion) return [];
57
64
  const ask = context.ask;
58
65
 
59
66
  return [
60
67
  defineTool({
61
68
  name: "contact_supervisor",
62
69
  label: "Ask Supervisor",
63
- description:
64
- "Ask the human who started you a question and wait for their answer. Use ONLY for a decision you cannot make yourself and cannot defer: an ambiguous requirement where the readings lead to materially different work, a destructive step that needs confirmation, or missing information nothing available to you can supply. It interrupts a person, so prefer stating an assumption and continuing. Returns their answer, or tells you they declined — in which case proceed on your best judgement and say what you assumed.",
70
+ description: context.parentQuestion
71
+ ? "Ask the main agent an ordinary task clarification and await its answer. Only use for missing information or ambiguous requirements you cannot resolve or defer. Never use this channel for permission, destructive-action confirmation, or approval; the main agent cannot authorize new actions or override a human denial. Prefer stating an assumption and continuing. A timeout, cancellation, or decline is no answer, not approval."
72
+ : "Ask the human who started you a question and wait for their answer. Use ONLY for a decision you cannot make yourself and cannot defer: an ambiguous requirement where the readings lead to materially different work, a destructive step that needs confirmation, or missing information nothing available to you can supply. It interrupts a person, so prefer stating an assumption and continuing. Returns their answer, or tells you they declined — in which case proceed on your best judgement and say what you assumed.",
65
73
  parameters: Type.Object({
66
74
  question: Type.String({
67
- description: "The question, self-contained. The human cannot see your conversation.",
75
+ description: context.parentQuestion
76
+ ? "A self-contained ordinary clarification. Only this explicit bounded question reaches the main agent; do not include credentials or request permission."
77
+ : "The question, self-contained. The human cannot see your conversation.",
68
78
  maxLength: MAX_QUESTION,
69
79
  }),
70
80
  options: Type.Optional(
@@ -74,7 +84,17 @@ export function createSupervisorTool(context: SupervisorToolContext) {
74
84
  }),
75
85
  ),
76
86
  }),
77
- execute: async (_toolCallId, params) => {
87
+ execute: async (_toolCallId, params, signal) => {
88
+ if (context.parentQuestion) {
89
+ const reply = await context.parentQuestion(params.question, params.options, signal);
90
+ return {
91
+ content: [{ type: "text" as const, text: reply.status === "answered"
92
+ ? `Main agent clarification (NOT human approval or a permission grant): ${reply.answer}`
93
+ : `No answer (${reply.reason}). Proceed only within existing authorization, state any safe assumption, or stop if human authorization is required.` }],
94
+ details: reply,
95
+ };
96
+ }
97
+ if (!ask) return textResult("No supervisor is available.", true);
78
98
  // Both the question and any option labels are model-authored text about
79
99
  // to be drawn into the user's terminal, so they are sanitized and
80
100
  // bounded here — the same treatment any other child-supplied string
@@ -7,10 +7,11 @@
7
7
  * sanitizer on the way out.
8
8
  */
9
9
 
10
+ import { sanitizeDisplayText } from "@signalridge/pi-ui";
10
11
  import { getConfig } from "../agent-types.js";
11
12
  import type { AgentInvocation, SubagentType } from "../types.js";
12
13
  import type { LifetimeUsage, SessionLike } from "../usage.js";
13
- import { sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
14
+ import { truncateCodePoints } from "./safe-text.js";
14
15
 
15
16
  // ---- Constants ----
16
17
 
@@ -7,13 +7,14 @@
7
7
 
8
8
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
9
  import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { sanitizeDisplayText } from "@signalridge/pi-ui";
10
11
  import { renderAgentName } from "../agent-color.js";
11
12
  import { extractText } from "../context.js";
12
13
  import type { AgentRecordSnapshot } from "../types.js";
13
14
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
14
15
  import type { Theme } from "./agent-display.js";
15
16
  import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
16
- import { PREVIEW_SCAN_LIMIT, safeTerminalText, sanitizeDisplayText, truncateCodePoints } from "./safe-text.js";
17
+ import { PREVIEW_SCAN_LIMIT, safeTerminalText, truncateCodePoints } from "./safe-text.js";
17
18
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
18
19
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
19
20
 
@@ -56,6 +57,10 @@ function isBashExecutionMessage(message: { role: string }): message is BashExecu
56
57
  && typeof message.output === "string";
57
58
  }
58
59
 
60
+ // biome-ignore lint/suspicious/noConfusingVoidType: preserve existing void-returning steering callbacks.
61
+ export type SteerAcceptance = boolean | void;
62
+ export type SteerHandler = (message: string) => SteerAcceptance | Promise<SteerAcceptance>;
63
+
59
64
  export class ConversationViewer implements Component {
60
65
  private scrollOffset = 0;
61
66
  private autoScroll = true;
@@ -67,6 +72,8 @@ export class ConversationViewer implements Component {
67
72
  private keys: ViewerKeys;
68
73
  /** Steering composer — present while the user is typing a message to the agent. */
69
74
  private composer: Input | undefined;
75
+ private steerError: string | undefined;
76
+ private sending = false;
70
77
 
71
78
  constructor(
72
79
  private tui: TUI,
@@ -80,7 +87,7 @@ export class ConversationViewer implements Component {
80
87
  /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
81
88
  keybindings?: ViewerKeybindings,
82
89
  /** Send a steering message to the agent. Omitted → no compose affordance. */
83
- private onSteer?: (message: string) => void,
90
+ private onSteer?: SteerHandler,
84
91
  /** Retrieve a fresh public snapshot so the viewer reflects status changes. */
85
92
  private getRecord?: () => AgentRecordSnapshot | undefined,
86
93
  ) {
@@ -92,16 +99,19 @@ export class ConversationViewer implements Component {
92
99
  }
93
100
 
94
101
  private refreshRecord(): AgentRecordSnapshot {
95
- const current = this.getRecord?.();
96
- if (current) this.record = current;
102
+ try {
103
+ const current = this.getRecord?.();
104
+ if (current) this.record = current;
105
+ } catch { /* a disposed owner cannot erase the retained snapshot */ }
97
106
  return this.record;
98
107
  }
99
108
 
100
109
  handleInput(data: string): void {
110
+ if (this.closed) return;
101
111
  // While composing a steer message, the input owns all keys (Enter sends,
102
112
  // Esc cancels — both wired in openComposer()). Editing keys flow through.
103
113
  if (this.composer) {
104
- this.composer.handleInput(data);
114
+ if (!this.sending || matchesKey(data, "escape")) this.composer.handleInput(data);
105
115
  this.tui.requestRender();
106
116
  return;
107
117
  }
@@ -206,9 +216,9 @@ export class ConversationViewer implements Component {
206
216
  const headerStats: string[] = [duration];
207
217
  const toolUses = this.activity?.toolUses ?? this.record.toolUses;
208
218
  if (toolUses > 0) headerStats.unshift(`tools ${toolUses}`);
209
- const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
210
- if (tokens > 0) {
211
- const percent = getSessionContextPercent(this.activity?.session);
219
+ const tokens = getLifetimeTotal(this.activity?.lifetimeUsage ?? this.record.lifetimeUsage);
220
+ if (tokens > 0 || this.record.compactionCount > 0) {
221
+ const percent = getSessionContextPercent(this.activity?.session ?? this.session);
212
222
  headerStats.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
213
223
  }
214
224
 
@@ -245,10 +255,8 @@ export class ConversationViewer implements Component {
245
255
  const renderedComposer = this.composer.render(innerW)[0] ?? "";
246
256
  const composerLine = renderedComposer.startsWith("> ") ? renderedComposer.slice(2) : renderedComposer;
247
257
  lines.push(row(composerLine));
248
- const composeHint = th.fg("dim", "Enter send · Esc cancel");
249
- const composeLeft = th.fg("accent", "chat");
250
- const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
251
- lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
258
+ const composeHint = this.sending ? "Sending..." : this.steerError ?? "Enter send · Esc cancel";
259
+ lines.push(row(th.fg(this.steerError ? "error" : "dim", composeHint)));
252
260
  } else {
253
261
  const sep = th.fg("dim", " · ");
254
262
  const actions: string[] = [];
@@ -278,13 +286,13 @@ export class ConversationViewer implements Component {
278
286
  /** Stoppable only when a stop handler exists and the agent is still active. */
279
287
  private isStoppable(): boolean {
280
288
  const record = this.refreshRecord();
281
- return !!this.onStop && (record.status === "running" || record.status === "queued");
289
+ return !record.detached && !!this.onStop && (record.status === "running" || record.status === "queued");
282
290
  }
283
291
 
284
292
  /** Steerable only when a steer handler exists and the agent is still active. */
285
293
  private canSteer(): boolean {
286
294
  const record = this.refreshRecord();
287
- return !!this.onSteer && (record.status === "running" || record.status === "queued");
295
+ return !record.detached && !!this.onSteer && (record.status === "running" || record.status === "queued");
288
296
  }
289
297
 
290
298
  /** Open the inline steering composer and route subsequent input to it. */
@@ -293,12 +301,34 @@ export class ConversationViewer implements Component {
293
301
  input.focused = true;
294
302
  input.onSubmit = (value: string) => {
295
303
  const message = value.trim();
296
- this.composer = undefined;
297
- if (message) this.onSteer?.(message);
304
+ if (!message) { this.composer = undefined; this.tui.requestRender(); return; }
305
+ if (!this.canSteer()) {
306
+ this.steerError = "Agent finished; draft retained · Esc cancel";
307
+ this.tui.requestRender();
308
+ return;
309
+ }
310
+ this.steerError = undefined;
311
+ const settle = (accepted: SteerAcceptance): void => {
312
+ if (this.closed || this.composer !== input) return;
313
+ this.sending = false;
314
+ if (accepted === false) this.steerError = "Steering rejected; draft retained · Esc cancel";
315
+ else this.composer = undefined;
316
+ this.tui.requestRender();
317
+ };
318
+ const reject = (): void => settle(false);
319
+ try {
320
+ const result = this.onSteer?.(message);
321
+ if (result && typeof result === "object") {
322
+ this.sending = true;
323
+ void result.then(settle, reject);
324
+ } else settle(result);
325
+ } catch { reject(); }
298
326
  this.tui.requestRender();
299
327
  };
300
328
  input.onEscape = () => {
301
329
  this.composer = undefined;
330
+ this.steerError = undefined;
331
+ this.sending = false;
302
332
  this.tui.requestRender();
303
333
  };
304
334
  this.composer = input;
@@ -336,14 +366,15 @@ export class ConversationViewer implements Component {
336
366
  const { modelName, tags } = buildInvocationTags(this.record.invocation);
337
367
  const parts = modelName ? [modelName, ...tags] : tags;
338
368
  if (parts.length === 0) return undefined;
339
- return this.theme.fg("dim", ` invocation · ${parts.join(" · ")}`);
369
+ return this.theme.fg("dim", ` invocation · ${parts.map(part => sanitizeDisplayText(part)).join(" · ")}`);
340
370
  }
341
371
 
342
372
  private buildContentLines(width: number): string[] {
343
373
  if (width <= 0) return [];
344
374
 
345
375
  const th = this.theme;
346
- const messages = this.session.messages;
376
+ let messages: AgentSession["messages"];
377
+ try { messages = this.session.messages; } catch { messages = []; }
347
378
  const lines: string[] = [];
348
379
 
349
380
  if (messages.length === 0) {
@@ -11,15 +11,15 @@
11
11
  * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
12
12
  */
13
13
 
14
- import { Editor, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
- import { borderedComponent } from "@signalridge/pi-ui";
14
+ import { type Component, isKeyRelease, Key, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { borderedComponent, sanitizeDisplayText } from "@signalridge/pi-ui";
16
16
  import { renderAgentName } from "../agent-color.js";
17
17
  import type { AgentManager } from "../agent-manager.js";
18
18
  import type { AgentRecord } from "../types.js";
19
19
  import { getLifetimeTotal } from "../usage.js";
20
20
  import { type AgentActivity, type Theme } from "./agent-display.js";
21
21
  import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
22
- import { sanitizeDisplayText } from "./safe-text.js";
22
+ import { type OwnerGroup, OwnerGroupViewer, workflowGroupId } from "./owner-group.js";
23
23
  import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
24
24
  import type { ViewerKeybindings } from "./viewer-keys.js";
25
25
 
@@ -48,9 +48,10 @@ export type FleetUICtx = {
48
48
  ): Promise<T>;
49
49
  };
50
50
 
51
- type MainEntry = { kind: "main" };
52
- type AgentEntry = { kind: "agent"; record: AgentRecord };
53
- type FleetEntry = MainEntry | AgentEntry;
51
+ type MainEntry = { kind: "main"; id: string };
52
+ type AgentEntry = { kind: "agent"; id: string; record: AgentRecord };
53
+ type GroupEntry = { kind: "group"; id: string; group: OwnerGroup };
54
+ type FleetEntry = MainEntry | AgentEntry | GroupEntry;
54
55
 
55
56
  /** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
56
57
  export function formatFleetElapsed(ms: number): string {
@@ -91,6 +92,8 @@ export class FleetList {
91
92
  private active = false;
92
93
  /** 0 = `main`, 1..N = subagents. */
93
94
  private selectedIndex = 0;
95
+ private selectedId = "main";
96
+ private overlayGeneration = 0;
94
97
  /** Set while a conversation overlay is open; calling it closes the overlay. */
95
98
  private viewerClose: (() => void) | undefined;
96
99
  private viewingAgentId: string | undefined;
@@ -112,7 +115,7 @@ export class FleetList {
112
115
  /** Capture the UI context and (re)register the global input handler. */
113
116
  setUICtx(ui: FleetUICtx): void {
114
117
  if (ui === this.ui) return;
115
- this.inputUnsub?.();
118
+ this.dispose();
116
119
  this.ui = ui;
117
120
  this.widgetRegistered = false;
118
121
  this.tui = undefined;
@@ -133,6 +136,7 @@ export class FleetList {
133
136
  }
134
137
 
135
138
  dispose(): void {
139
+ this.overlayGeneration++;
136
140
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
137
141
  this.inputUnsub?.();
138
142
  this.inputUnsub = undefined;
@@ -149,7 +153,7 @@ export class FleetList {
149
153
  /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
150
154
  update(): void {
151
155
  if (!this.ui) return;
152
- const hasAgents = this.enabled && this.agentRecords().length > 0;
156
+ const hasAgents = this.enabled && this.roster().length > 1;
153
157
 
154
158
  if (!hasAgents) {
155
159
  if (this.widgetRegistered) {
@@ -160,6 +164,7 @@ export class FleetList {
160
164
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
161
165
  this.active = false;
162
166
  this.selectedIndex = 0;
167
+ this.selectedId = "main";
163
168
  return;
164
169
  }
165
170
 
@@ -171,7 +176,8 @@ export class FleetList {
171
176
  this.tui = tui;
172
177
  return {
173
178
  render: (w: number) => this.renderBar(w, theme),
174
- invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
179
+ invalidate: () => { /* no render cache; mounting/focus remain owned */ },
180
+ dispose: () => { if (this.tui === tui) { this.widgetRegistered = false; this.tui = undefined; } },
175
181
  };
176
182
  }, { placement: "belowEditor" });
177
183
  this.widgetRegistered = true;
@@ -193,7 +199,7 @@ export class FleetList {
193
199
  private agentRecords(): AgentRecord[] {
194
200
  const now = Date.now();
195
201
  return this.manager.listAgentsMutable()
196
- .filter(a => !a.parentAgentId && a.session && (
202
+ .filter(a => !a.parentAgentId && !a.detached && !workflowGroupId(a) && a.session && (
197
203
  a.status === "running" || a.status === "queued"
198
204
  || a.id === this.viewingAgentId
199
205
  || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
@@ -201,14 +207,39 @@ export class FleetList {
201
207
  .sort((a, b) => a.startedAt - b.startedAt);
202
208
  }
203
209
 
210
+ private groups(): OwnerGroup[] {
211
+ const groups = new Map<string, OwnerGroup>();
212
+ for (const record of this.manager.listAgentsMutable()) {
213
+ if (record.parentAgentId || record.detached) continue;
214
+ const id = workflowGroupId(record);
215
+ if (!id) continue;
216
+ let group = groups.get(id);
217
+ if (!group) { group = { id, runId: record.owner!.runId, records: [] }; groups.set(id, group); }
218
+ group.records.push(record);
219
+ }
220
+ for (const group of groups.values()) group.records.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
221
+ return [...groups.values()];
222
+ }
223
+
204
224
  private roster(): FleetEntry[] {
205
- return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))];
225
+ return [
226
+ { kind: "main", id: "main" },
227
+ ...this.groups().map(group => ({ kind: "group" as const, id: `group:${group.id}`, group })),
228
+ ...this.agentRecords().map(record => ({ kind: "agent" as const, id: `agent:${record.id}`, record })),
229
+ ];
206
230
  }
207
231
 
208
- private clampSelection(): void {
209
- const max = this.roster().length - 1;
210
- if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
211
- if (this.selectedIndex < 0) this.selectedIndex = 0;
232
+ private clampSelection(roster = this.roster()): void {
233
+ const index = roster.findIndex(entry => entry.id === this.selectedId);
234
+ // Missing identity returns explicitly to main, never to a different child.
235
+ this.selectedIndex = index >= 0 ? index : 0;
236
+ this.selectedId = roster[this.selectedIndex].id;
237
+ }
238
+
239
+ private select(index: number): void {
240
+ const roster = this.roster();
241
+ this.selectedIndex = Math.max(0, Math.min(roster.length - 1, index));
242
+ this.selectedId = roster[this.selectedIndex].id;
212
243
  }
213
244
 
214
245
  // ---- Key handling ----
@@ -234,25 +265,27 @@ export class FleetList {
234
265
  if (!this.active) {
235
266
  // Activate: ↓ or ← at an empty prompt moves focus into the list.
236
267
  const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
237
- if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
268
+ if (isActivator && this.roster().length > 1 && this.ui.getEditorText() === "") {
238
269
  this.active = true;
239
- this.selectedIndex = 0;
270
+ this.select(0);
240
271
  this.update();
241
272
  return { consume: true };
242
273
  }
243
274
  return undefined;
244
275
  }
245
276
 
277
+ // Reconcile identity before both navigation and opening, even between ticks.
278
+ this.clampSelection();
246
279
  // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
247
280
  if (matchesKey(data, "down")) {
248
281
  const max = this.roster().length - 1;
249
- this.selectedIndex = Math.min(max, this.selectedIndex + 1);
282
+ this.select(Math.min(max, this.selectedIndex + 1));
250
283
  this.update();
251
284
  return { consume: true };
252
285
  }
253
286
  if (matchesKey(data, "up")) {
254
287
  if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
255
- this.selectedIndex -= 1;
288
+ this.select(this.selectedIndex - 1);
256
289
  this.update();
257
290
  return { consume: true };
258
291
  }
@@ -265,64 +298,91 @@ export class FleetList {
265
298
  }
266
299
 
267
300
  /**
268
- * True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
269
- * subclass (CustomEditor) while every dialog/selector is not, and the loader
270
- * aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
271
- * check. `focusedComponent` is TUI-private (no public accessor), hence the
272
- * best-effort peek: unknowable focus (no tui seen yet, nothing focused)
273
- * counts as the editor so activation keeps working.
301
+ * True when pi's prompt editor owns the keyboard. The editor is pluggable
302
+ * (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
303
+ * `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
304
+ * every editor and none of pi's dialogs, selectors or overlays implement.
305
+ * Use the public structural focus getter (not present in every TUI interface
306
+ * declaration). Unknown focus retains the initial editor fallback.
274
307
  */
275
308
  private editorHasFocus(): boolean {
276
- const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
277
- return focused == null || focused instanceof Editor;
309
+ const focusHost = this.tui as { getFocusedComponent?: () => unknown } | undefined;
310
+ const focused = typeof focusHost?.getFocusedComponent === "function" ? focusHost.getFocusedComponent() : undefined;
311
+ if (focused == null) return true;
312
+ const c = focused as { getText?: unknown; setText?: unknown };
313
+ return typeof c.getText === "function" && typeof c.setText === "function";
278
314
  }
279
315
 
280
316
  private deactivate(): void {
281
317
  this.active = false;
282
- this.selectedIndex = 0;
318
+ this.select(0);
283
319
  this.update();
284
320
  }
285
321
 
286
322
  private openSelected(): void {
287
- const entry = this.roster()[this.selectedIndex];
323
+ const roster = this.roster();
324
+ this.clampSelection(roster);
325
+ const entry = roster[this.selectedIndex];
288
326
  if (!entry || entry.kind === "main") {
289
327
  // `main` = return to the prompt; the native transcript is already shown.
290
328
  this.deactivate();
291
329
  return;
292
330
  }
293
- const record = entry.record;
294
331
  if (!this.ui) return;
332
+ if (entry.kind === "group") { this.openGroup(entry.group); return; }
333
+ const record = entry.record;
295
334
  if (!record.session) {
296
335
  this.ui.notify(`Agent is ${record.status}; no session available.`, "info");
297
336
  return;
298
337
  }
299
- const session = record.session;
300
- const activity = this.agentActivity.get(record.id);
301
338
  this.viewingAgentId = record.id;
339
+ void this.openOverlay((tui, theme, keys, done) => this.conversation(record, tui, theme, keys, done));
340
+ }
341
+
342
+ private conversation(record: AgentRecord, tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void): ConversationViewer {
343
+ const session = record.session;
344
+ if (!session) throw new Error("No session available");
345
+ const owner = record.owner ? { ...record.owner } : undefined;
346
+ // An id can be reused for another thread attempt. Bind controls to this
347
+ // session and immutable owner generation, never to id alone.
348
+ const current = (): AgentRecord | undefined => this.manager.listAgentsMutable().find(candidate =>
349
+ candidate.id === record.id && candidate.session === session && !candidate.detached &&
350
+ candidate.owner?.extension === owner?.extension && candidate.owner?.runId === owner?.runId &&
351
+ candidate.owner?.nodeId === owner?.nodeId && candidate.owner?.attemptId === owner?.attemptId);
352
+ return new ConversationViewer(tui, session, record, this.agentActivity.get(record.id), theme, done,
353
+ () => { if (current() && this.manager.abort(record.id)) this.ui?.notify(`Stopped "${sanitizeDisplayText(record.description)}".`, "info"); },
354
+ keys,
355
+ message => !!current() && this.manager.steer(record.id, message),
356
+ () => current() ?? { ...record, status: record.status === "running" || record.status === "queued" ? "stopped" : record.status, detached: true },
357
+ );
358
+ }
302
359
 
303
- void this.ui.custom<undefined>(
304
- (tui, theme, keybindings, done) => {
360
+ private openGroup(group: OwnerGroup): void {
361
+ void this.openOverlay((tui, theme, keys, done) => new OwnerGroupViewer(tui, theme, group.runId,
362
+ () => this.groups().find(candidate => candidate.id === group.id)?.records ?? [],
363
+ (record, back) => this.conversation(record, tui, theme, keys, back),
364
+ () => done(undefined), keys));
365
+ }
366
+
367
+ private async openOverlay(factory: (tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void) => Component): Promise<void> {
368
+ const ui = this.ui;
369
+ if (!ui) return;
370
+ const generation = ++this.overlayGeneration;
371
+ this.viewerClose = () => {}; // reserve input during asynchronous construction
372
+ try {
373
+ await ui.custom<undefined>((tui, theme, keys, done) => {
374
+ if (generation !== this.overlayGeneration || ui !== this.ui) {
375
+ done(undefined);
376
+ return { render: () => [], invalidate: () => {} };
377
+ }
305
378
  this.viewerClose = () => done(undefined);
306
- return borderedComponent(new ConversationViewer(
307
- tui,
308
- session,
309
- record,
310
- activity,
311
- theme,
312
- done,
313
- () => {
314
- if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${sanitizeDisplayText(record.description)}".`, "info");
315
- },
316
- keybindings,
317
- (message: string) => this.manager.steer(record.id, message),
318
- () => this.manager.getRecord?.(record.id),
319
- ), (text) => theme.fg("borderAccent", text));
320
- },
321
- {
322
- overlay: true,
323
- overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
324
- },
325
- ).then(() => this.clearViewer(), () => this.clearViewer());
379
+ return borderedComponent(factory(tui, theme, keys, done), text => theme.fg("borderAccent", text));
380
+ }, { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` } });
381
+ } catch {
382
+ if (generation === this.overlayGeneration) ui.notify("Cannot open conversation; try again.", "error");
383
+ } finally {
384
+ if (generation === this.overlayGeneration) this.clearViewer();
385
+ }
326
386
  }
327
387
 
328
388
  /** Reset overlay state and return to the list (on close, auto-close, or error). */
@@ -333,7 +393,7 @@ export class FleetList {
333
393
  // update()'s clamp to settle.
334
394
  if (this.viewingAgentId) {
335
395
  const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
336
- if (idx >= 0) this.selectedIndex = idx;
396
+ if (idx >= 0) this.select(idx);
337
397
  }
338
398
  this.viewerClose = undefined;
339
399
  this.viewingAgentId = undefined;
@@ -343,9 +403,11 @@ export class FleetList {
343
403
  // ---- Rendering ----
344
404
 
345
405
  private renderBar(width: number, theme: Theme): string[] {
346
- const agents = this.roster().slice(1) as AgentEntry[];
406
+ const roster = this.roster();
407
+ this.clampSelection(roster);
408
+ const agents = roster.slice(1);
347
409
  if (agents.length === 0) return [];
348
- const sel = Math.min(this.selectedIndex, agents.length);
410
+ const sel = this.selectedIndex;
349
411
 
350
412
  // Key names read as proper nouns, the verbs after them stay lowercase, and
351
413
  // segments are separated by " · " — the same shape every hint line in the
@@ -372,7 +434,12 @@ export class FleetList {
372
434
 
373
435
  if (start > 0) lines.push(rightAlign("", theme.fg("dim", `${start} more above`), width));
374
436
  for (let a = start; a < start + visible; a++) {
375
- lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
437
+ const entry = agents[a];
438
+ if (entry.kind === "agent") lines.push(this.renderAgentRow(a + 1, sel, entry.record, width, theme));
439
+ else if (entry.kind === "group") {
440
+ const label = ` ▸ Workflow ${sanitizeDisplayText(entry.group.runId)} · ${entry.group.records.length} retained children`;
441
+ lines.push(truncateToWidth(theme.fg(a + 1 === sel ? "accent" : "muted", label), width));
442
+ }
376
443
  }
377
444
  if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
378
445