@signalridge/pi-subagents 1.11.1 → 1.13.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/CHANGELOG.md +27 -0
- package/README.md +77 -10
- package/package.json +3 -3
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +395 -66
- package/src/agent-runner.ts +83 -35
- package/src/extension-resource-names.ts +105 -0
- package/src/index.ts +221 -69
- package/src/output-file.ts +28 -9
- package/src/parent-questions.ts +453 -0
- package/src/question-origin.ts +24 -0
- package/src/supervisor.ts +28 -8
- package/src/types.ts +4 -3
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +641 -290
- package/src/ui/fleet-list.ts +258 -66
- package/src/ui/owner-group.ts +110 -0
- package/src/ui/schedule-menu.ts +14 -7
- package/src/ui/workflow-ui-client.ts +189 -0
package/src/ui/fleet-list.ts
CHANGED
|
@@ -11,17 +11,31 @@
|
|
|
11
11
|
* can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
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
|
-
import { ConversationViewer,
|
|
22
|
-
import {
|
|
21
|
+
import { ConversationViewer, getConversationOverlayOptions, getConversationViewerMode } from "./conversation-viewer.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
|
+
import type { WorkflowUiClient } from "./workflow-ui-client.js";
|
|
26
|
+
|
|
27
|
+
/** Immutable selection, including the attempt generation on a reusable native thread. */
|
|
28
|
+
export function captureAgentSelection(manager: AgentManager, record: AgentRecord, isCurrent: () => boolean = () => true) {
|
|
29
|
+
const session = record.session;
|
|
30
|
+
const owner = record.owner ? { ...record.owner } : undefined;
|
|
31
|
+
const snapshot = { ...record, owner, lifetimeUsage: { ...record.lifetimeUsage } };
|
|
32
|
+
const current = (): AgentRecord | undefined => isCurrent() ? manager.listAgentsMutable().find(candidate =>
|
|
33
|
+
candidate.id === snapshot.id && candidate.session === session && !candidate.detached &&
|
|
34
|
+
candidate.owner?.extension === owner?.extension && candidate.owner?.runId === owner?.runId &&
|
|
35
|
+
candidate.owner?.nodeId === owner?.nodeId && candidate.owner?.attemptId === owner?.attemptId) : undefined;
|
|
36
|
+
return { snapshot, session, owner, current };
|
|
37
|
+
}
|
|
38
|
+
export type AgentSelection = ReturnType<typeof captureAgentSelection>;
|
|
25
39
|
|
|
26
40
|
/** Widget key for the below-editor fleet list. */
|
|
27
41
|
const FLEET_KEY = "fleet";
|
|
@@ -48,9 +62,10 @@ export type FleetUICtx = {
|
|
|
48
62
|
): Promise<T>;
|
|
49
63
|
};
|
|
50
64
|
|
|
51
|
-
type MainEntry = { kind: "main" };
|
|
52
|
-
type AgentEntry = { kind: "agent"; record: AgentRecord };
|
|
53
|
-
type
|
|
65
|
+
type MainEntry = { kind: "main"; id: string };
|
|
66
|
+
type AgentEntry = { kind: "agent"; id: string; record: AgentRecord };
|
|
67
|
+
type GroupEntry = { kind: "group"; id: string; group: OwnerGroup };
|
|
68
|
+
type FleetEntry = MainEntry | AgentEntry | GroupEntry;
|
|
54
69
|
|
|
55
70
|
/** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
|
|
56
71
|
export function formatFleetElapsed(ms: number): string {
|
|
@@ -91,15 +106,44 @@ export class FleetList {
|
|
|
91
106
|
private active = false;
|
|
92
107
|
/** 0 = `main`, 1..N = subagents. */
|
|
93
108
|
private selectedIndex = 0;
|
|
109
|
+
private selectedId = "main";
|
|
110
|
+
private overlayGeneration = 0;
|
|
94
111
|
/** Set while a conversation overlay is open; calling it closes the overlay. */
|
|
95
112
|
private viewerClose: (() => void) | undefined;
|
|
96
113
|
private viewingAgentId: string | undefined;
|
|
114
|
+
private workflowClient: WorkflowUiClient | undefined;
|
|
115
|
+
private externalOpening = false;
|
|
116
|
+
private externalViewingId: string | undefined;
|
|
117
|
+
private externalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
+
|
|
119
|
+
setWorkflowClient(client: WorkflowUiClient | undefined): void {
|
|
120
|
+
this.workflowClient = client;
|
|
121
|
+
this.update();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Shared exact-attempt opener for user selections and the workflow UI endpoint. */
|
|
125
|
+
canOpenConversation(selection: AgentSelection): boolean {
|
|
126
|
+
return !!this.ui && !this.viewerClose && !!selection.session && !!selection.current();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
openConversation(selection: AgentSelection, deferConstruction = false): boolean {
|
|
130
|
+
if (!this.canOpenConversation(selection)) return false;
|
|
131
|
+
this.viewingAgentId = selection.snapshot.id;
|
|
132
|
+
const mode = this.conversationMode();
|
|
133
|
+
void this.openOverlay((tui, theme, keys, done) => {
|
|
134
|
+
if (!selection.current()) { done(undefined); return { render: () => [], invalidate: () => {} }; }
|
|
135
|
+
return this.conversation(selection, tui, theme, keys, done, undefined, mode);
|
|
136
|
+
}, mode, deferConstruction);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
97
139
|
|
|
98
140
|
constructor(
|
|
99
141
|
private manager: AgentManager,
|
|
100
142
|
private agentActivity: Map<string, AgentActivity>,
|
|
101
143
|
) {}
|
|
102
144
|
|
|
145
|
+
conversationMode(): "fullscreen" | "overlay" { return getConversationViewerMode(this.tui); }
|
|
146
|
+
|
|
103
147
|
// ---- Lifecycle ----
|
|
104
148
|
|
|
105
149
|
setEnabled(enabled: boolean): void {
|
|
@@ -112,7 +156,7 @@ export class FleetList {
|
|
|
112
156
|
/** Capture the UI context and (re)register the global input handler. */
|
|
113
157
|
setUICtx(ui: FleetUICtx): void {
|
|
114
158
|
if (ui === this.ui) return;
|
|
115
|
-
this.
|
|
159
|
+
this.dispose();
|
|
116
160
|
this.ui = ui;
|
|
117
161
|
this.widgetRegistered = false;
|
|
118
162
|
this.tui = undefined;
|
|
@@ -133,6 +177,12 @@ export class FleetList {
|
|
|
133
177
|
}
|
|
134
178
|
|
|
135
179
|
dispose(): void {
|
|
180
|
+
this.overlayGeneration++;
|
|
181
|
+
if (this.externalTimer) clearTimeout(this.externalTimer);
|
|
182
|
+
this.externalTimer = undefined;
|
|
183
|
+
this.externalOpening = false;
|
|
184
|
+
this.externalViewingId = undefined;
|
|
185
|
+
this.workflowClient = undefined;
|
|
136
186
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
137
187
|
this.inputUnsub?.();
|
|
138
188
|
this.inputUnsub = undefined;
|
|
@@ -149,7 +199,8 @@ export class FleetList {
|
|
|
149
199
|
/** Re-register/refresh the below-editor widget; clears it when no agents remain. */
|
|
150
200
|
update(): void {
|
|
151
201
|
if (!this.ui) return;
|
|
152
|
-
|
|
202
|
+
if (this.externalViewingId && !this.externalOpening && this.editorHasFocus()) this.externalViewingId = undefined;
|
|
203
|
+
const hasAgents = this.enabled && this.roster().length > 1;
|
|
153
204
|
|
|
154
205
|
if (!hasAgents) {
|
|
155
206
|
if (this.widgetRegistered) {
|
|
@@ -160,6 +211,7 @@ export class FleetList {
|
|
|
160
211
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
161
212
|
this.active = false;
|
|
162
213
|
this.selectedIndex = 0;
|
|
214
|
+
this.selectedId = "main";
|
|
163
215
|
return;
|
|
164
216
|
}
|
|
165
217
|
|
|
@@ -171,7 +223,8 @@ export class FleetList {
|
|
|
171
223
|
this.tui = tui;
|
|
172
224
|
return {
|
|
173
225
|
render: (w: number) => this.renderBar(w, theme),
|
|
174
|
-
invalidate: () => {
|
|
226
|
+
invalidate: () => { /* no render cache; mounting/focus remain owned */ },
|
|
227
|
+
dispose: () => { if (this.tui === tui) { this.widgetRegistered = false; this.tui = undefined; } },
|
|
175
228
|
};
|
|
176
229
|
}, { placement: "belowEditor" });
|
|
177
230
|
this.widgetRegistered = true;
|
|
@@ -193,7 +246,7 @@ export class FleetList {
|
|
|
193
246
|
private agentRecords(): AgentRecord[] {
|
|
194
247
|
const now = Date.now();
|
|
195
248
|
return this.manager.listAgentsMutable()
|
|
196
|
-
.filter(a => !a.parentAgentId && a.session && (
|
|
249
|
+
.filter(a => !a.parentAgentId && !a.detached && !workflowGroupId(a) && a.session && (
|
|
197
250
|
a.status === "running" || a.status === "queued"
|
|
198
251
|
|| a.id === this.viewingAgentId
|
|
199
252
|
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
|
|
@@ -201,14 +254,47 @@ export class FleetList {
|
|
|
201
254
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
202
255
|
}
|
|
203
256
|
|
|
257
|
+
private groups(): OwnerGroup[] {
|
|
258
|
+
const groups = new Map<string, OwnerGroup>();
|
|
259
|
+
for (const record of this.manager.listAgentsMutable()) {
|
|
260
|
+
if (record.parentAgentId || record.detached) continue;
|
|
261
|
+
const id = workflowGroupId(record);
|
|
262
|
+
if (!id) continue;
|
|
263
|
+
let group = groups.get(id);
|
|
264
|
+
if (!group) { group = { id, runId: record.owner!.runId, records: [] }; groups.set(id, group); }
|
|
265
|
+
group.records.push(record);
|
|
266
|
+
}
|
|
267
|
+
const retained = new Set([...groups.values()].map(group => group.runId));
|
|
268
|
+
if (this.externalViewingId) retained.add(this.externalViewingId);
|
|
269
|
+
for (const run of this.workflowClient?.runs(retained) ?? []) {
|
|
270
|
+
const id = JSON.stringify(["pi-workflows", run.runId]);
|
|
271
|
+
const group = groups.get(id) ?? { id, runId: run.runId, records: [] };
|
|
272
|
+
group.summary = run;
|
|
273
|
+
groups.set(id, group);
|
|
274
|
+
}
|
|
275
|
+
for (const group of groups.values()) group.records.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
|
|
276
|
+
return [...groups.values()];
|
|
277
|
+
}
|
|
278
|
+
|
|
204
279
|
private roster(): FleetEntry[] {
|
|
205
|
-
return [
|
|
280
|
+
return [
|
|
281
|
+
{ kind: "main", id: "main" },
|
|
282
|
+
...this.groups().map(group => ({ kind: "group" as const, id: `group:${group.id}`, group })),
|
|
283
|
+
...this.agentRecords().map(record => ({ kind: "agent" as const, id: `agent:${record.id}`, record })),
|
|
284
|
+
];
|
|
206
285
|
}
|
|
207
286
|
|
|
208
|
-
private clampSelection(): void {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
287
|
+
private clampSelection(roster = this.roster()): void {
|
|
288
|
+
const index = roster.findIndex(entry => entry.id === this.selectedId);
|
|
289
|
+
// Missing identity returns explicitly to main, never to a different child.
|
|
290
|
+
this.selectedIndex = index >= 0 ? index : 0;
|
|
291
|
+
this.selectedId = roster[this.selectedIndex].id;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private select(index: number): void {
|
|
295
|
+
const roster = this.roster();
|
|
296
|
+
this.selectedIndex = Math.max(0, Math.min(roster.length - 1, index));
|
|
297
|
+
this.selectedId = roster[this.selectedIndex].id;
|
|
212
298
|
}
|
|
213
299
|
|
|
214
300
|
// ---- Key handling ----
|
|
@@ -221,38 +307,52 @@ export class FleetList {
|
|
|
221
307
|
// tap would move/fire twice. Repeats still pass through for held-key nav.
|
|
222
308
|
if (isKeyRelease(data)) return undefined;
|
|
223
309
|
// While an overlay is open, let it own all input.
|
|
224
|
-
if (this.viewerClose) return undefined;
|
|
310
|
+
if (this.viewerClose || this.externalOpening) return undefined;
|
|
225
311
|
// Input listeners fire BEFORE the focused component, and dialogs
|
|
226
312
|
// (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
|
|
227
313
|
// while getEditorText() still reads the detached — empty — editor. So when
|
|
228
314
|
// anything but the editor owns the keyboard, stay out of its keys (#123).
|
|
229
315
|
if (!this.editorHasFocus()) {
|
|
230
|
-
if (this.active) this.deactivate();
|
|
316
|
+
if (this.active && !this.externalViewingId) this.deactivate();
|
|
231
317
|
return undefined;
|
|
232
318
|
}
|
|
319
|
+
this.externalViewingId = undefined;
|
|
320
|
+
|
|
321
|
+
if (!this.active && this.ui.getEditorText() === "" && matchesKey(data, "ctrl+b")) {
|
|
322
|
+
const candidates = this.manager.listAgentsMutable().filter(record => !record.detached && !record.parentAgentId &&
|
|
323
|
+
!record.owner && record.isBackground === false && record.status === "running");
|
|
324
|
+
const eligible = candidates.filter(record => this.manager.canHandoff(record.id));
|
|
325
|
+
if (eligible.length > 1) this.ui.notify("Multiple foreground agents; select a conversation and press b to hand off explicitly.", "info");
|
|
326
|
+
else if (eligible.length === 1) this.handoff(captureAgentSelection(this.manager, eligible[0]));
|
|
327
|
+
else if (candidates.length) this.ui.notify(this.manager.handoffUnavailableReason(candidates[0].id) ?? "No eligible foreground agent.", "warning");
|
|
328
|
+
else return undefined;
|
|
329
|
+
return { consume: true };
|
|
330
|
+
}
|
|
233
331
|
|
|
234
332
|
if (!this.active) {
|
|
235
333
|
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
236
334
|
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
|
|
237
|
-
if (isActivator && this.
|
|
335
|
+
if (isActivator && this.roster().length > 1 && this.ui.getEditorText() === "") {
|
|
238
336
|
this.active = true;
|
|
239
|
-
this.
|
|
337
|
+
this.select(0);
|
|
240
338
|
this.update();
|
|
241
339
|
return { consume: true };
|
|
242
340
|
}
|
|
243
341
|
return undefined;
|
|
244
342
|
}
|
|
245
343
|
|
|
344
|
+
// Reconcile identity before both navigation and opening, even between ticks.
|
|
345
|
+
this.clampSelection();
|
|
246
346
|
// Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
|
|
247
347
|
if (matchesKey(data, "down")) {
|
|
248
348
|
const max = this.roster().length - 1;
|
|
249
|
-
this.
|
|
349
|
+
this.select(Math.min(max, this.selectedIndex + 1));
|
|
250
350
|
this.update();
|
|
251
351
|
return { consume: true };
|
|
252
352
|
}
|
|
253
353
|
if (matchesKey(data, "up")) {
|
|
254
354
|
if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
|
|
255
|
-
this.selectedIndex
|
|
355
|
+
this.select(this.selectedIndex - 1);
|
|
256
356
|
this.update();
|
|
257
357
|
return { consume: true };
|
|
258
358
|
}
|
|
@@ -265,64 +365,129 @@ export class FleetList {
|
|
|
265
365
|
}
|
|
266
366
|
|
|
267
367
|
/**
|
|
268
|
-
* True when pi's prompt editor owns the keyboard.
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
368
|
+
* True when pi's prompt editor owns the keyboard. The editor is pluggable
|
|
369
|
+
* (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
|
|
370
|
+
* `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
|
|
371
|
+
* every editor and none of pi's dialogs, selectors or overlays implement.
|
|
372
|
+
* Use the public structural focus getter (not present in every TUI interface
|
|
373
|
+
* declaration). Unknown focus retains the initial editor fallback.
|
|
274
374
|
*/
|
|
275
375
|
private editorHasFocus(): boolean {
|
|
276
|
-
const
|
|
277
|
-
|
|
376
|
+
const focusHost = this.tui as { getFocusedComponent?: () => unknown } | undefined;
|
|
377
|
+
const focused = typeof focusHost?.getFocusedComponent === "function" ? focusHost.getFocusedComponent() : undefined;
|
|
378
|
+
if (focused == null) return true;
|
|
379
|
+
const c = focused as { getText?: unknown; setText?: unknown };
|
|
380
|
+
return typeof c.getText === "function" && typeof c.setText === "function";
|
|
278
381
|
}
|
|
279
382
|
|
|
280
383
|
private deactivate(): void {
|
|
281
384
|
this.active = false;
|
|
282
|
-
this.
|
|
385
|
+
this.select(0);
|
|
283
386
|
this.update();
|
|
284
387
|
}
|
|
285
388
|
|
|
286
389
|
private openSelected(): void {
|
|
287
|
-
const
|
|
390
|
+
const roster = this.roster();
|
|
391
|
+
this.clampSelection(roster);
|
|
392
|
+
const entry = roster[this.selectedIndex];
|
|
288
393
|
if (!entry || entry.kind === "main") {
|
|
289
394
|
// `main` = return to the prompt; the native transcript is already shown.
|
|
290
395
|
this.deactivate();
|
|
291
396
|
return;
|
|
292
397
|
}
|
|
293
|
-
const record = entry.record;
|
|
294
398
|
if (!this.ui) return;
|
|
399
|
+
if (entry.kind === "group") { this.openGroup(entry.group); return; }
|
|
400
|
+
const record = entry.record;
|
|
295
401
|
if (!record.session) {
|
|
296
402
|
this.ui.notify(`Agent is ${record.status}; no session available.`, "info");
|
|
297
403
|
return;
|
|
298
404
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
this.viewingAgentId = record.id;
|
|
405
|
+
this.openConversation(captureAgentSelection(this.manager, record));
|
|
406
|
+
}
|
|
302
407
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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}%` },
|
|
408
|
+
conversation(selection: AgentSelection, tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void,
|
|
409
|
+
notify = (message: string) => this.ui?.notify(message, "info"), mode: "fullscreen" | "overlay" = "overlay"): ConversationViewer {
|
|
410
|
+
const { snapshot: record, session, owner, current } = selection;
|
|
411
|
+
if (!session || !current()) throw new Error("Conversation selection is no longer active");
|
|
412
|
+
let last = record;
|
|
413
|
+
return new ConversationViewer(tui, session, record, this.agentActivity.get(record.id), theme, done,
|
|
414
|
+
() => { if (current() && this.manager.abort(record.id)) notify(`Stopped "${sanitizeDisplayText(record.description)}".`); },
|
|
415
|
+
keys,
|
|
416
|
+
message => current() ? this.manager.steerAccepted(record.id, message, { session, owner }) : Promise.resolve(false),
|
|
417
|
+
() => {
|
|
418
|
+
const fresh = current();
|
|
419
|
+
if (fresh) { last = { ...fresh, owner, lifetimeUsage: { ...fresh.lifetimeUsage } }; return fresh; }
|
|
420
|
+
return { ...last, detached: true };
|
|
324
421
|
},
|
|
325
|
-
|
|
422
|
+
{ mode, canHandoff: () => !!current() && this.manager.canHandoff(record.id), onHandoff: () => this.handoff(selection) },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private handoff(selection: AgentSelection): boolean {
|
|
427
|
+
if (!selection.current()) return false;
|
|
428
|
+
const id = selection.snapshot.id;
|
|
429
|
+
if (!this.manager.handoff(id)) {
|
|
430
|
+
this.ui?.notify(this.manager.handoffUnavailableReason(id) ?? "Cannot hand off this agent.", "warning");
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
this.ui?.notify(`Agent ${sanitizeDisplayText(id)} continues in background.`, "info");
|
|
434
|
+
this.update();
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async openGroup(group: OwnerGroup): Promise<void> {
|
|
439
|
+
const client = this.workflowClient;
|
|
440
|
+
const ui = this.ui;
|
|
441
|
+
const generation = this.overlayGeneration;
|
|
442
|
+
if (group.summary && client) {
|
|
443
|
+
this.externalOpening = true;
|
|
444
|
+
try {
|
|
445
|
+
await client.open(group.summary);
|
|
446
|
+
if (generation === this.overlayGeneration && ui === this.ui) this.externalViewingId = group.runId;
|
|
447
|
+
// Admission precedes the peer's dialog factory. Yield through its next
|
|
448
|
+
// macrotask, then rely on public TUI focus for the entire interaction.
|
|
449
|
+
if (generation === this.overlayGeneration && ui === this.ui) {
|
|
450
|
+
this.externalTimer = setTimeout(() => { this.externalTimer = undefined; this.externalOpening = false; this.update(); }, 0);
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
} catch {
|
|
454
|
+
if (generation !== this.overlayGeneration || ui !== this.ui) return;
|
|
455
|
+
ui?.notify("Workflow inspector unavailable; showing retained children only (no workflow controls).", "warning");
|
|
456
|
+
} finally {
|
|
457
|
+
if (!this.externalTimer && generation === this.overlayGeneration) this.externalOpening = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (generation !== this.overlayGeneration || ui !== this.ui) return;
|
|
461
|
+
if (!group.records.length) { ui?.notify("No retained child conversations for this workflow.", "info"); return; }
|
|
462
|
+
void this.openOverlay((tui, theme, keys, done) => new OwnerGroupViewer(tui, theme, group.runId,
|
|
463
|
+
() => this.groups().find(candidate => candidate.id === group.id)?.records ?? [],
|
|
464
|
+
(record, back) => this.conversation(captureAgentSelection(this.manager, record), tui, theme, keys, back),
|
|
465
|
+
() => done(undefined), keys));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async openOverlay(factory: (tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void) => Component,
|
|
469
|
+
mode: "fullscreen" | "overlay" = "overlay", deferConstruction = false): Promise<void> {
|
|
470
|
+
const ui = this.ui;
|
|
471
|
+
if (!ui) return;
|
|
472
|
+
const generation = ++this.overlayGeneration;
|
|
473
|
+
this.viewerClose = () => {}; // reserve input during asynchronous construction
|
|
474
|
+
try {
|
|
475
|
+
if (deferConstruction) await Promise.resolve();
|
|
476
|
+
if (generation !== this.overlayGeneration || ui !== this.ui) return;
|
|
477
|
+
await ui.custom<undefined>((tui, theme, keys, done) => {
|
|
478
|
+
if (generation !== this.overlayGeneration || ui !== this.ui) {
|
|
479
|
+
done(undefined);
|
|
480
|
+
return { render: () => [], invalidate: () => {} };
|
|
481
|
+
}
|
|
482
|
+
this.viewerClose = () => done(undefined);
|
|
483
|
+
const component = factory(tui, theme, keys, done);
|
|
484
|
+
return mode === "fullscreen" ? component : borderedComponent(component, text => theme.fg("borderAccent", text));
|
|
485
|
+
}, { overlay: true, overlayOptions: getConversationOverlayOptions({ mode: mode === "fullscreen" ? "fullscreen" : undefined }) });
|
|
486
|
+
} catch {
|
|
487
|
+
if (generation === this.overlayGeneration) ui.notify("Cannot open conversation; try again.", "error");
|
|
488
|
+
} finally {
|
|
489
|
+
if (generation === this.overlayGeneration) this.clearViewer();
|
|
490
|
+
}
|
|
326
491
|
}
|
|
327
492
|
|
|
328
493
|
/** Reset overlay state and return to the list (on close, auto-close, or error). */
|
|
@@ -333,7 +498,7 @@ export class FleetList {
|
|
|
333
498
|
// update()'s clamp to settle.
|
|
334
499
|
if (this.viewingAgentId) {
|
|
335
500
|
const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
|
|
336
|
-
if (idx >= 0) this.
|
|
501
|
+
if (idx >= 0) this.select(idx);
|
|
337
502
|
}
|
|
338
503
|
this.viewerClose = undefined;
|
|
339
504
|
this.viewingAgentId = undefined;
|
|
@@ -343,9 +508,12 @@ export class FleetList {
|
|
|
343
508
|
// ---- Rendering ----
|
|
344
509
|
|
|
345
510
|
private renderBar(width: number, theme: Theme): string[] {
|
|
346
|
-
|
|
511
|
+
if (width <= 0) return [];
|
|
512
|
+
const roster = this.roster();
|
|
513
|
+
this.clampSelection(roster);
|
|
514
|
+
const agents = roster.slice(1);
|
|
347
515
|
if (agents.length === 0) return [];
|
|
348
|
-
const sel =
|
|
516
|
+
const sel = this.selectedIndex;
|
|
349
517
|
|
|
350
518
|
// Key names read as proper nouns, the verbs after them stay lowercase, and
|
|
351
519
|
// segments are separated by " · " — the same shape every hint line in the
|
|
@@ -353,9 +521,13 @@ export class FleetList {
|
|
|
353
521
|
const hint = this.active
|
|
354
522
|
? "↑↓ navigate · Enter view · Esc back"
|
|
355
523
|
: "Esc interrupt · ← agents · ↓ manage";
|
|
524
|
+
const height = Math.max(0, Math.floor((this.tui?.terminal.rows ?? 24) / 2));
|
|
525
|
+
if (height === 0) return [];
|
|
356
526
|
const lines: string[] = [];
|
|
357
|
-
|
|
358
|
-
|
|
527
|
+
if (height >= 7) {
|
|
528
|
+
lines.push(truncateToWidth(` ${theme.fg("dim", hint)}`, width));
|
|
529
|
+
lines.push("");
|
|
530
|
+
}
|
|
359
531
|
|
|
360
532
|
const mainColor = sel === 0 ? "accent" : "dim";
|
|
361
533
|
const main = sel === 0
|
|
@@ -365,18 +537,38 @@ export class FleetList {
|
|
|
365
537
|
const mainMark = theme.fg(mainColor, getAgentStatusMark("running"));
|
|
366
538
|
lines.push(truncateToWidth(` ${mainMark} ${main} ${theme.fg("dim", "·")} ${theme.fg("dim", "current")}`, width));
|
|
367
539
|
|
|
368
|
-
const
|
|
540
|
+
const rowBudget = Math.max(1, Math.floor((this.tui?.terminal.rows ?? 24) / 2) - 7);
|
|
541
|
+
const visible = Math.min(MAX_AGENT_ROWS, rowBudget, agents.length);
|
|
369
542
|
const selAgent = Math.max(0, sel - 1);
|
|
370
543
|
const start = selAgent < visible ? 0 : selAgent - visible + 1;
|
|
371
544
|
const hiddenBelow = agents.length - (start + visible);
|
|
372
545
|
|
|
373
546
|
if (start > 0) lines.push(rightAlign("", theme.fg("dim", `${start} more above`), width));
|
|
374
547
|
for (let a = start; a < start + visible; a++) {
|
|
375
|
-
|
|
548
|
+
const entry = agents[a];
|
|
549
|
+
if (entry.kind === "agent") lines.push(this.renderAgentRow(a + 1, sel, entry.record, width, theme));
|
|
550
|
+
else if (entry.kind === "group") {
|
|
551
|
+
const run = entry.group.summary;
|
|
552
|
+
if (run) {
|
|
553
|
+
const selected = a + 1 === sel;
|
|
554
|
+
const status = run.status === "failed" ? "error" : run.status === "pending" || run.status === "paused" ? "queued" :
|
|
555
|
+
run.status === "stopped" || run.status === "interrupted" ? "stopped" : run.status === "completed" ? "completed" : "running";
|
|
556
|
+
const color = status === "error" ? "error" : status === "stopped" ? "warning" : selected ? "accent" : "dim";
|
|
557
|
+
const name = sanitizeDisplayText(run.name);
|
|
558
|
+
const phase = run.currentPhase ? ` · ${sanitizeDisplayText(run.currentPhase)}` : "";
|
|
559
|
+
const left = ` ${theme.fg(color, getAgentStatusMark(status))} ${theme.fg(selected ? "accent" : "muted", name)} · ${theme.fg(color, run.status)}${phase} · ${run.runningCalls}/${run.callCount} running · ${run.completedCalls} done · ${run.failedCalls} failed${run.restored ? " · restored" : ""}`;
|
|
560
|
+
const right = `${formatFleetElapsed(run.elapsedMs)}${run.tokenCount === undefined ? "" : ` · ${formatFleetTokens(run.tokenCount)}`}`;
|
|
561
|
+
lines.push(rightAlign(left, theme.fg("dim", right), width));
|
|
562
|
+
} else {
|
|
563
|
+
const label = ` ▸ Workflow ${sanitizeDisplayText(entry.group.runId)} · ${entry.group.records.length} retained children`;
|
|
564
|
+
lines.push(truncateToWidth(theme.fg(a + 1 === sel ? "accent" : "muted", label), width));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
376
567
|
}
|
|
377
568
|
if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
|
|
378
569
|
|
|
379
|
-
|
|
570
|
+
lines.push(""); // breathing room below the roster, including narrow terminals
|
|
571
|
+
return [...lines.slice(0, height - 1), ""].map(line => truncateToWidth(line, Math.max(0, width)));
|
|
380
572
|
}
|
|
381
573
|
|
|
382
574
|
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Observation-only retained workflow children; no workflow engine/RPC dependency. */
|
|
2
|
+
import { type Component, type Focusable, isKeyRelease, matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { WorkflowUiRunSummary } from "@signalridge/pi-subagents-protocol";
|
|
4
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
5
|
+
import type { AgentRecord } from "../types.js";
|
|
6
|
+
import type { Theme } from "./agent-display.js";
|
|
7
|
+
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
8
|
+
|
|
9
|
+
type DisposableComponent = Component & Partial<Focusable> & { dispose?(): void };
|
|
10
|
+
|
|
11
|
+
export type OwnerGroup = { id: string; runId: string; records: AgentRecord[]; summary?: WorkflowUiRunSummary };
|
|
12
|
+
|
|
13
|
+
export function workflowGroupId(record: AgentRecord): string | undefined {
|
|
14
|
+
const owner = record.owner;
|
|
15
|
+
if (owner?.extension !== "pi-workflows" || typeof owner.runId !== "string" || !owner.runId.trim()) return undefined;
|
|
16
|
+
return JSON.stringify([owner.extension, owner.runId]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A bounded child list that swaps its body for the existing conversation viewer. */
|
|
20
|
+
export class OwnerGroupViewer implements Component, Focusable {
|
|
21
|
+
private hasFocus = false;
|
|
22
|
+
get focused(): boolean { return this.hasFocus; }
|
|
23
|
+
set focused(value: boolean) {
|
|
24
|
+
this.hasFocus = value;
|
|
25
|
+
if (this.child && typeof this.child.focused === "boolean") this.child.focused = value;
|
|
26
|
+
}
|
|
27
|
+
private selectedId: string | undefined;
|
|
28
|
+
private initialized = false;
|
|
29
|
+
private child: DisposableComponent | undefined;
|
|
30
|
+
private closed = false;
|
|
31
|
+
private error: string | undefined;
|
|
32
|
+
private keys: ViewerKeys;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private tui: TUI,
|
|
36
|
+
private theme: Theme,
|
|
37
|
+
private runId: string,
|
|
38
|
+
private records: () => AgentRecord[],
|
|
39
|
+
private open: (record: AgentRecord, done: () => void) => DisposableComponent,
|
|
40
|
+
private done: () => void,
|
|
41
|
+
keybindings?: ViewerKeybindings,
|
|
42
|
+
) { this.keys = createViewerKeys(keybindings); }
|
|
43
|
+
|
|
44
|
+
private roster(): AgentRecord[] {
|
|
45
|
+
const records = this.records();
|
|
46
|
+
if (!this.initialized) { this.selectedId = records[0]?.id; this.initialized = true; }
|
|
47
|
+
else if (!records.some(record => record.id === this.selectedId)) this.selectedId = undefined;
|
|
48
|
+
return records;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
handleInput(data: string): void {
|
|
52
|
+
if (this.closed || isKeyRelease(data)) return;
|
|
53
|
+
if (this.child) { this.child.handleInput?.(data); return; }
|
|
54
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) { this.done(); return; }
|
|
55
|
+
const records = this.roster();
|
|
56
|
+
const index = records.findIndex(record => record.id === this.selectedId);
|
|
57
|
+
const page = Math.max(1, Math.floor(this.tui.terminal.rows * 0.7) - 5);
|
|
58
|
+
let next = index;
|
|
59
|
+
if (this.keys.scrollUp(data)) next--;
|
|
60
|
+
else if (this.keys.scrollDown(data)) next++;
|
|
61
|
+
else if (this.keys.pageUp(data)) next -= page;
|
|
62
|
+
else if (this.keys.pageDown(data)) next += page;
|
|
63
|
+
else if (matchesKey(data, "home")) next = 0;
|
|
64
|
+
else if (matchesKey(data, "end")) next = records.length - 1;
|
|
65
|
+
else if (matchesKey(data, "enter")) {
|
|
66
|
+
const record = records[index];
|
|
67
|
+
if (!record?.session) this.error = record ? "No session yet; child retained" : "Selection gone; use ↑↓";
|
|
68
|
+
else {
|
|
69
|
+
try {
|
|
70
|
+
this.child = this.open(record, () => {
|
|
71
|
+
this.child?.dispose?.();
|
|
72
|
+
this.child = undefined;
|
|
73
|
+
this.tui.requestRender();
|
|
74
|
+
});
|
|
75
|
+
if (typeof this.child.focused === "boolean") this.child.focused = this.focused;
|
|
76
|
+
this.error = undefined;
|
|
77
|
+
} catch { this.error = "Cannot open conversation; try again"; }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (records.length && next !== index) this.selectedId = records[Math.max(0, Math.min(records.length - 1, next))].id;
|
|
81
|
+
this.tui.requestRender();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render(width: number): string[] {
|
|
85
|
+
if (this.child) return this.child.render(width);
|
|
86
|
+
const records = this.roster();
|
|
87
|
+
const selected = records.findIndex(record => record.id === this.selectedId);
|
|
88
|
+
// Reserve header, two controls rows, and the shared adapter's two borders.
|
|
89
|
+
const bodyRows = Math.max(1, Math.floor(this.tui.terminal.rows * 0.7) - 5);
|
|
90
|
+
const start = Math.max(0, selected - bodyRows + 1);
|
|
91
|
+
const labels = records.map(record => sanitizeDisplayText(record.description));
|
|
92
|
+
const counts = new Map<string, number>();
|
|
93
|
+
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
94
|
+
const lines = [this.theme.fg("accent", `Workflow ${sanitizeDisplayText(this.runId)} · ${records.length} retained children`)];
|
|
95
|
+
if (!records.length) lines.push("No retained children");
|
|
96
|
+
for (let index = start; index < Math.min(records.length, start + bodyRows); index++) {
|
|
97
|
+
const record = records[index];
|
|
98
|
+
const discriminator = (counts.get(labels[index]) ?? 0) > 1 ? ` [${sanitizeDisplayText(record.id)}]` : "";
|
|
99
|
+
// The discriminator leads the label so it survives narrow-width clipping.
|
|
100
|
+
const text = `${record.id === this.selectedId ? ">" : " "}${discriminator} ${labels[index]} · ${record.status}${record.session ? "" : " · no session yet"}`;
|
|
101
|
+
lines.push(this.theme.fg(record.id === this.selectedId ? "accent" : "muted", text));
|
|
102
|
+
}
|
|
103
|
+
lines.push(this.error ? this.theme.fg("error", this.error) : `${selected + 1}/${records.length} · ↑↓/PgUp/PgDn`);
|
|
104
|
+
lines.push(width >= 22 ? "Esc back · Enter view" : width >= 10 ? "Esc back ↵" : "Esc ↵");
|
|
105
|
+
return lines.map(line => truncateToWidth(line, Math.max(0, width)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
invalidate(): void { this.child?.invalidate(); }
|
|
109
|
+
dispose(): void { this.closed = true; this.child?.dispose?.(); this.child = undefined; }
|
|
110
|
+
}
|