pi-agent-squad 0.8.5 → 0.9.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/README.md +5 -1
- package/cypher-status.ts +401 -0
- package/index.ts +75 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,7 +102,10 @@ The TUI-only widget is installed above the editor while at least one subagent is
|
|
|
102
102
|
the hint and hide it when the title itself needs the space.
|
|
103
103
|
- The elapsed time and spinner refresh once per second while work is active, avoiding hot-loop rerenders on very large sessions.
|
|
104
104
|
- Normal completion, failure, timeout, cancellation, crash, and session shutdown all remove the matching activity. The widget itself is removed when no activities remain.
|
|
105
|
-
- JSON/RPC/print modes do not install the widget.
|
|
105
|
+
- JSON/RPC/print modes do not install the widget. In RPC mode the same live runs
|
|
106
|
+
are instead published as the `cypher.subagents.v1` status snapshot (see
|
|
107
|
+
`cypher-status.ts`), which is what a GUI host such as Cypher renders in its
|
|
108
|
+
Subagents panel.
|
|
106
109
|
|
|
107
110
|
### Keyboard navigation
|
|
108
111
|
|
|
@@ -245,6 +248,7 @@ subagents/
|
|
|
245
248
|
|-- message.ts # generic messaging (file channel + send/reply/read + main-side router)
|
|
246
249
|
|-- session.ts # common interactive session-handle interface
|
|
247
250
|
|-- session-ui.ts # focused overlay for live transcript + interactive input
|
|
251
|
+
|-- cypher-status.ts # `cypher.subagents.v1` live run projection for GUI hosts (RPC mode)
|
|
248
252
|
|-- task-state.ts # durable schema, validation, and branch reconstruction
|
|
249
253
|
|-- task-recovery.ts # stale-run and undelivered-result reconciliation planning
|
|
250
254
|
|-- task-delivery.ts # delivery IDs, durable dedupe, and recovery outbox
|
package/cypher-status.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cypher subagent status protocol (`cypher.subagents.v1`).
|
|
3
|
+
*
|
|
4
|
+
* Cypher runs Pi in RPC mode and has no terminal to render the TUI widget
|
|
5
|
+
* into. Instead it consumes a STRUCTURED live projection published through
|
|
6
|
+
* `ctx.ui.setStatus`: the one status key Cypher parses instead of treating as
|
|
7
|
+
* transient TUI furniture. Without it a subagent run shows as an eternal
|
|
8
|
+
* "starting" row in Cypher's Subagents inspector, because the only other
|
|
9
|
+
* signal it has is an unresolved tool call.
|
|
10
|
+
*
|
|
11
|
+
* Contract (mirrors Cypher's `parse_subagent_status`, which validates every
|
|
12
|
+
* field strictly and DROPS THE WHOLE SNAPSHOT on any violation):
|
|
13
|
+
* - key `cypher.subagents.v1`, value `JSON.stringify({version: 1, runs: […]})`;
|
|
14
|
+
* blank text is a CLEAR snapshot.
|
|
15
|
+
* - per run: `runId` + `agent` + `mode` (`sync|async|message`) + `status`
|
|
16
|
+
* (`running|done|error`) + `startedAt`/`updatedAt` epoch millis are
|
|
17
|
+
* required; `toolCallId`, `model`, `task`, `progress`, `endedAt` optional.
|
|
18
|
+
* - bounds: ≤32 runs, task ≤500 chars, progress ≤8 lines / 4KiB, snapshot
|
|
19
|
+
* ≤64KiB.
|
|
20
|
+
*
|
|
21
|
+
* `updatedAt` doubles as the heartbeat: Cypher greys a run out as stale after
|
|
22
|
+
* 45s of silence, so live runs are republished on a timer.
|
|
23
|
+
*
|
|
24
|
+
* RPC mode only — the TUI keeps its own widget and must never be handed this
|
|
25
|
+
* JSON. A standalone Pi (no Cypher) simply never attaches.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const CYPHER_SUBAGENT_STATUS_KEY = "cypher.subagents.v1";
|
|
29
|
+
|
|
30
|
+
const SNAPSHOT_VERSION = 1;
|
|
31
|
+
/** Snapshot bounds — Cypher re-checks each one and ignores a snapshot that breaks any. */
|
|
32
|
+
const MAX_RUNS = 32;
|
|
33
|
+
const MAX_TASK_CHARS = 500;
|
|
34
|
+
const MAX_PROGRESS_LINES = 8;
|
|
35
|
+
const MAX_PROGRESS_BYTES = 4096;
|
|
36
|
+
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
37
|
+
/** Settled runs kept in the snapshot so the panel can show Done/Error instead of a row that vanishes. */
|
|
38
|
+
const MAX_TERMINAL_RUNS = 8;
|
|
39
|
+
/** Progress tail kept per run (Cypher renders the last line on the row). */
|
|
40
|
+
const MAX_PROGRESS_KEPT = 6;
|
|
41
|
+
const MAX_PROGRESS_LINE_CHARS = 160;
|
|
42
|
+
/** Republish period for live runs; Cypher's staleness window is 45s. */
|
|
43
|
+
const HEARTBEAT_MS = 10_000;
|
|
44
|
+
/**
|
|
45
|
+
* Floor between progress-driven publishes. Every snapshot Cypher accepts is a
|
|
46
|
+
* synced session-row write, and a busy child emits tool events several times a
|
|
47
|
+
* second — lifecycle changes publish immediately, chatter coalesces.
|
|
48
|
+
*/
|
|
49
|
+
const MIN_PUBLISH_INTERVAL_MS = 750;
|
|
50
|
+
|
|
51
|
+
export type CypherRunMode = "sync" | "async" | "message";
|
|
52
|
+
export type CypherRunStatus = "running" | "done" | "error";
|
|
53
|
+
|
|
54
|
+
/** One run as published. Field names are the wire format — camelCase, epoch millis. */
|
|
55
|
+
export interface CypherRun {
|
|
56
|
+
runId: string;
|
|
57
|
+
toolCallId?: string;
|
|
58
|
+
agent: string;
|
|
59
|
+
model?: string;
|
|
60
|
+
task: string;
|
|
61
|
+
mode: CypherRunMode;
|
|
62
|
+
status: CypherRunStatus;
|
|
63
|
+
progress?: string;
|
|
64
|
+
startedAt: number;
|
|
65
|
+
updatedAt: number;
|
|
66
|
+
endedAt?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CypherStatusSnapshot {
|
|
70
|
+
version: number;
|
|
71
|
+
runs: CypherRun[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface StartRunInput {
|
|
75
|
+
runId: string;
|
|
76
|
+
agent: string;
|
|
77
|
+
task: string;
|
|
78
|
+
mode: CypherRunMode;
|
|
79
|
+
/** The parent tool call this run answers to (sync/async); absent for message activity. */
|
|
80
|
+
toolCallId?: string;
|
|
81
|
+
model?: string;
|
|
82
|
+
startedAt?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StatusUi {
|
|
86
|
+
setStatus(key: string, text: string | undefined): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface StatusContext {
|
|
90
|
+
mode?: string;
|
|
91
|
+
ui?: Partial<StatusUi>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function boundText(value: string, maxChars: number): string {
|
|
95
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
96
|
+
if (text.length <= maxChars) return text;
|
|
97
|
+
// Cut on a code point boundary: Cypher counts characters, not UTF-16 units.
|
|
98
|
+
return `${[...text].slice(0, Math.max(0, maxChars - 1)).join("")}…`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function boundLine(value: string): string {
|
|
102
|
+
const stripped = String(value ?? "").replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
|
|
103
|
+
return boundText(stripped, MAX_PROGRESS_LINE_CHARS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Join the kept tail within BOTH Cypher's line and byte caps. */
|
|
107
|
+
function renderProgress(lines: string[]): string | undefined {
|
|
108
|
+
let kept = lines.filter((line) => line.length > 0).slice(-MAX_PROGRESS_LINES);
|
|
109
|
+
while (kept.length > 0 && Buffer.byteLength(kept.join("\n"), "utf8") > MAX_PROGRESS_BYTES) {
|
|
110
|
+
kept = kept.slice(1);
|
|
111
|
+
}
|
|
112
|
+
const text = kept.join("\n");
|
|
113
|
+
return text.length > 0 ? text : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A short, human-readable argument for a child tool call ("bash: cargo test"). */
|
|
117
|
+
function summarizeToolArgs(args: unknown): string {
|
|
118
|
+
if (!args || typeof args !== "object") return "";
|
|
119
|
+
const record = args as Record<string, unknown>;
|
|
120
|
+
for (const key of ["command", "path", "file_path", "pattern", "query", "url"]) {
|
|
121
|
+
const value = record[key];
|
|
122
|
+
if (typeof value === "string" && value.trim()) return boundText(value, 60);
|
|
123
|
+
}
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** First meaningful line of an assistant message's text content. */
|
|
128
|
+
function assistantHeadline(message: any): string {
|
|
129
|
+
const content = message?.content;
|
|
130
|
+
const parts = Array.isArray(content) ? content : [];
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
const text = typeof part?.text === "string" ? part.text : "";
|
|
133
|
+
const line = text.split("\n").map((l: string) => l.trim()).find((l: string) => l.length > 0);
|
|
134
|
+
if (line) return boundLine(line);
|
|
135
|
+
}
|
|
136
|
+
if (typeof content === "string") {
|
|
137
|
+
const line = content.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
138
|
+
if (line) return boundLine(line);
|
|
139
|
+
}
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The live run ledger Cypher reads. Deliberately independent of the TUI
|
|
145
|
+
* widget: the widget is presentation for a terminal, this is a protocol.
|
|
146
|
+
*/
|
|
147
|
+
export class CypherStatusPublisher {
|
|
148
|
+
private runs = new Map<string, CypherRun>();
|
|
149
|
+
private progress = new Map<string, string[]>();
|
|
150
|
+
private ui: StatusUi | undefined;
|
|
151
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
152
|
+
private pending: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
private lastPublishAt = 0;
|
|
154
|
+
/** Last published text — an unchanged snapshot is never re-sent. */
|
|
155
|
+
private lastPublished: string | undefined;
|
|
156
|
+
|
|
157
|
+
/** RPC mode only. Re-attaching (a new session) keeps whatever is live. */
|
|
158
|
+
attach(ctx: StatusContext | undefined): void {
|
|
159
|
+
const ui = ctx?.mode === "rpc" ? ctx.ui : undefined;
|
|
160
|
+
this.ui = typeof ui?.setStatus === "function" ? (ui as StatusUi) : undefined;
|
|
161
|
+
this.lastPublished = undefined;
|
|
162
|
+
if (!this.ui) {
|
|
163
|
+
this.stopHeartbeat();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (this.liveCount() > 0) this.startHeartbeat();
|
|
167
|
+
this.publish(true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Session shutdown: clear the board so Cypher never keeps a ghost runner. */
|
|
171
|
+
shutdown(): void {
|
|
172
|
+
this.runs.clear();
|
|
173
|
+
this.progress.clear();
|
|
174
|
+
this.stopHeartbeat();
|
|
175
|
+
this.cancelPending();
|
|
176
|
+
if (this.ui) {
|
|
177
|
+
this.ui.setStatus(CYPHER_SUBAGENT_STATUS_KEY, undefined);
|
|
178
|
+
this.lastPublished = undefined;
|
|
179
|
+
}
|
|
180
|
+
this.ui = undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get size(): number {
|
|
184
|
+
return this.runs.size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Is this run still published as running? */
|
|
188
|
+
isLive(runId: string): boolean {
|
|
189
|
+
return this.runs.get(runId)?.status === "running";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Snapshot the publisher would send right now (the unit-test surface). */
|
|
193
|
+
snapshot(): CypherStatusSnapshot {
|
|
194
|
+
return { version: SNAPSHOT_VERSION, runs: this.orderedRuns() };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
start(input: StartRunInput): void {
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const startedAt = input.startedAt ?? now;
|
|
200
|
+
this.runs.set(input.runId, {
|
|
201
|
+
runId: input.runId,
|
|
202
|
+
...(input.toolCallId ? { toolCallId: input.toolCallId } : {}),
|
|
203
|
+
agent: boundText(input.agent, 120) || "subagent",
|
|
204
|
+
...(input.model ? { model: boundText(input.model, 120) } : {}),
|
|
205
|
+
task: boundText(input.task, MAX_TASK_CHARS),
|
|
206
|
+
mode: input.mode,
|
|
207
|
+
status: "running",
|
|
208
|
+
startedAt,
|
|
209
|
+
updatedAt: now,
|
|
210
|
+
});
|
|
211
|
+
this.progress.delete(input.runId);
|
|
212
|
+
this.startHeartbeat();
|
|
213
|
+
this.publish(true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Model discovered mid-run, or a new progress line. No-op for unknown runs. */
|
|
217
|
+
update(runId: string, patch: { model?: string; progressLine?: string }): void {
|
|
218
|
+
const run = this.runs.get(runId);
|
|
219
|
+
if (!run || run.status !== "running") return;
|
|
220
|
+
let changed = false;
|
|
221
|
+
if (patch.model && !run.model) {
|
|
222
|
+
run.model = boundText(patch.model, 120);
|
|
223
|
+
changed = true;
|
|
224
|
+
}
|
|
225
|
+
const line = patch.progressLine ? boundLine(patch.progressLine) : "";
|
|
226
|
+
if (line) {
|
|
227
|
+
const lines = this.progress.get(runId) ?? [];
|
|
228
|
+
// Consecutive duplicates are noise (a tool retried on every chunk).
|
|
229
|
+
if (lines[lines.length - 1] !== line) {
|
|
230
|
+
lines.push(line);
|
|
231
|
+
this.progress.set(runId, lines.slice(-MAX_PROGRESS_KEPT));
|
|
232
|
+
changed = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!changed) return;
|
|
236
|
+
run.updatedAt = Date.now();
|
|
237
|
+
this.publish();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Terminalize a run only if it is still live — a teardown path that can run
|
|
242
|
+
* after a specific `finish` (or after the owner lost interest) must never
|
|
243
|
+
* leave a runner published forever.
|
|
244
|
+
*/
|
|
245
|
+
settleIfLive(runId: string, status: Exclude<CypherRunStatus, "running">, detail?: string): void {
|
|
246
|
+
if (!this.isLive(runId)) return;
|
|
247
|
+
this.finish(runId, status, detail);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
finish(runId: string, status: Exclude<CypherRunStatus, "running">, detail?: string): void {
|
|
251
|
+
const run = this.runs.get(runId);
|
|
252
|
+
if (!run) return;
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
run.status = status;
|
|
255
|
+
run.updatedAt = now;
|
|
256
|
+
run.endedAt = now;
|
|
257
|
+
const line = detail ? boundLine(detail) : "";
|
|
258
|
+
if (line) {
|
|
259
|
+
const lines = this.progress.get(runId) ?? [];
|
|
260
|
+
lines.push(line);
|
|
261
|
+
this.progress.set(runId, lines.slice(-MAX_PROGRESS_KEPT));
|
|
262
|
+
}
|
|
263
|
+
this.trimTerminal();
|
|
264
|
+
if (this.liveCount() === 0) this.stopHeartbeat();
|
|
265
|
+
this.publish(true);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Child Pi RPC events → model + a progress tail. Best effort: the run is
|
|
270
|
+
* still reported without any of this.
|
|
271
|
+
*/
|
|
272
|
+
observeChildEvent(runId: string, event: any): void {
|
|
273
|
+
if (!this.runs.has(runId)) return;
|
|
274
|
+
// This runs inside the child's event dispatch, which does NOT guard its
|
|
275
|
+
// callbacks: a throw here would break the run itself.
|
|
276
|
+
try {
|
|
277
|
+
const type = event?.type;
|
|
278
|
+
if (type === "tool_execution_start") {
|
|
279
|
+
const name = String(event?.toolName ?? "tool");
|
|
280
|
+
const args = summarizeToolArgs(event?.args);
|
|
281
|
+
this.update(runId, { progressLine: args ? `${name}: ${args}` : name });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (type === "message_end" && event?.message?.role === "assistant") {
|
|
285
|
+
const model = typeof event.message.model === "string" ? event.message.model : undefined;
|
|
286
|
+
this.update(runId, { model, progressLine: assistantHeadline(event.message) });
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
/* a malformed child event is never worth a failed run */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private liveCount(): number {
|
|
294
|
+
let live = 0;
|
|
295
|
+
for (const run of this.runs.values()) if (run.status === "running") live++;
|
|
296
|
+
return live;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Live first (oldest first), then the most recently settled. */
|
|
300
|
+
private orderedRuns(): CypherRun[] {
|
|
301
|
+
const all = [...this.runs.values()];
|
|
302
|
+
const live = all.filter((run) => run.status === "running").sort((a, b) => a.startedAt - b.startedAt);
|
|
303
|
+
const settled = all
|
|
304
|
+
.filter((run) => run.status !== "running")
|
|
305
|
+
.sort((a, b) => (b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt));
|
|
306
|
+
return [...live, ...settled].slice(0, MAX_RUNS).map((run) => {
|
|
307
|
+
const progress = renderProgress(this.progress.get(run.runId) ?? []);
|
|
308
|
+
return progress ? { ...run, progress } : { ...run };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Keep the settled tail bounded so a long session never grows the board. */
|
|
313
|
+
private trimTerminal(): void {
|
|
314
|
+
const settled = [...this.runs.values()]
|
|
315
|
+
.filter((run) => run.status !== "running")
|
|
316
|
+
.sort((a, b) => (b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt));
|
|
317
|
+
for (const run of settled.slice(MAX_TERMINAL_RUNS)) {
|
|
318
|
+
this.runs.delete(run.runId);
|
|
319
|
+
this.progress.delete(run.runId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private startHeartbeat(): void {
|
|
324
|
+
// Nothing consumes the snapshot outside Cypher, so a standalone TUI must
|
|
325
|
+
// not even carry the timer.
|
|
326
|
+
if (!this.ui || this.timer) return;
|
|
327
|
+
this.timer = setInterval(() => {
|
|
328
|
+
if (this.liveCount() === 0) {
|
|
329
|
+
this.stopHeartbeat();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
for (const run of this.runs.values()) if (run.status === "running") run.updatedAt = now;
|
|
334
|
+
this.publish(true);
|
|
335
|
+
}, HEARTBEAT_MS);
|
|
336
|
+
// A heartbeat must never hold the process open.
|
|
337
|
+
this.timer.unref?.();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private stopHeartbeat(): void {
|
|
341
|
+
if (!this.timer) return;
|
|
342
|
+
clearInterval(this.timer);
|
|
343
|
+
this.timer = undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** `immediate` = a lifecycle change; anything else coalesces. */
|
|
347
|
+
private publish(immediate = false): void {
|
|
348
|
+
if (!this.ui) return;
|
|
349
|
+
const waited = Date.now() - this.lastPublishAt;
|
|
350
|
+
if (!immediate && waited < MIN_PUBLISH_INTERVAL_MS) {
|
|
351
|
+
if (this.pending) return;
|
|
352
|
+
this.pending = setTimeout(() => {
|
|
353
|
+
this.pending = undefined;
|
|
354
|
+
this.publishNow();
|
|
355
|
+
}, MIN_PUBLISH_INTERVAL_MS - waited);
|
|
356
|
+
this.pending.unref?.();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.cancelPending();
|
|
360
|
+
this.publishNow();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private publishNow(): void {
|
|
364
|
+
if (!this.ui) return;
|
|
365
|
+
const text = serializeSnapshot(this.orderedRuns());
|
|
366
|
+
this.lastPublishAt = Date.now();
|
|
367
|
+
if (text === this.lastPublished) return;
|
|
368
|
+
this.lastPublished = text;
|
|
369
|
+
try {
|
|
370
|
+
this.ui.setStatus(CYPHER_SUBAGENT_STATUS_KEY, text);
|
|
371
|
+
} catch {
|
|
372
|
+
/* a status frame must never break a run */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private cancelPending(): void {
|
|
377
|
+
if (!this.pending) return;
|
|
378
|
+
clearTimeout(this.pending);
|
|
379
|
+
this.pending = undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Serialize within the 64KiB cap. Over budget, progress goes first, then the
|
|
385
|
+
* settled tail — the live runs are the part Cypher cannot re-derive.
|
|
386
|
+
*/
|
|
387
|
+
export function serializeSnapshot(runs: CypherRun[]): string {
|
|
388
|
+
const encode = (list: CypherRun[]) => JSON.stringify({ version: SNAPSHOT_VERSION, runs: list });
|
|
389
|
+
let text = encode(runs);
|
|
390
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_SNAPSHOT_BYTES) return text;
|
|
391
|
+
const withoutProgress = runs.map(({ progress: _progress, ...run }) => run as CypherRun);
|
|
392
|
+
text = encode(withoutProgress);
|
|
393
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_SNAPSHOT_BYTES) return text;
|
|
394
|
+
let live = withoutProgress.filter((run) => run.status === "running");
|
|
395
|
+
text = encode(live);
|
|
396
|
+
while (live.length > 1 && Buffer.byteLength(text, "utf8") > MAX_SNAPSHOT_BYTES) {
|
|
397
|
+
live = live.slice(0, Math.floor(live.length / 2));
|
|
398
|
+
text = encode(live);
|
|
399
|
+
}
|
|
400
|
+
return text;
|
|
401
|
+
}
|
package/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { openSubagentSessionOverlay } from "./session-ui.ts";
|
|
|
36
36
|
import { getFinalOutput, spawnInteractiveSubagent } from "./spawn.ts";
|
|
37
37
|
import { deadlockMessage, MessageWaitGraph } from "./wait-graph.ts";
|
|
38
38
|
import { ActiveRunRegistry, type ActiveRun } from "./active-runs.ts";
|
|
39
|
+
import { CypherStatusPublisher } from "./cypher-status.ts";
|
|
39
40
|
import {
|
|
40
41
|
boundTaskText,
|
|
41
42
|
isTerminalTaskStatus,
|
|
@@ -668,6 +669,9 @@ export function createAgentSquadExtension(
|
|
|
668
669
|
let poolDisposed = false;
|
|
669
670
|
const activeRuns = new ActiveRunRegistry();
|
|
670
671
|
const runningWidget = new RunningSubagentWidgetController();
|
|
672
|
+
// The same live runs as the TUI widget, published as the structured
|
|
673
|
+
// `cypher.subagents.v1` projection when Pi runs under Cypher (RPC mode).
|
|
674
|
+
const cypherStatus = new CypherStatusPublisher();
|
|
671
675
|
const sessionHandles = new Map<string, SubagentSessionHandle>();
|
|
672
676
|
const waitGraph = new MessageWaitGraph();
|
|
673
677
|
const pendingMainReplyEdges = new Map<
|
|
@@ -890,6 +894,7 @@ export function createAgentSquadExtension(
|
|
|
890
894
|
pool.setIntercomRoot(messageRoot);
|
|
891
895
|
pool.setWorkingDirectory(cwd);
|
|
892
896
|
runningWidget.attach(ctx);
|
|
897
|
+
cypherStatus.attach(ctx);
|
|
893
898
|
sessionContext = ctx.mode === "tui" ? ctx : undefined;
|
|
894
899
|
reconcileCurrentBranch();
|
|
895
900
|
acceptingTasks = true;
|
|
@@ -980,6 +985,7 @@ export function createAgentSquadExtension(
|
|
|
980
985
|
activeRuns.clear();
|
|
981
986
|
tasks.clear();
|
|
982
987
|
runningWidget.shutdown();
|
|
988
|
+
cypherStatus.shutdown();
|
|
983
989
|
currentSessionToken = undefined;
|
|
984
990
|
runtimeContext = undefined;
|
|
985
991
|
sweepMessageRoots(MESSAGE_ROOT_BASE);
|
|
@@ -1180,6 +1186,14 @@ export function createAgentSquadExtension(
|
|
|
1180
1186
|
}
|
|
1181
1187
|
const activityId = `message:${msg.id}`;
|
|
1182
1188
|
runningWidget.start(activityId, resolvedTargetRun.address, msg.content, "message");
|
|
1189
|
+
// Subagent-to-subagent traffic has no parent tool call; Cypher
|
|
1190
|
+
// renders it as its own `message` row.
|
|
1191
|
+
cypherStatus.start({
|
|
1192
|
+
runId: activityId,
|
|
1193
|
+
agent: resolvedTargetRun.address,
|
|
1194
|
+
task: msg.content,
|
|
1195
|
+
mode: "message",
|
|
1196
|
+
});
|
|
1183
1197
|
try {
|
|
1184
1198
|
if (resolvedTargetRun.session) {
|
|
1185
1199
|
const session = await resolvedTargetRun.session;
|
|
@@ -1192,6 +1206,7 @@ export function createAgentSquadExtension(
|
|
|
1192
1206
|
return replyText;
|
|
1193
1207
|
} catch (e) {
|
|
1194
1208
|
const reply = `Target subagent ${resolvedTargetRun.address} failed to process the message: ${e instanceof Error ? e.message : String(e)}`;
|
|
1209
|
+
cypherStatus.settleIfLive(activityId, "error", e instanceof Error ? e.message : String(e));
|
|
1195
1210
|
completeRoutedMessage(msg, reply, routeRoot);
|
|
1196
1211
|
if (msg.from === MAIN_AGENT) throw new Error(reply);
|
|
1197
1212
|
return reply;
|
|
@@ -1200,6 +1215,7 @@ export function createAgentSquadExtension(
|
|
|
1200
1215
|
if (pendingOpenActivityId === activityId) pendingOpenActivityId = undefined;
|
|
1201
1216
|
sessionHandles.delete(activityId);
|
|
1202
1217
|
runningWidget.finish(activityId);
|
|
1218
|
+
cypherStatus.settleIfLive(activityId, "done");
|
|
1203
1219
|
}
|
|
1204
1220
|
},
|
|
1205
1221
|
onMessageReplied: (msg: MessageRequest) => releaseMainReplyEdge(msg.id),
|
|
@@ -1329,6 +1345,7 @@ export function createAgentSquadExtension(
|
|
|
1329
1345
|
requestedAddress?: string,
|
|
1330
1346
|
readOnly = agent.readOnly === true,
|
|
1331
1347
|
retryOf?: string,
|
|
1348
|
+
toolCallId?: string,
|
|
1332
1349
|
): { runId: string; address: string } {
|
|
1333
1350
|
if (!acceptingTasks) throw new Error("This Pi session is shutting down and cannot accept new subagent tasks.");
|
|
1334
1351
|
validateLaunchText("agent", agent.name);
|
|
@@ -1396,6 +1413,17 @@ export function createAgentSquadExtension(
|
|
|
1396
1413
|
sessionGeneration,
|
|
1397
1414
|
});
|
|
1398
1415
|
runningWidget.start(runId, agent.name, taskText, "background");
|
|
1416
|
+
// Async: the tool call returns a launch ack immediately, so the snapshot
|
|
1417
|
+
// is the ONLY thing that keeps Cypher's row alive until the child ends.
|
|
1418
|
+
cypherStatus.start({
|
|
1419
|
+
runId,
|
|
1420
|
+
toolCallId,
|
|
1421
|
+
agent: agent.name,
|
|
1422
|
+
task: taskText,
|
|
1423
|
+
mode: "async",
|
|
1424
|
+
model: agent.model,
|
|
1425
|
+
startedAt,
|
|
1426
|
+
});
|
|
1399
1427
|
|
|
1400
1428
|
const start = async () => {
|
|
1401
1429
|
return await dependencies.spawnInteractiveSubagent({
|
|
@@ -1409,6 +1437,7 @@ export function createAgentSquadExtension(
|
|
|
1409
1437
|
signal: controller.signal,
|
|
1410
1438
|
timeoutMs: effectiveTimeoutMs,
|
|
1411
1439
|
persistSession: true,
|
|
1440
|
+
onEvent: (event) => cypherStatus.observeChildEvent(runId, event),
|
|
1412
1441
|
onStarted: (childSessionFile) => {
|
|
1413
1442
|
const now = Date.now();
|
|
1414
1443
|
const running = transitionTask(
|
|
@@ -1462,6 +1491,12 @@ export function createAgentSquadExtension(
|
|
|
1462
1491
|
? "cancelled"
|
|
1463
1492
|
: "failed";
|
|
1464
1493
|
const now = Date.now();
|
|
1494
|
+
if (result.model) cypherStatus.update(runId, { model: result.model });
|
|
1495
|
+
cypherStatus.settleIfLive(
|
|
1496
|
+
runId,
|
|
1497
|
+
finalStatus === "completed" ? "done" : "error",
|
|
1498
|
+
finalStatus === "completed" ? undefined : failureReason || finalStatus,
|
|
1499
|
+
);
|
|
1465
1500
|
try {
|
|
1466
1501
|
if (finalStatus === "completed") {
|
|
1467
1502
|
const completed = transitionTask(
|
|
@@ -1527,6 +1562,7 @@ export function createAgentSquadExtension(
|
|
|
1527
1562
|
direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
|
|
1528
1563
|
const task = tasks.get(runId);
|
|
1529
1564
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
1565
|
+
cypherStatus.settleIfLive(runId, "error", errorText);
|
|
1530
1566
|
const current = persistedTasks.get(runId);
|
|
1531
1567
|
if (!current || isTerminalTaskStatus(current.status)) return;
|
|
1532
1568
|
const cancelled =
|
|
@@ -1577,6 +1613,9 @@ export function createAgentSquadExtension(
|
|
|
1577
1613
|
if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
|
|
1578
1614
|
sessionHandles.delete(runId);
|
|
1579
1615
|
runningWidget.finish(runId);
|
|
1616
|
+
// Belt and braces: an early return above (a stale session
|
|
1617
|
+
// generation) must never leave a runner published forever.
|
|
1618
|
+
cypherStatus.settleIfLive(runId, "done");
|
|
1580
1619
|
});
|
|
1581
1620
|
|
|
1582
1621
|
return { runId, address };
|
|
@@ -1633,7 +1672,7 @@ export function createAgentSquadExtension(
|
|
|
1633
1672
|
),
|
|
1634
1673
|
}),
|
|
1635
1674
|
|
|
1636
|
-
async execute(
|
|
1675
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1637
1676
|
if (!acceptingTasks) {
|
|
1638
1677
|
return {
|
|
1639
1678
|
content: [{ type: "text", text: "This Pi session is shutting down and cannot accept new subagent tasks." }],
|
|
@@ -1658,7 +1697,16 @@ export function createAgentSquadExtension(
|
|
|
1658
1697
|
if (params.async) {
|
|
1659
1698
|
try {
|
|
1660
1699
|
const readOnly = params.readonly ?? agent.readOnly === true;
|
|
1661
|
-
const launched = launchBackground(
|
|
1700
|
+
const launched = launchBackground(
|
|
1701
|
+
agent,
|
|
1702
|
+
params.task,
|
|
1703
|
+
params.cwd,
|
|
1704
|
+
timeoutMs,
|
|
1705
|
+
params.as,
|
|
1706
|
+
readOnly,
|
|
1707
|
+
undefined,
|
|
1708
|
+
toolCallId,
|
|
1709
|
+
);
|
|
1662
1710
|
const { runId, address } = launched;
|
|
1663
1711
|
return {
|
|
1664
1712
|
content: [
|
|
@@ -1712,6 +1760,14 @@ export function createAgentSquadExtension(
|
|
|
1712
1760
|
};
|
|
1713
1761
|
}
|
|
1714
1762
|
runningWidget.start(runId, agent.name, params.task, "task");
|
|
1763
|
+
cypherStatus.start({
|
|
1764
|
+
runId,
|
|
1765
|
+
toolCallId,
|
|
1766
|
+
agent: agent.name,
|
|
1767
|
+
task: params.task,
|
|
1768
|
+
mode: "sync",
|
|
1769
|
+
model: agent.model,
|
|
1770
|
+
});
|
|
1715
1771
|
let result: Awaited<ReturnType<typeof spawnInteractiveSubagent>>;
|
|
1716
1772
|
try {
|
|
1717
1773
|
result = await dependencies.spawnInteractiveSubagent({
|
|
@@ -1724,6 +1780,7 @@ export function createAgentSquadExtension(
|
|
|
1724
1780
|
childIndex: 0,
|
|
1725
1781
|
signal: runController.signal,
|
|
1726
1782
|
timeoutMs,
|
|
1783
|
+
onEvent: (event) => cypherStatus.observeChildEvent(runId, event),
|
|
1727
1784
|
onSession: (session) => {
|
|
1728
1785
|
direct.resolveSession(session);
|
|
1729
1786
|
registerSessionHandle(runId, session);
|
|
@@ -1731,6 +1788,11 @@ export function createAgentSquadExtension(
|
|
|
1731
1788
|
});
|
|
1732
1789
|
} catch (error) {
|
|
1733
1790
|
direct.rejectSession(error instanceof Error ? error : new Error(String(error)));
|
|
1791
|
+
cypherStatus.settleIfLive(
|
|
1792
|
+
runId,
|
|
1793
|
+
"error",
|
|
1794
|
+
error instanceof Error ? error.message : String(error),
|
|
1795
|
+
);
|
|
1734
1796
|
throw error;
|
|
1735
1797
|
} finally {
|
|
1736
1798
|
signal?.removeEventListener("abort", forwardAbort);
|
|
@@ -1742,6 +1804,17 @@ export function createAgentSquadExtension(
|
|
|
1742
1804
|
runningWidget.finish(runId);
|
|
1743
1805
|
}
|
|
1744
1806
|
const output = getFinalOutput(result.messages);
|
|
1807
|
+
// Terminal truth for the panel: the tool result Cypher folds into the
|
|
1808
|
+
// transcript settles the row too, but only the snapshot carries the
|
|
1809
|
+
// model, the reason and the end time.
|
|
1810
|
+
if (result.model) cypherStatus.update(runId, { model: result.model });
|
|
1811
|
+
const failure =
|
|
1812
|
+
result.exitCode !== 0
|
|
1813
|
+
? `exit ${result.exitCode}: ${result.stderr || result.errorMessage || "unknown error"}`
|
|
1814
|
+
: result.stopReason === "error"
|
|
1815
|
+
? result.errorMessage || "subagent reported an error"
|
|
1816
|
+
: "";
|
|
1817
|
+
cypherStatus.settleIfLive(runId, failure ? "error" : "done", failure || undefined);
|
|
1745
1818
|
|
|
1746
1819
|
if (result.exitCode !== 0) {
|
|
1747
1820
|
return {
|