@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.
@@ -9,6 +9,8 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
13
+ import { sanitizeDisplayText } from "@signalridge/pi-ui";
12
14
  import type { SubagentScheduler } from "../schedule.js";
13
15
  import type { ScheduledSubagent } from "../types.js";
14
16
  import { selectItem } from "./select-item.js";
@@ -38,14 +40,19 @@ export function statusText(j: ScheduledSubagent): string {
38
40
  return "ready";
39
41
  }
40
42
 
43
+ function displayCell(text: string, width: number): string {
44
+ // Drop only trusted truncation resets, after neutralizing untrusted controls.
45
+ return stripTerminalSequences(truncateToWidth(sanitizeDisplayText(text), width));
46
+ }
47
+
41
48
  /** Compact selectable row — name, schedule, agent type, next/last run, count. */
42
49
  function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
43
50
  const next = scheduler.getNextRun(j.id);
44
51
  return [
45
52
  statusText(j),
46
- j.name.padEnd(18).slice(0, 18),
47
- j.schedule.padEnd(14).slice(0, 14),
48
- `[${j.subagent_type}]`,
53
+ displayCell(j.name, 18).padEnd(18),
54
+ displayCell(j.schedule, 14).padEnd(14),
55
+ `[${sanitizeDisplayText(j.subagent_type)}]`,
49
56
  `next ${relTime(next)}`,
50
57
  `last ${relTime(j.lastRun)}`,
51
58
  `runs ${j.runCount}`,
@@ -59,12 +66,12 @@ function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): stri
59
66
  `name: ${j.name}`,
60
67
  `schedule: ${j.schedule} (${j.scheduleType})`,
61
68
  `agent: ${j.subagent_type}`,
62
- `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "..." : ""}`,
69
+ `prompt: ${displayCell(j.prompt, 200)}`,
63
70
  `created: ${j.createdAt}`,
64
71
  `last run: ${j.lastRun ?? "not set"} (${j.lastStatus ?? "not set"})`,
65
72
  `next run: ${next}`,
66
73
  `runs: ${j.runCount}`,
67
- ].join("\n");
74
+ ].map(line => sanitizeDisplayText(line)).join("\n");
68
75
  }
69
76
 
70
77
  /**
@@ -94,9 +101,9 @@ export async function showSchedulesMenu(
94
101
  );
95
102
  if (!job) return;
96
103
 
97
- const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
104
+ const ok = await ctx.ui.confirm(`Cancel "${sanitizeDisplayText(job.name)}"?`, formatDetails(job, scheduler));
98
105
  if (!ok) return;
99
106
 
100
107
  scheduler.removeJob(job.id);
101
- ctx.ui.notify(`Cancelled "${job.name}".`, "info");
108
+ ctx.ui.notify(`Cancelled "${sanitizeDisplayText(job.name)}".`, "info");
102
109
  }
@@ -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
+ }