@signalridge/pi-subagents 1.12.0 → 1.14.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 +26 -0
- package/README.md +24 -5
- package/package.json +3 -2
- package/src/agent-manager.ts +368 -64
- package/src/agent-runner.ts +150 -35
- package/src/index.ts +177 -67
- package/src/operation-approvals.ts +49 -0
- package/src/output-file.ts +28 -9
- package/src/types.ts +4 -3
- package/src/ui/conversation-viewer.ts +607 -287
- package/src/ui/fleet-list.ts +154 -29
- package/src/ui/owner-group.ts +12 -4
- package/src/ui/workflow-ui-client.ts +189 -0
package/src/ui/fleet-list.ts
CHANGED
|
@@ -18,10 +18,24 @@ 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,
|
|
21
|
+
import { ConversationViewer, getConversationOverlayOptions, getConversationViewerMode } from "./conversation-viewer.js";
|
|
22
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";
|
|
@@ -97,12 +111,39 @@ export class FleetList {
|
|
|
97
111
|
/** Set while a conversation overlay is open; calling it closes the overlay. */
|
|
98
112
|
private viewerClose: (() => void) | undefined;
|
|
99
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
|
+
}
|
|
100
139
|
|
|
101
140
|
constructor(
|
|
102
141
|
private manager: AgentManager,
|
|
103
142
|
private agentActivity: Map<string, AgentActivity>,
|
|
104
143
|
) {}
|
|
105
144
|
|
|
145
|
+
conversationMode(): "fullscreen" | "overlay" { return getConversationViewerMode(this.tui); }
|
|
146
|
+
|
|
106
147
|
// ---- Lifecycle ----
|
|
107
148
|
|
|
108
149
|
setEnabled(enabled: boolean): void {
|
|
@@ -137,6 +178,11 @@ export class FleetList {
|
|
|
137
178
|
|
|
138
179
|
dispose(): void {
|
|
139
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;
|
|
140
186
|
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
141
187
|
this.inputUnsub?.();
|
|
142
188
|
this.inputUnsub = undefined;
|
|
@@ -153,6 +199,7 @@ export class FleetList {
|
|
|
153
199
|
/** Re-register/refresh the below-editor widget; clears it when no agents remain. */
|
|
154
200
|
update(): void {
|
|
155
201
|
if (!this.ui) return;
|
|
202
|
+
if (this.externalViewingId && !this.externalOpening && this.editorHasFocus()) this.externalViewingId = undefined;
|
|
156
203
|
const hasAgents = this.enabled && this.roster().length > 1;
|
|
157
204
|
|
|
158
205
|
if (!hasAgents) {
|
|
@@ -217,6 +264,14 @@ export class FleetList {
|
|
|
217
264
|
if (!group) { group = { id, runId: record.owner!.runId, records: [] }; groups.set(id, group); }
|
|
218
265
|
group.records.push(record);
|
|
219
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
|
+
}
|
|
220
275
|
for (const group of groups.values()) group.records.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
|
|
221
276
|
return [...groups.values()];
|
|
222
277
|
}
|
|
@@ -252,15 +307,27 @@ export class FleetList {
|
|
|
252
307
|
// tap would move/fire twice. Repeats still pass through for held-key nav.
|
|
253
308
|
if (isKeyRelease(data)) return undefined;
|
|
254
309
|
// While an overlay is open, let it own all input.
|
|
255
|
-
if (this.viewerClose) return undefined;
|
|
310
|
+
if (this.viewerClose || this.externalOpening) return undefined;
|
|
256
311
|
// Input listeners fire BEFORE the focused component, and dialogs
|
|
257
312
|
// (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
|
|
258
313
|
// while getEditorText() still reads the detached — empty — editor. So when
|
|
259
314
|
// anything but the editor owns the keyboard, stay out of its keys (#123).
|
|
260
315
|
if (!this.editorHasFocus()) {
|
|
261
|
-
if (this.active) this.deactivate();
|
|
316
|
+
if (this.active && !this.externalViewingId) this.deactivate();
|
|
262
317
|
return undefined;
|
|
263
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
|
+
}
|
|
264
331
|
|
|
265
332
|
if (!this.active) {
|
|
266
333
|
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
@@ -335,49 +402,87 @@ export class FleetList {
|
|
|
335
402
|
this.ui.notify(`Agent is ${record.status}; no session available.`, "info");
|
|
336
403
|
return;
|
|
337
404
|
}
|
|
338
|
-
this.
|
|
339
|
-
void this.openOverlay((tui, theme, keys, done) => this.conversation(record, tui, theme, keys, done));
|
|
405
|
+
this.openConversation(captureAgentSelection(this.manager, record));
|
|
340
406
|
}
|
|
341
407
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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);
|
|
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;
|
|
352
413
|
return new ConversationViewer(tui, session, record, this.agentActivity.get(record.id), theme, done,
|
|
353
|
-
() => { if (current() && this.manager.abort(record.id))
|
|
414
|
+
() => { if (current() && this.manager.abort(record.id)) notify(`Stopped "${sanitizeDisplayText(record.description)}".`); },
|
|
354
415
|
keys,
|
|
355
|
-
message =>
|
|
356
|
-
() =>
|
|
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 };
|
|
421
|
+
},
|
|
422
|
+
{ mode, canHandoff: () => !!current() && this.manager.canHandoff(record.id), onHandoff: () => this.handoff(selection) },
|
|
357
423
|
);
|
|
358
424
|
}
|
|
359
425
|
|
|
360
|
-
private
|
|
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; }
|
|
361
462
|
void this.openOverlay((tui, theme, keys, done) => new OwnerGroupViewer(tui, theme, group.runId,
|
|
362
463
|
() => this.groups().find(candidate => candidate.id === group.id)?.records ?? [],
|
|
363
|
-
(record, back) => this.conversation(record, tui, theme, keys, back),
|
|
464
|
+
(record, back) => this.conversation(captureAgentSelection(this.manager, record), tui, theme, keys, back),
|
|
364
465
|
() => done(undefined), keys));
|
|
365
466
|
}
|
|
366
467
|
|
|
367
|
-
private async openOverlay(factory: (tui: TUI, theme: Theme, keys: ViewerKeybindings, done: (result: undefined) => void) => Component
|
|
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> {
|
|
368
470
|
const ui = this.ui;
|
|
369
471
|
if (!ui) return;
|
|
370
472
|
const generation = ++this.overlayGeneration;
|
|
371
473
|
this.viewerClose = () => {}; // reserve input during asynchronous construction
|
|
372
474
|
try {
|
|
475
|
+
if (deferConstruction) await Promise.resolve();
|
|
476
|
+
if (generation !== this.overlayGeneration || ui !== this.ui) return;
|
|
373
477
|
await ui.custom<undefined>((tui, theme, keys, done) => {
|
|
374
478
|
if (generation !== this.overlayGeneration || ui !== this.ui) {
|
|
375
479
|
done(undefined);
|
|
376
480
|
return { render: () => [], invalidate: () => {} };
|
|
377
481
|
}
|
|
378
482
|
this.viewerClose = () => done(undefined);
|
|
379
|
-
|
|
380
|
-
|
|
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 }) });
|
|
381
486
|
} catch {
|
|
382
487
|
if (generation === this.overlayGeneration) ui.notify("Cannot open conversation; try again.", "error");
|
|
383
488
|
} finally {
|
|
@@ -403,6 +508,7 @@ export class FleetList {
|
|
|
403
508
|
// ---- Rendering ----
|
|
404
509
|
|
|
405
510
|
private renderBar(width: number, theme: Theme): string[] {
|
|
511
|
+
if (width <= 0) return [];
|
|
406
512
|
const roster = this.roster();
|
|
407
513
|
this.clampSelection(roster);
|
|
408
514
|
const agents = roster.slice(1);
|
|
@@ -415,9 +521,13 @@ export class FleetList {
|
|
|
415
521
|
const hint = this.active
|
|
416
522
|
? "↑↓ navigate · Enter view · Esc back"
|
|
417
523
|
: "Esc interrupt · ← agents · ↓ manage";
|
|
524
|
+
const height = Math.max(0, Math.floor((this.tui?.terminal.rows ?? 24) / 2));
|
|
525
|
+
if (height === 0) return [];
|
|
418
526
|
const lines: string[] = [];
|
|
419
|
-
|
|
420
|
-
|
|
527
|
+
if (height >= 7) {
|
|
528
|
+
lines.push(truncateToWidth(` ${theme.fg("dim", hint)}`, width));
|
|
529
|
+
lines.push("");
|
|
530
|
+
}
|
|
421
531
|
|
|
422
532
|
const mainColor = sel === 0 ? "accent" : "dim";
|
|
423
533
|
const main = sel === 0
|
|
@@ -427,7 +537,8 @@ export class FleetList {
|
|
|
427
537
|
const mainMark = theme.fg(mainColor, getAgentStatusMark("running"));
|
|
428
538
|
lines.push(truncateToWidth(` ${mainMark} ${main} ${theme.fg("dim", "·")} ${theme.fg("dim", "current")}`, width));
|
|
429
539
|
|
|
430
|
-
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);
|
|
431
542
|
const selAgent = Math.max(0, sel - 1);
|
|
432
543
|
const start = selAgent < visible ? 0 : selAgent - visible + 1;
|
|
433
544
|
const hiddenBelow = agents.length - (start + visible);
|
|
@@ -437,13 +548,27 @@ export class FleetList {
|
|
|
437
548
|
const entry = agents[a];
|
|
438
549
|
if (entry.kind === "agent") lines.push(this.renderAgentRow(a + 1, sel, entry.record, width, theme));
|
|
439
550
|
else if (entry.kind === "group") {
|
|
440
|
-
const
|
|
441
|
-
|
|
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
|
+
}
|
|
442
566
|
}
|
|
443
567
|
}
|
|
444
568
|
if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
|
|
445
569
|
|
|
446
|
-
|
|
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)));
|
|
447
572
|
}
|
|
448
573
|
|
|
449
574
|
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
|
package/src/ui/owner-group.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/** Observation-only retained workflow children; no workflow engine/RPC dependency. */
|
|
2
|
-
import { type Component, isKeyRelease, matchesKey, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
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";
|
|
3
4
|
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
4
5
|
import type { AgentRecord } from "../types.js";
|
|
5
6
|
import type { Theme } from "./agent-display.js";
|
|
6
7
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
7
8
|
|
|
8
|
-
type DisposableComponent = Component & { dispose?(): void };
|
|
9
|
+
type DisposableComponent = Component & Partial<Focusable> & { dispose?(): void };
|
|
9
10
|
|
|
10
|
-
export type OwnerGroup = { id: string; runId: string; records: AgentRecord[] };
|
|
11
|
+
export type OwnerGroup = { id: string; runId: string; records: AgentRecord[]; summary?: WorkflowUiRunSummary };
|
|
11
12
|
|
|
12
13
|
export function workflowGroupId(record: AgentRecord): string | undefined {
|
|
13
14
|
const owner = record.owner;
|
|
@@ -16,7 +17,13 @@ export function workflowGroupId(record: AgentRecord): string | undefined {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/** A bounded child list that swaps its body for the existing conversation viewer. */
|
|
19
|
-
export class OwnerGroupViewer implements Component {
|
|
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
|
+
}
|
|
20
27
|
private selectedId: string | undefined;
|
|
21
28
|
private initialized = false;
|
|
22
29
|
private child: DisposableComponent | undefined;
|
|
@@ -65,6 +72,7 @@ export class OwnerGroupViewer implements Component {
|
|
|
65
72
|
this.child = undefined;
|
|
66
73
|
this.tui.requestRender();
|
|
67
74
|
});
|
|
75
|
+
if (typeof this.child.focused === "boolean") this.child.focused = this.focused;
|
|
68
76
|
this.error = undefined;
|
|
69
77
|
} catch { this.error = "Cannot open conversation; try again"; }
|
|
70
78
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
type EventBus,
|
|
4
|
+
parseWorkflowUiSnapshot,
|
|
5
|
+
replyChannel,
|
|
6
|
+
WORKFLOW_UI_CHANNELS,
|
|
7
|
+
type WorkflowUiRunSummary,
|
|
8
|
+
type WorkflowUiSnapshot,
|
|
9
|
+
} from "@signalridge/pi-subagents-protocol";
|
|
10
|
+
|
|
11
|
+
function replyData(raw: unknown): unknown {
|
|
12
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) ||
|
|
13
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(raw))) throw new Error("Invalid workflow UI reply.");
|
|
14
|
+
const values: Record<string, unknown> = Object.create(null);
|
|
15
|
+
for (const key of Reflect.ownKeys(raw)) {
|
|
16
|
+
if (typeof key !== "string" || !["success", "data", "error"].includes(key)) throw new Error("Invalid workflow UI reply.");
|
|
17
|
+
const descriptor = Object.getOwnPropertyDescriptor(raw, key);
|
|
18
|
+
if (!descriptor || !Object.hasOwn(descriptor, "value")) throw new Error("Invalid workflow UI reply.");
|
|
19
|
+
values[key] = descriptor.value as unknown;
|
|
20
|
+
}
|
|
21
|
+
if (values.success !== true) throw new Error(typeof values.error === "string" ? values.error : "Workflow UI unavailable.");
|
|
22
|
+
return values.data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TIMEOUT_MS = 2_000;
|
|
26
|
+
export const WORKFLOW_FINISHED_LINGER_MS = 4_000;
|
|
27
|
+
|
|
28
|
+
export function isWorkflowTerminal(run: WorkflowUiRunSummary): boolean {
|
|
29
|
+
return ["completed", "failed", "stopped"].includes(run.status);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Optional, session/branch-bound presentation peer. No execution or model polling. */
|
|
33
|
+
export class WorkflowUiClient {
|
|
34
|
+
private snapshot: WorkflowUiSnapshot | undefined;
|
|
35
|
+
private readonly finishedAt = new Map<string, number>();
|
|
36
|
+
private readonly requests = new Set<(error?: Error) => void>();
|
|
37
|
+
private readonly unsubscribe: () => void;
|
|
38
|
+
private disposed = false;
|
|
39
|
+
private revision = 0;
|
|
40
|
+
private querying = false;
|
|
41
|
+
private queryAgain = false;
|
|
42
|
+
private queryingSurface: string | undefined;
|
|
43
|
+
private candidate: WorkflowUiSnapshot | undefined;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly events: EventBus,
|
|
47
|
+
private readonly isCurrent: () => boolean,
|
|
48
|
+
private readonly changed: () => void,
|
|
49
|
+
private readonly retiredSurfaces: Set<string> = new Set(),
|
|
50
|
+
) {
|
|
51
|
+
this.unsubscribe = events.on(WORKFLOW_UI_CHANNELS.changed, raw => this.accept(raw));
|
|
52
|
+
this.query();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Keep expired terminal identities in the bounded snapshot so repeated ticks cannot relinger them. */
|
|
56
|
+
runs(retainedRunIds: ReadonlySet<string>, now = Date.now()): WorkflowUiRunSummary[] {
|
|
57
|
+
if (!this.live()) return [];
|
|
58
|
+
return (this.snapshot?.runs ?? []).filter(run => !isWorkflowTerminal(run) || retainedRunIds.has(run.runId) ||
|
|
59
|
+
now - (this.finishedAt.get(run.runId) ?? now) < WORKFLOW_FINISHED_LINGER_MS);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async open(run: WorkflowUiRunSummary): Promise<void> {
|
|
63
|
+
if (!this.live() || this.snapshot?.surfaceId !== run.surfaceId || !this.snapshot.runs.some(item => item.runId === run.runId))
|
|
64
|
+
throw new Error("Workflow selection is no longer active.");
|
|
65
|
+
const reply = await this.request(WORKFLOW_UI_CHANNELS.open, { surfaceId: run.surfaceId, runId: run.runId });
|
|
66
|
+
if (!this.live() || this.snapshot?.surfaceId !== run.surfaceId || !reply || typeof reply !== "object" ||
|
|
67
|
+
Array.isArray(reply) || ![Object.prototype, null].includes(Object.getPrototypeOf(reply)) ||
|
|
68
|
+
Object.getOwnPropertyDescriptor(reply, "accepted")?.value !== true) throw new Error("Workflow inspector did not accept the selection.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
dispose(): void {
|
|
72
|
+
if (this.disposed) return;
|
|
73
|
+
this.disposed = true;
|
|
74
|
+
if (this.snapshot) this.retireSurface(this.snapshot.surfaceId);
|
|
75
|
+
this.unsubscribe();
|
|
76
|
+
for (const finish of [...this.requests]) finish(new Error("Workflow UI retired."));
|
|
77
|
+
this.snapshot = undefined;
|
|
78
|
+
this.candidate = undefined;
|
|
79
|
+
this.queryAgain = false;
|
|
80
|
+
this.finishedAt.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private live(): boolean { return !this.disposed && this.isCurrent(); }
|
|
84
|
+
|
|
85
|
+
private accept(raw: unknown): void {
|
|
86
|
+
if (!this.live()) return;
|
|
87
|
+
let snapshot: WorkflowUiSnapshot;
|
|
88
|
+
try { snapshot = parseWorkflowUiSnapshot(raw); } catch { return; }
|
|
89
|
+
if (this.retiredSurfaces.has(snapshot.surfaceId)) return;
|
|
90
|
+
// Once a replacement follows an empty retirement, late old-source frames
|
|
91
|
+
// cannot cancel its pending ownership check. Ordinary same-source updates
|
|
92
|
+
// (including an initially empty active peer) still work without a query.
|
|
93
|
+
if (this.candidate && this.snapshot?.runs.length === 0 && this.snapshot.surfaceId === snapshot.surfaceId) return;
|
|
94
|
+
this.revision++;
|
|
95
|
+
if (this.snapshot && this.snapshot.surfaceId !== snapshot.surfaceId) {
|
|
96
|
+
// An empty retirement can arrive before the replacement's publication.
|
|
97
|
+
// Only the active peer's query responder can authorize a new source.
|
|
98
|
+
this.candidate = snapshot;
|
|
99
|
+
this.query();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.candidate = undefined;
|
|
103
|
+
this.apply(snapshot);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private retireSurface(surfaceId: string): void {
|
|
107
|
+
this.retiredSurfaces.add(surfaceId);
|
|
108
|
+
if (this.retiredSurfaces.size > 256) this.retiredSurfaces.delete(this.retiredSurfaces.values().next().value!);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private query(): void {
|
|
112
|
+
if (!this.live()) return;
|
|
113
|
+
if (this.querying) {
|
|
114
|
+
// Coalesce same-source revisions; a query sent before a new publication
|
|
115
|
+
// cannot verify that source, so schedule one fresh event-triggered query.
|
|
116
|
+
if (this.candidate?.surfaceId !== this.queryingSurface) this.queryAgain = true;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.querying = true;
|
|
120
|
+
const revision = this.revision;
|
|
121
|
+
const surfaceId = this.candidate?.surfaceId;
|
|
122
|
+
this.queryingSurface = surfaceId;
|
|
123
|
+
void this.request(WORKFLOW_UI_CHANNELS.query, {}).then(raw => {
|
|
124
|
+
if (!this.live()) return;
|
|
125
|
+
let snapshot: WorkflowUiSnapshot;
|
|
126
|
+
try { snapshot = parseWorkflowUiSnapshot(raw); } catch { return; }
|
|
127
|
+
if (this.retiredSurfaces.has(snapshot.surfaceId)) return;
|
|
128
|
+
if (surfaceId) {
|
|
129
|
+
if (this.candidate?.surfaceId !== surfaceId) return;
|
|
130
|
+
if (snapshot.surfaceId !== surfaceId) {
|
|
131
|
+
// A valid ownership reply rejects only the candidate it queried.
|
|
132
|
+
// Release the empty-peer guard without retiring the active source.
|
|
133
|
+
this.candidate = undefined;
|
|
134
|
+
this.queryAgain = false;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.snapshot) this.retireSurface(this.snapshot.surfaceId);
|
|
138
|
+
// The reply verifies ownership, not freshness. Later publications win.
|
|
139
|
+
const latest = this.candidate;
|
|
140
|
+
this.candidate = undefined;
|
|
141
|
+
this.apply(latest);
|
|
142
|
+
} else if (revision === this.revision) this.accept(snapshot);
|
|
143
|
+
}, () => {}).finally(() => {
|
|
144
|
+
this.querying = false;
|
|
145
|
+
this.queryingSurface = undefined;
|
|
146
|
+
const again = this.queryAgain;
|
|
147
|
+
this.queryAgain = false;
|
|
148
|
+
if (again && this.candidate && this.live()) this.query();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private apply(snapshot: WorkflowUiSnapshot): void {
|
|
153
|
+
const old = this.snapshot?.surfaceId === snapshot.surfaceId ? this.finishedAt : new Map<string, number>();
|
|
154
|
+
const next = new Map<string, number>();
|
|
155
|
+
for (const run of snapshot.runs) if (isWorkflowTerminal(run)) next.set(run.runId, old.get(run.runId) ?? Date.now());
|
|
156
|
+
this.finishedAt.clear();
|
|
157
|
+
for (const [id, time] of next) this.finishedAt.set(id, time);
|
|
158
|
+
this.snapshot = snapshot;
|
|
159
|
+
this.changed();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private request(channel: string, payload: Record<string, string>): Promise<unknown> {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
let settled = false;
|
|
165
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
166
|
+
let unsubscribe = () => {};
|
|
167
|
+
const finish = (error?: Error, data?: unknown): void => {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
settled = true;
|
|
170
|
+
if (timer) clearTimeout(timer);
|
|
171
|
+
unsubscribe();
|
|
172
|
+
this.requests.delete(finish);
|
|
173
|
+
if (error) reject(error); else resolve(data);
|
|
174
|
+
};
|
|
175
|
+
this.requests.add(finish);
|
|
176
|
+
if (!this.live()) { finish(new Error("Workflow UI retired.")); return; }
|
|
177
|
+
const requestId = randomUUID();
|
|
178
|
+
try {
|
|
179
|
+
unsubscribe = this.events.on(replyChannel(channel, requestId), raw => {
|
|
180
|
+
if (!this.live()) { finish(new Error("Workflow UI retired.")); return; }
|
|
181
|
+
try { finish(undefined, replyData(raw)); }
|
|
182
|
+
catch (error) { finish(error instanceof Error ? error : new Error("Invalid workflow UI reply.")); }
|
|
183
|
+
});
|
|
184
|
+
timer = setTimeout(() => finish(new Error("Workflow UI unavailable; retained children remain inspectable.")), TIMEOUT_MS);
|
|
185
|
+
this.events.emit(channel, { requestId, ...payload });
|
|
186
|
+
} catch { finish(new Error("Workflow UI unavailable.")); }
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|