pi-squad 0.16.2 → 0.16.4

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/src/store.ts CHANGED
@@ -9,13 +9,16 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import * as os from "node:os";
12
+ import { randomUUID } from "node:crypto";
12
13
  import type {
13
14
  AgentDef,
14
15
  KnowledgeEntry,
15
16
  Squad,
16
17
  SquadContext,
17
18
  Task,
19
+ TaskMailboxEntry,
18
20
  TaskMessage,
21
+ TaskSession,
19
22
  TaskUsage,
20
23
  DEFAULT_SQUAD_CONFIG,
21
24
  } from "./types.js";
@@ -122,6 +125,15 @@ export function getMessagesFilePath(squadId: string, taskId: string, parentPath?
122
125
  return path.join(getTaskDir(squadId, taskId, parentPath), "messages.jsonl");
123
126
  }
124
127
 
128
+ export function getTaskMailboxFilePath(squadId: string, taskId: string, parentPath?: string): string {
129
+ return path.join(getTaskDir(squadId, taskId, parentPath), "mailbox.json");
130
+ }
131
+
132
+ /** Task-owned directory where Pi creates the task's durable session JSONL. */
133
+ export function getTaskSessionDir(squadId: string, taskId: string, parentPath?: string): string {
134
+ return path.join(getTaskDir(squadId, taskId, parentPath), "session");
135
+ }
136
+
125
137
  // ============================================================================
126
138
  // Atomic File Operations
127
139
  // ============================================================================
@@ -149,6 +161,52 @@ function readJson<T>(filePath: string): T | null {
149
161
  }
150
162
  }
151
163
 
164
+ /**
165
+ * Serialize cross-process read/modify/write operations for one JSON file.
166
+ * Atomic rename prevents torn writes but does not prevent two writers from
167
+ * reading the same old value and overwriting each other. The adjacent lock is
168
+ * short-lived; an abandoned lock older than 30s is recoverable after a crash.
169
+ */
170
+ function withJsonFileLock<T>(filePath: string, operation: () => T): T {
171
+ ensureDir(path.dirname(filePath));
172
+ const lockPath = `${filePath}.lock`;
173
+ const startedAt = Date.now();
174
+ let lockFd: number | null = null;
175
+ while (lockFd === null) {
176
+ try {
177
+ lockFd = fs.openSync(lockPath, "wx");
178
+ } catch (error) {
179
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
180
+ try {
181
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > 30_000) {
182
+ fs.unlinkSync(lockPath);
183
+ continue;
184
+ }
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (Date.now() - startedAt > 10_000) {
189
+ throw new Error(`Timed out acquiring durable store lock: ${lockPath}`);
190
+ }
191
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
192
+ }
193
+ }
194
+ try {
195
+ fs.writeFileSync(lockFd, `${process.pid}\n${Date.now()}\n`, "utf-8");
196
+ } catch (error) {
197
+ try { fs.closeSync(lockFd); } catch { /* ignore */ }
198
+ try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
199
+ throw error;
200
+ }
201
+
202
+ try {
203
+ return operation();
204
+ } finally {
205
+ try { fs.closeSync(lockFd); } catch { /* ignore */ }
206
+ try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
207
+ }
208
+ }
209
+
152
210
  /** Append a JSONL line */
153
211
  function appendJsonl(filePath: string, entry: unknown): void {
154
212
  ensureDir(path.dirname(filePath));
@@ -385,6 +443,64 @@ export function updateTaskUsage(squadId: string, taskId: string, usage: Partial<
385
443
  saveTask(squadId, task);
386
444
  }
387
445
 
446
+ /** Read the durable Pi session currently bound to a task. */
447
+ export function loadTaskSession(squadId: string, taskId: string, parentPath?: string): TaskSession | null {
448
+ return loadTask(squadId, taskId, parentPath)?.session ?? null;
449
+ }
450
+
451
+ /**
452
+ * Bind a task to its durable Pi session. The file identity is write-once: a
453
+ * reconstructed scheduler may repeat the same binding, but cannot replace it
454
+ * with a fresh session and silently discard the task's conversation context.
455
+ */
456
+ export function bindTaskSession(
457
+ squadId: string,
458
+ taskId: string,
459
+ session: TaskSession,
460
+ parentPath?: string,
461
+ ): TaskSession {
462
+ const task = loadTask(squadId, taskId, parentPath);
463
+ if (!task) throw new Error(`Task not found: ${taskId}`);
464
+ if (!path.isAbsolute(session.file)) {
465
+ throw new Error(`Task session file must be absolute: ${session.file}`);
466
+ }
467
+
468
+ const normalized: TaskSession = {
469
+ file: path.normalize(session.file),
470
+ ...(session.sessionId ? { sessionId: session.sessionId } : {}),
471
+ };
472
+ const existing = task.session;
473
+ if (existing) {
474
+ if (path.normalize(existing.file) !== normalized.file) {
475
+ throw new Error(`Task ${taskId} is already bound to Pi session ${existing.file}`);
476
+ }
477
+ if (existing.sessionId && normalized.sessionId && existing.sessionId !== normalized.sessionId) {
478
+ // Before Pi accepts the first prompt, get_state reserves the eventual
479
+ // file path but the JSONL does not exist yet. Reopening that same path
480
+ // produces a new provisional sessionId. Permit only that pre-materialized
481
+ // same-file recovery; a real JSONL header makes the ID immutable.
482
+ let materialized = false;
483
+ try { materialized = fs.statSync(normalized.file).size > 0; } catch { /* not created yet */ }
484
+ if (materialized) {
485
+ throw new Error(`Task ${taskId} is already bound to Pi session ${existing.file}`);
486
+ }
487
+ task.session = normalized;
488
+ saveTask(squadId, task, parentPath);
489
+ return normalized;
490
+ }
491
+ if (!existing.sessionId && normalized.sessionId) {
492
+ task.session = normalized;
493
+ saveTask(squadId, task, parentPath);
494
+ return normalized;
495
+ }
496
+ return existing;
497
+ }
498
+
499
+ task.session = normalized;
500
+ saveTask(squadId, task, parentPath);
501
+ return normalized;
502
+ }
503
+
388
504
  // ============================================================================
389
505
  // Messages
390
506
  // ============================================================================
@@ -394,7 +510,85 @@ export function appendMessage(squadId: string, taskId: string, message: TaskMess
394
510
  }
395
511
 
396
512
  export function loadMessages(squadId: string, taskId: string, parentPath?: string): TaskMessage[] {
397
- return readJsonl<TaskMessage>(getMessagesFilePath(squadId, taskId, parentPath));
513
+ const history = readJsonl<TaskMessage>(getMessagesFilePath(squadId, taskId, parentPath));
514
+ const seen = new Set(history.flatMap((message) => message.id ? [message.id] : []));
515
+ for (const entry of loadTaskMailbox(squadId, taskId, parentPath)) {
516
+ if (!seen.has(entry.id)) {
517
+ history.push(entry.message);
518
+ seen.add(entry.id);
519
+ }
520
+ }
521
+ return history.sort((a, b) => a.ts.localeCompare(b.ts));
522
+ }
523
+
524
+ /** Load all task-addressed mailbox entries, including delivered history. */
525
+ export function loadTaskMailbox(squadId: string, taskId: string, parentPath?: string): TaskMailboxEntry[] {
526
+ return readJson<TaskMailboxEntry[]>(getTaskMailboxFilePath(squadId, taskId, parentPath)) ?? [];
527
+ }
528
+
529
+ /** Load only mailbox entries still awaiting successful delivery to this task. */
530
+ export function loadPendingTaskMessages(squadId: string, taskId: string, parentPath?: string): TaskMailboxEntry[] {
531
+ return loadTaskMailbox(squadId, taskId, parentPath).filter((entry) => entry.deliveredAt === null);
532
+ }
533
+
534
+ /**
535
+ * Durably queue one inbound message for a task. The mailbox is written before
536
+ * the append-only history; loadMessages merges both by stable ID, so the
537
+ * message remains visible even if a process stops between those writes.
538
+ */
539
+ export function queueTaskMessage(
540
+ squadId: string,
541
+ taskId: string,
542
+ message: TaskMessage,
543
+ parentPath?: string,
544
+ ): TaskMailboxEntry {
545
+ if (!loadTask(squadId, taskId, parentPath)) throw new Error(`Task not found: ${taskId}`);
546
+
547
+ const mailboxPath = getTaskMailboxFilePath(squadId, taskId, parentPath);
548
+ return withJsonFileLock(mailboxPath, () => {
549
+ const mailbox = loadTaskMailbox(squadId, taskId, parentPath);
550
+ const id = message.id ?? randomUUID();
551
+ const existing = mailbox.find((entry) => entry.id === id);
552
+ if (existing) return existing;
553
+
554
+ const durableMessage: TaskMessage = { ...message, id };
555
+ const entry: TaskMailboxEntry = {
556
+ id,
557
+ taskId,
558
+ enqueuedAt: now(),
559
+ deliveredAt: null,
560
+ message: durableMessage,
561
+ };
562
+ mailbox.push(entry);
563
+ writeJsonAtomic(mailboxPath, mailbox);
564
+ appendJsonl(getMessagesFilePath(squadId, taskId, parentPath), durableMessage);
565
+ return entry;
566
+ });
567
+ }
568
+
569
+ /** Mark task mailbox entries delivered while retaining their durable history. */
570
+ export function acknowledgeTaskMessages(
571
+ squadId: string,
572
+ taskId: string,
573
+ messageIds: string[],
574
+ parentPath?: string,
575
+ ): number {
576
+ if (messageIds.length === 0) return 0;
577
+ const wanted = new Set(messageIds);
578
+ const mailboxPath = getTaskMailboxFilePath(squadId, taskId, parentPath);
579
+ return withJsonFileLock(mailboxPath, () => {
580
+ const mailbox = loadTaskMailbox(squadId, taskId, parentPath);
581
+ const deliveredAt = now();
582
+ let count = 0;
583
+ for (const entry of mailbox) {
584
+ if (wanted.has(entry.id) && entry.deliveredAt === null) {
585
+ entry.deliveredAt = deliveredAt;
586
+ count++;
587
+ }
588
+ }
589
+ if (count > 0) writeJsonAtomic(mailboxPath, mailbox);
590
+ return count;
591
+ });
398
592
  }
399
593
 
400
594
  // ============================================================================
package/src/types.ts CHANGED
@@ -129,6 +129,14 @@ export interface TaskUsage {
129
129
  turns: number;
130
130
  }
131
131
 
132
+ /** Durable Pi session owned by one task. Once bound, the file identity is immutable. */
133
+ export interface TaskSession {
134
+ /** Absolute Pi session JSONL path, suitable for `pi --session`. */
135
+ file: string;
136
+ /** Pi's session UUID from RPC get_state, when available. */
137
+ sessionId?: string;
138
+ }
139
+
132
140
  export interface Task {
133
141
  id: string;
134
142
  title: string;
@@ -145,6 +153,8 @@ export interface Task {
145
153
  output: string | null;
146
154
  error: string | null;
147
155
  usage: TaskUsage;
156
+ /** Durable Pi context for this task. Absent until its first process creates a session. */
157
+ session?: TaskSession;
148
158
  /** If this is a rework task, the original task ID it's fixing */
149
159
  retryOf?: string;
150
160
  /** How many times this task chain has been retried */
@@ -160,15 +170,31 @@ export interface Task {
160
170
  export type MessageType = "status" | "text" | "tool" | "mention" | "reply" | "message" | "done" | "error";
161
171
 
162
172
  export interface TaskMessage {
173
+ /** Stable ID when the message participates in the durable task mailbox. */
174
+ id?: string;
163
175
  ts: string;
164
176
  from: string;
165
177
  type: MessageType;
166
178
  text: string;
167
179
  to?: string;
180
+ /** Main-orchestrator message expects the next substantive agent response. */
181
+ expectsReply?: boolean;
168
182
  name?: string;
169
183
  args?: Record<string, unknown>;
170
184
  }
171
185
 
186
+ /**
187
+ * Durable inbound delivery record owned by a task ID. Entries are retained
188
+ * after acknowledgement so task history remains available across restarts.
189
+ */
190
+ export interface TaskMailboxEntry {
191
+ id: string;
192
+ taskId: string;
193
+ enqueuedAt: string;
194
+ deliveredAt: string | null;
195
+ message: TaskMessage;
196
+ }
197
+
172
198
  // ============================================================================
173
199
  // Context (extension-maintained live state)
174
200
  // ============================================================================