@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.
@@ -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, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
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.viewingAgentId = record.id;
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
- 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);
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)) this.ui?.notify(`Stopped "${sanitizeDisplayText(record.description)}".`, "info"); },
414
+ () => { if (current() && this.manager.abort(record.id)) notify(`Stopped "${sanitizeDisplayText(record.description)}".`); },
354
415
  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 },
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 openGroup(group: OwnerGroup): void {
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): Promise<void> {
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
- return borderedComponent(factory(tui, theme, keys, done), text => theme.fg("borderAccent", text));
380
- }, { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` } });
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
- lines.push(truncateToWidth(` ${theme.fg("dim", hint)}`, width));
420
- lines.push("");
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 visible = Math.min(MAX_AGENT_ROWS, agents.length);
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 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));
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
- return lines;
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 {
@@ -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
+ }