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 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
@@ -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(_toolCallId, params, signal, onUpdate, ctx) {
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(agent, params.task, params.cwd, timeoutMs, params.as, readOnly);
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-squad",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Interactive multi-agent orchestration, messaging, and live sessions for the Pi Coding Agent",
5
5
  "type": "module",
6
6
  "keywords": [