@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/schedule-menu.ts
CHANGED
|
@@ -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
|
|
47
|
-
j.schedule
|
|
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
|
|
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
|
+
}
|