pi-agent-squad 0.8.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-squad",
3
- "version": "0.8.4",
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": [
@@ -20,11 +20,28 @@
20
20
  "dependencies": {
21
21
  "pi-compact-ui": "^0.1.0"
22
22
  },
23
+ "devDependencies": {
24
+ "@earendil-works/pi-coding-agent": "0.84.3",
25
+ "@earendil-works/pi-tui": "0.84.3",
26
+ "@types/node": "^24.0.0",
27
+ "@typescript-eslint/parser": "^8.0.0",
28
+ "eslint": "^9.0.0",
29
+ "jiti": "^2.7.0",
30
+ "tsx": "^4.20.0",
31
+ "typebox": "^1.0.0",
32
+ "typescript": "^5.9.0"
33
+ },
23
34
  "peerDependencies": {
24
35
  "@earendil-works/pi-coding-agent": "*",
25
36
  "@earendil-works/pi-tui": "*",
26
37
  "typebox": "*"
27
38
  },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json --noEmit",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit",
42
+ "lint": "eslint .",
43
+ "test": "node --import tsx --test test/**/*.test.ts"
44
+ },
28
45
  "pi": {
29
46
  "extensions": [
30
47
  "./index.ts"
package/session.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export interface SubagentSessionHandle {
2
2
  readonly agent: string;
3
+ /** Child Pi session file when the child is configured to persist one. */
4
+ readonly sessionFile?: string;
3
5
  getMessages(): Promise<any[]>;
4
6
  isStreaming(): Promise<boolean>;
5
7
  send(message: string): Promise<void>;
package/spawn.ts CHANGED
@@ -51,9 +51,13 @@ export interface SpawnOptions {
51
51
  childIndex: number;
52
52
  signal?: AbortSignal;
53
53
  timeoutMs?: number;
54
+ /** Persist the child Pi session so full background results remain recoverable. */
55
+ persistSession?: boolean;
54
56
  }
55
57
 
56
58
  export interface InteractiveSpawnOptions extends SpawnOptions {
59
+ /** Called after the RPC child has started, before the initial task prompt. */
60
+ onStarted?: (childSessionFile?: string) => void;
57
61
  onSession?: (session: SubagentSessionHandle) => void;
58
62
  onEvent?: (event: any) => void;
59
63
  }
@@ -63,6 +67,38 @@ const FORCE_KILL_DELAY_MS = 5000;
63
67
  const TEARDOWN_ABORT_CAP_MS = 3000;
64
68
  const CRASH_STDERR_MAX_CHARS = 2000;
65
69
 
70
+ /**
71
+ * Keep the owning task lifecycle alive while a routed message is waiting for
72
+ * the same child session to settle. RpcClient can emit agent_settled before
73
+ * the waiting caller has read the routed reply; tearing down at that instant
74
+ * would kill the child and turn a successful reply into exit 143.
75
+ */
76
+ export class SessionSettleGate {
77
+ private activeWaits = 0;
78
+ private settleDeferred = false;
79
+
80
+ constructor(private readonly settle: () => void) {}
81
+
82
+ beginWait(): () => void {
83
+ this.activeWaits++;
84
+ let finished = false;
85
+ return () => {
86
+ if (finished) return;
87
+ finished = true;
88
+ this.activeWaits = Math.max(0, this.activeWaits - 1);
89
+ if (this.activeWaits === 0 && this.settleDeferred) {
90
+ this.settleDeferred = false;
91
+ this.settle();
92
+ }
93
+ };
94
+ }
95
+
96
+ onAgentSettled(): void {
97
+ if (this.activeWaits > 0) this.settleDeferred = true;
98
+ else this.settle();
99
+ }
100
+ }
101
+
66
102
  /** Bound a best-effort teardown step so a stuck child can never stall cleanup. */
67
103
  async function raceWithCap(promise: Promise<void>, capMs: number): Promise<void> {
68
104
  let capTimer: ReturnType<typeof setTimeout> | undefined;
@@ -204,6 +240,7 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
204
240
  let childExitListener: ((code: number | null, exitSignal: string | null) => void) | undefined;
205
241
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
206
242
  const sessionListeners = new Set<(event: any) => void>();
243
+ const sessionSettleGate = new SessionSettleGate(() => settleLifecycle({ kind: "settled" }));
207
244
 
208
245
  const emitSessionEvent = (event: any) => {
209
246
  for (const listener of sessionListeners) {
@@ -222,7 +259,7 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
222
259
  };
223
260
 
224
261
  try {
225
- const args: string[] = ["--no-session"];
262
+ const args: string[] = opts.persistSession ? [] : ["--no-session"];
226
263
  if (agent.thinking) args.push("--thinking", agent.thinking);
227
264
  const tools = agent.tools ? [...agent.tools] : [];
228
265
  for (const tool of ["send_message", "read_inbox", "reply_message"]) {
@@ -286,7 +323,9 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
286
323
  // be cancelled during teardown). The subscription above is live
287
324
  // before the prompt is sent, so a very fast run cannot settle in the
288
325
  // gap between prompt acceptance and waiter setup.
289
- if (event?.type === "agent_settled") settleLifecycle({ kind: "settled" });
326
+ if (event?.type === "agent_settled") {
327
+ sessionSettleGate.onAgentSettled();
328
+ }
290
329
  });
291
330
 
292
331
  // Timeout and abort feed the same terminal-cause promise as settle/crash.
@@ -315,8 +354,11 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
315
354
  childExitListener(childProcess.exitCode, childProcess.signalCode);
316
355
  }
317
356
  }
357
+ const initialState = await activeClient.getState();
358
+ opts.onStarted?.(initialState.sessionFile);
318
359
  const session: SubagentSessionHandle = {
319
360
  agent: address,
361
+ sessionFile: initialState.sessionFile,
320
362
  getMessages: () => activeClient.getMessages(),
321
363
  isStreaming: async () => (await activeClient.getState()).isStreaming,
322
364
  send: async (message: string) => {
@@ -326,47 +368,52 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
326
368
  else await activeClient.prompt(message);
327
369
  },
328
370
  sendAndWait: async (message: string, messageTimeoutMs: number, messageSignal?: AbortSignal) => {
329
- await initialPromptAcceptedPromise;
330
- if (messageSignal?.aborted) throw new Error("Message delivery cancelled.");
331
- const before = await activeClient.getMessages();
332
- // Own settle detection so the timer and listener are always
333
- // cancelled below (RpcClient.waitForIdle would leak both).
334
- let resolveSettled!: () => void;
335
- let rejectSettled!: (error: unknown) => void;
336
- const settled = new Promise<void>((resolve, reject) => {
337
- resolveSettled = resolve;
338
- rejectSettled = reject;
339
- });
340
- let unsubscribeSettle: (() => void) | undefined;
341
- let settleTimer: ReturnType<typeof setTimeout> | undefined;
342
- let abortHandler: (() => void) | undefined;
343
- const aborted = new Promise<never>((_resolve, reject) => {
344
- abortHandler = () => reject(new Error("Message delivery cancelled."));
345
- if (messageSignal?.aborted) abortHandler();
346
- else messageSignal?.addEventListener("abort", abortHandler, { once: true });
347
- });
371
+ const finishSessionWait = sessionSettleGate.beginWait();
348
372
  try {
349
- unsubscribeSettle = activeClient.onEvent((event: any) => {
350
- if (event?.type === "agent_settled") resolveSettled();
373
+ await initialPromptAcceptedPromise;
374
+ if (messageSignal?.aborted) throw new Error("Message delivery cancelled.");
375
+ const before = await activeClient.getMessages();
376
+ // Own settle detection so the timer and listener are always
377
+ // cancelled below (RpcClient.waitForIdle would leak both).
378
+ let resolveSettled!: () => void;
379
+ let rejectSettled!: (error: unknown) => void;
380
+ const settled = new Promise<void>((resolve, reject) => {
381
+ resolveSettled = resolve;
382
+ rejectSettled = reject;
383
+ });
384
+ let unsubscribeSettle: (() => void) | undefined;
385
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
386
+ let abortHandler: (() => void) | undefined;
387
+ const aborted = new Promise<never>((_resolve, reject) => {
388
+ abortHandler = () => reject(new Error("Message delivery cancelled."));
389
+ if (messageSignal?.aborted) abortHandler();
390
+ else messageSignal?.addEventListener("abort", abortHandler, { once: true });
351
391
  });
352
- settleTimer = setTimeout(() => {
353
- rejectSettled(
354
- new Error(`Timeout waiting for agent to become idle. Stderr: ${activeClient.getStderr()}`),
355
- );
356
- }, messageTimeoutMs);
357
- settleTimer.unref?.();
358
- const state = await activeClient.getState();
359
- if (state.isStreaming) await activeClient.steer(message);
360
- else await activeClient.prompt(message);
361
- // `crashed` fails fast if the child dies mid-message.
362
- await Promise.race([settled, aborted, crashed]);
392
+ try {
393
+ unsubscribeSettle = activeClient.onEvent((event: any) => {
394
+ if (event?.type === "agent_settled") resolveSettled();
395
+ });
396
+ settleTimer = setTimeout(() => {
397
+ rejectSettled(
398
+ new Error(`Timeout waiting for agent to become idle. Stderr: ${activeClient.getStderr()}`),
399
+ );
400
+ }, messageTimeoutMs);
401
+ settleTimer.unref?.();
402
+ const state = await activeClient.getState();
403
+ if (state.isStreaming) await activeClient.steer(message);
404
+ else await activeClient.prompt(message);
405
+ // `crashed` fails fast if the child dies mid-message.
406
+ await Promise.race([settled, aborted, crashed]);
407
+ } finally {
408
+ if (settleTimer) clearTimeout(settleTimer);
409
+ unsubscribeSettle?.();
410
+ if (messageSignal && abortHandler) messageSignal.removeEventListener("abort", abortHandler);
411
+ }
412
+ const after = await activeClient.getMessages();
413
+ return getFinalOutput(after.slice(before.length) as unknown as SingleResult["messages"]);
363
414
  } finally {
364
- if (settleTimer) clearTimeout(settleTimer);
365
- unsubscribeSettle?.();
366
- if (messageSignal && abortHandler) messageSignal.removeEventListener("abort", abortHandler);
415
+ finishSessionWait();
367
416
  }
368
- const after = await activeClient.getMessages();
369
- return getFinalOutput(after.slice(before.length) as unknown as SingleResult["messages"]);
370
417
  },
371
418
  abort: () => activeClient.abort(),
372
419
  subscribe: (listener) => {
@@ -0,0 +1,201 @@
1
+ import {
2
+ TASK_RESULT_DELIVERY_PREFIX,
3
+ type PersistedTaskState,
4
+ } from "./task-state.ts";
5
+
6
+ export const BACKGROUND_EVENT_TYPE = "pi_subagent_background_event";
7
+ export const DELIVERY_ID_PREFIX = TASK_RESULT_DELIVERY_PREFIX;
8
+
9
+ export interface BackgroundEventDetails {
10
+ agent?: string;
11
+ address?: string;
12
+ status?: "done" | "error";
13
+ body?: string;
14
+ elapsedMs?: number;
15
+ runId?: string;
16
+ deliveryId?: string;
17
+ }
18
+
19
+ export interface BackgroundResultPayload {
20
+ body: string;
21
+ content?: string;
22
+ }
23
+
24
+ export function resultDeliveryId(runId: string): string {
25
+ return `${DELIVERY_ID_PREFIX}${runId}`;
26
+ }
27
+
28
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
29
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
30
+ try {
31
+ const prototype = Object.getPrototypeOf(value);
32
+ return prototype === Object.prototype || prototype === null;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Match the real Pi CustomMessageEntry shape persisted by sendMessage(). */
39
+ export function entryHasResultDelivery(entry: unknown, deliveryId: string): boolean {
40
+ if (!isPlainObject(entry)) return false;
41
+ if (entry.type !== "custom_message" || entry.customType !== BACKGROUND_EVENT_TYPE) return false;
42
+ if (!isPlainObject(entry.details)) return false;
43
+ return (
44
+ entry.details.deliveryId === deliveryId &&
45
+ typeof entry.details.runId === "string" &&
46
+ entry.details.deliveryId === resultDeliveryId(entry.details.runId) &&
47
+ typeof entry.details.agent === "string" &&
48
+ typeof entry.details.address === "string" &&
49
+ entry.details.status === "done"
50
+ );
51
+ }
52
+
53
+ export function branchHasResultDelivery(entries: readonly unknown[], deliveryId: string): boolean {
54
+ return entries.some((entry) => entryHasResultDelivery(entry, deliveryId));
55
+ }
56
+
57
+ export function createBackgroundResultMessage(
58
+ state: PersistedTaskState,
59
+ payload?: BackgroundResultPayload,
60
+ ): {
61
+ customType: typeof BACKGROUND_EVENT_TYPE;
62
+ content: string;
63
+ display: true;
64
+ details: BackgroundEventDetails;
65
+ } {
66
+ const body = payload?.body || state.resultSummary || "(result summary unavailable)";
67
+ const contentBody = payload?.content || body;
68
+ return {
69
+ customType: BACKGROUND_EVENT_TYPE,
70
+ content: `[background subagent done] Subagent ${state.agent} done (run ${state.runId.slice(0, 8)})\n\n--- result ---\n${contentBody}`,
71
+ display: true,
72
+ details: {
73
+ agent: state.agent,
74
+ address: state.address,
75
+ status: "done",
76
+ body,
77
+ elapsedMs: Math.max(0, (state.endedAt ?? state.updatedAt) - state.startedAt),
78
+ runId: state.runId,
79
+ deliveryId: state.deliveryId,
80
+ },
81
+ };
82
+ }
83
+
84
+ interface OutboxJob {
85
+ state: PersistedTaskState;
86
+ payload?: BackgroundResultPayload;
87
+ sessionToken: string;
88
+ timer?: ReturnType<typeof setTimeout>;
89
+ sent: boolean;
90
+ }
91
+
92
+ export interface RecoveryOutboxOptions {
93
+ getSessionToken(): string | undefined;
94
+ getBranch(): readonly unknown[];
95
+ getTaskState(runId: string): PersistedTaskState | undefined;
96
+ send(
97
+ message: ReturnType<typeof createBackgroundResultMessage>,
98
+ options: { triggerTurn: true; deliverAs: "steer" },
99
+ ): void;
100
+ markInjected(state: PersistedTaskState): void;
101
+ onError?(error: unknown, state: PersistedTaskState): void;
102
+ pollMs?: number;
103
+ }
104
+
105
+ /**
106
+ * In-memory scheduler for completed-but-undelivered results. Durable state
107
+ * remains in the Pi session; losing this object cannot lose recovery data.
108
+ */
109
+ export class RecoveryOutbox {
110
+ private readonly jobs = new Map<string, OutboxJob>();
111
+ private readonly pollMs: number;
112
+
113
+ constructor(private readonly options: RecoveryOutboxOptions) {
114
+ this.pollMs = Math.max(10, options.pollMs ?? 250);
115
+ }
116
+
117
+ get size(): number {
118
+ return this.jobs.size;
119
+ }
120
+
121
+ enqueue(state: PersistedTaskState, payload?: BackgroundResultPayload): boolean {
122
+ const deliveryId = state.deliveryId;
123
+ const sessionToken = this.options.getSessionToken();
124
+ if (
125
+ state.status !== "completed" ||
126
+ state.resultInjected !== false ||
127
+ !deliveryId ||
128
+ !sessionToken ||
129
+ this.jobs.has(deliveryId)
130
+ ) {
131
+ return false;
132
+ }
133
+ const job: OutboxJob = { state, payload, sessionToken, sent: false };
134
+ this.jobs.set(deliveryId, job);
135
+ job.timer = setTimeout(() => this.run(deliveryId, job), 0);
136
+ return true;
137
+ }
138
+
139
+ cancelAll(): void {
140
+ for (const job of this.jobs.values()) {
141
+ if (job.timer) clearTimeout(job.timer);
142
+ }
143
+ this.jobs.clear();
144
+ }
145
+
146
+ private finish(deliveryId: string, job: OutboxJob): void {
147
+ if (job.timer) clearTimeout(job.timer);
148
+ if (this.jobs.get(deliveryId) === job) this.jobs.delete(deliveryId);
149
+ }
150
+
151
+ private schedulePoll(deliveryId: string, job: OutboxJob): void {
152
+ job.timer = setTimeout(() => this.run(deliveryId, job), this.pollMs);
153
+ job.timer.unref?.();
154
+ }
155
+
156
+ private run(deliveryId: string, job: OutboxJob): void {
157
+ if (this.jobs.get(deliveryId) !== job) return;
158
+ if (this.options.getSessionToken() !== job.sessionToken) {
159
+ this.finish(deliveryId, job);
160
+ return;
161
+ }
162
+ const current = this.options.getTaskState(job.state.runId);
163
+ if (
164
+ !current ||
165
+ current.status !== "completed" ||
166
+ current.resultInjected !== false ||
167
+ current.deliveryId !== deliveryId
168
+ ) {
169
+ this.finish(deliveryId, job);
170
+ return;
171
+ }
172
+
173
+ // Check immediately before every possible send. This is the durable
174
+ // dedupe barrier for both crash window B and concurrent recovery events.
175
+ if (branchHasResultDelivery(this.options.getBranch(), deliveryId)) {
176
+ try {
177
+ this.options.markInjected(current);
178
+ this.finish(deliveryId, job);
179
+ } catch (error) {
180
+ this.options.onError?.(error, current);
181
+ this.schedulePoll(deliveryId, job);
182
+ }
183
+ return;
184
+ }
185
+
186
+ if (!job.sent) {
187
+ try {
188
+ this.options.send(createBackgroundResultMessage(current, job.payload), {
189
+ triggerTurn: true,
190
+ deliverAs: "steer",
191
+ });
192
+ job.sent = true;
193
+ } catch (error) {
194
+ this.options.onError?.(error, current);
195
+ this.finish(deliveryId, job);
196
+ return;
197
+ }
198
+ }
199
+ this.schedulePoll(deliveryId, job);
200
+ }
201
+ }
@@ -0,0 +1,41 @@
1
+ import type { PersistedTaskState } from "./task-state.ts";
2
+
3
+ export interface TaskRecoveryPlan {
4
+ interrupt: PersistedTaskState[];
5
+ markInjected: PersistedTaskState[];
6
+ deliver: PersistedTaskState[];
7
+ }
8
+
9
+ export type DeliveryPresence = (deliveryId: string) => boolean;
10
+
11
+ /**
12
+ * Build a recovery plan without performing any process launch. The caller
13
+ * applies append-only snapshots and schedules result delivery after the
14
+ * session_start handlers have finished.
15
+ */
16
+ export function planTaskRecovery(
17
+ states: ReadonlyMap<string, PersistedTaskState>,
18
+ ownerRuntimeId: string,
19
+ liveRunIds: ReadonlySet<string>,
20
+ hasDelivery: DeliveryPresence,
21
+ ): TaskRecoveryPlan {
22
+ const plan: TaskRecoveryPlan = { interrupt: [], markInjected: [], deliver: [] };
23
+ for (const state of states.values()) {
24
+ if (state.status === "starting" || state.status === "running") {
25
+ const genuinelyLive =
26
+ state.ownerRuntimeId === ownerRuntimeId && liveRunIds.has(state.runId);
27
+ if (!genuinelyLive) plan.interrupt.push(state);
28
+ continue;
29
+ }
30
+ if (
31
+ state.status === "completed" &&
32
+ state.resultInjected === false &&
33
+ state.deliveryId
34
+ ) {
35
+ if (hasDelivery(state.deliveryId)) plan.markInjected.push(state);
36
+ else plan.deliver.push(state);
37
+ }
38
+ }
39
+ return plan;
40
+ }
41
+