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/README.md +18 -12
- package/package.json +1 -1
- package/src/agent-pool.ts +123 -44
- package/src/index.ts +161 -158
- package/src/panel/message-view.ts +58 -50
- package/src/panel/squad-widget.ts +35 -10
- package/src/panel/task-list.ts +13 -3
- package/src/router.ts +37 -35
- package/src/scheduler.ts +292 -65
- package/src/skills/squad-supervisor/SKILL.md +2 -1
- package/src/store.ts +195 -1
- package/src/types.ts +26 -0
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
|
-
|
|
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
|
// ============================================================================
|