@rosetears/aili-pi 0.1.8 → 0.1.10

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.
Files changed (63) hide show
  1. package/README.md +7 -7
  2. package/THIRD_PARTY_NOTICES.md +12 -12
  3. package/docs/persistent-agents.md +114 -0
  4. package/extensions/matrix/index.ts +24 -8
  5. package/extensions/zentui/config.ts +13 -13
  6. package/extensions/zentui/format.ts +15 -1
  7. package/extensions/zentui/gradient.ts +22 -13
  8. package/extensions/zentui/tool-execution.ts +7 -4
  9. package/manifests/adapter-evidence.json +53 -20
  10. package/manifests/capabilities.json +3 -3
  11. package/manifests/live-verification.json +18 -19
  12. package/manifests/provenance.json +12 -12
  13. package/manifests/roles.json +270 -57
  14. package/manifests/sbom.json +1 -128
  15. package/manifests/skill-compatibility.json +57 -61
  16. package/manifests/subagent-provenance.json +8 -15
  17. package/package.json +3 -3
  18. package/roles/agent-evaluator.md +8 -3
  19. package/roles/ai-regression-scout.md +8 -3
  20. package/roles/browser-qa-runner.md +8 -3
  21. package/roles/code-reviewer.md +8 -3
  22. package/roles/code-scout.md +8 -3
  23. package/roles/convergence-reviewer.md +8 -3
  24. package/roles/doc-researcher.md +8 -3
  25. package/roles/e2e-artifact-runner.md +8 -3
  26. package/roles/general.md +49 -0
  27. package/roles/implementer.md +8 -3
  28. package/roles/opensource-sanitizer.md +8 -3
  29. package/roles/plan-auditor.md +8 -3
  30. package/roles/pr-test-analyzer.md +8 -3
  31. package/roles/security-auditor.md +8 -3
  32. package/roles/silent-failure-reviewer.md +8 -3
  33. package/roles/spec-miner.md +8 -3
  34. package/roles/test-coverage-reviewer.md +8 -3
  35. package/roles/test-engineer.md +8 -3
  36. package/roles/web-performance-auditor.md +8 -3
  37. package/roles/web-researcher.md +8 -3
  38. package/scripts/sync-roles.ts +186 -24
  39. package/src/runtime/doctor.ts +38 -10
  40. package/src/runtime/global-resources.ts +5 -1
  41. package/src/runtime/index.ts +2 -2
  42. package/src/runtime/persistent-agents/hub.ts +429 -0
  43. package/src/runtime/persistent-agents/model-selection.ts +363 -0
  44. package/src/runtime/persistent-agents/output-delivery.ts +356 -0
  45. package/src/runtime/persistent-agents/permission.ts +223 -0
  46. package/src/runtime/persistent-agents/policy.ts +311 -0
  47. package/src/runtime/persistent-agents/production.ts +569 -0
  48. package/src/runtime/persistent-agents/runtime.ts +164 -0
  49. package/src/runtime/persistent-agents/sandbox.ts +68 -0
  50. package/src/runtime/persistent-agents/scheduler.ts +190 -0
  51. package/src/runtime/persistent-agents/session-factory.ts +184 -0
  52. package/src/runtime/persistent-agents/storage.ts +775 -0
  53. package/src/runtime/persistent-agents/task-coordinator.ts +460 -0
  54. package/src/runtime/persistent-agents/task-schema.ts +141 -0
  55. package/src/runtime/persistent-agents/types.ts +134 -0
  56. package/src/runtime/persistent-agents/workspace.ts +335 -0
  57. package/src/runtime/registry.ts +28 -32
  58. package/src/runtime/roles.ts +211 -18
  59. package/src/runtime/rose-context.ts +1 -1
  60. package/templates/APPEND_SYSTEM.md +1 -1
  61. package/themes/rose-cyberdeck.json +5 -9
  62. package/upstream/opencode-global-agents.lock.json +1 -1
  63. package/src/runtime/subagents.ts +0 -249
@@ -0,0 +1,775 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, realpath, rename } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ COORDINATOR_SCHEMA_VERSION,
7
+ DEFAULT_IDLE_TTL_MS,
8
+ type AgentRecord,
9
+ type AgentState,
10
+ type CoordinatorEvent,
11
+ type CoordinatorEventInput,
12
+ type CoordinatorSnapshot,
13
+ type CoordinatorState,
14
+ type JobRecord,
15
+ type JobState,
16
+ type ReplayDiagnostics,
17
+ type ReplayResult,
18
+ type SidecarLayout,
19
+ type TurnRecord,
20
+ type TurnState,
21
+ } from "./types.js";
22
+
23
+ const AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
24
+ const AGENT_TRANSITIONS: Record<AgentState, ReadonlySet<AgentState>> = {
25
+ queued: new Set(["running", "parked", "aborted"]),
26
+ running: new Set(["idle", "parked", "aborted"]),
27
+ idle: new Set(["running", "parked", "aborted"]),
28
+ parked: new Set(["running", "aborted"]),
29
+ aborted: new Set(),
30
+ };
31
+ const JOB_TRANSITIONS: Record<JobState, ReadonlySet<JobState>> = {
32
+ queued: new Set(["running", "aborted", "unexecuted"]),
33
+ running: new Set(["completed", "failed", "aborted"]),
34
+ completed: new Set(),
35
+ failed: new Set(),
36
+ aborted: new Set(),
37
+ unexecuted: new Set(),
38
+ };
39
+ const TURN_TRANSITIONS: Record<TurnState, ReadonlySet<TurnState>> = {
40
+ queued: new Set(["running", "aborted", "interrupted"]),
41
+ running: new Set(["completed", "failed", "aborted", "interrupted"]),
42
+ completed: new Set(),
43
+ failed: new Set(),
44
+ aborted: new Set(),
45
+ interrupted: new Set(),
46
+ };
47
+
48
+ function clone<T>(value: T): T {
49
+ return structuredClone(value);
50
+ }
51
+
52
+ async function durableAppend(path: string, content: string): Promise<void> {
53
+ const handle = await open(path, "a", 0o600);
54
+ try {
55
+ await handle.writeFile(content, "utf8");
56
+ await handle.sync();
57
+ } finally {
58
+ await handle.close();
59
+ }
60
+ }
61
+
62
+ function isInside(root: string, candidate: string): boolean {
63
+ const rel = relative(root, candidate);
64
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
65
+ }
66
+
67
+ async function lstatOptional(path: string) {
68
+ try {
69
+ return await lstat(path);
70
+ } catch (error) {
71
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ export function assertSafeAgentId(agentId: string): void {
77
+ if (!AGENT_ID_PATTERN.test(agentId) || agentId.includes("..") || agentId.endsWith(".")) {
78
+ throw new Error(`unsafe Agent ID: ${agentId}`);
79
+ }
80
+ }
81
+
82
+ export function sanitizeAgentName(requestedName: string | undefined): string {
83
+ const normalized = (requestedName ?? "agent")
84
+ .normalize("NFKD")
85
+ .replace(/[\u0300-\u036f]/g, "")
86
+ .replace(/[^A-Za-z0-9_-]+/g, "-")
87
+ .replace(/[-_]{2,}/g, "-")
88
+ .replace(/^[-_.]+|[-_.]+$/g, "")
89
+ .slice(0, 64);
90
+ return normalized || "agent";
91
+ }
92
+
93
+ export function allocateAgentId(
94
+ requestedName: string | undefined,
95
+ existingIds: Iterable<string>,
96
+ parentAgentId?: string,
97
+ ): string {
98
+ if (parentAgentId) assertSafeAgentId(parentAgentId);
99
+ const used = new Set(existingIds);
100
+ const base = sanitizeAgentName(requestedName);
101
+ for (let suffix = 1; suffix < 100_000; suffix += 1) {
102
+ const child = suffix === 1 ? base : `${base}-${suffix}`;
103
+ const candidate = parentAgentId ? `${parentAgentId}.${child}` : child;
104
+ assertSafeAgentId(candidate);
105
+ if (!used.has(candidate)) return candidate;
106
+ }
107
+ throw new Error(`unable to allocate Agent ID for ${base}`);
108
+ }
109
+
110
+ export function sidecarLayoutForParent(parentSessionPath: string): SidecarLayout {
111
+ if (!isAbsolute(parentSessionPath) || !parentSessionPath.endsWith(".jsonl")) {
112
+ throw new Error("parent session path must be an absolute .jsonl file");
113
+ }
114
+ const stem = parentSessionPath.slice(0, -".jsonl".length);
115
+ const root = resolve(stem, "aili-agents");
116
+ return {
117
+ parentSessionPath,
118
+ root,
119
+ coordinatorPath: resolve(root, "coordinator.jsonl"),
120
+ snapshotPath: resolve(root, "snapshot.json"),
121
+ agentsDir: resolve(root, "agents"),
122
+ patchesDir: resolve(root, "patches"),
123
+ workspacesPath: resolve(root, "workspaces.jsonl"),
124
+ };
125
+ }
126
+
127
+ async function requireRealPath(path: string, kind: "file" | "directory"): Promise<void> {
128
+ const stat = await lstat(path);
129
+ if (stat.isSymbolicLink() || (kind === "file" ? !stat.isFile() : !stat.isDirectory())) {
130
+ throw new Error(`${kind} must be real and non-symlinked: ${path}`);
131
+ }
132
+ }
133
+
134
+ export async function ensureSidecarLayout(parentSessionPath: string): Promise<SidecarLayout> {
135
+ const layout = sidecarLayoutForParent(parentSessionPath);
136
+ await requireRealPath(parentSessionPath, "file");
137
+ const existingRoot = await lstatOptional(layout.root);
138
+ if (existingRoot?.isSymbolicLink()) throw new Error(`sidecar root must not be a symlink: ${layout.root}`);
139
+ if (existingRoot && !existingRoot.isDirectory()) throw new Error(`sidecar root is not a directory: ${layout.root}`);
140
+ await mkdir(layout.agentsDir, { recursive: true, mode: 0o700 });
141
+ await mkdir(layout.patchesDir, { recursive: true, mode: 0o700 });
142
+ await requireRealPath(layout.root, "directory");
143
+ await requireRealPath(layout.agentsDir, "directory");
144
+ await requireRealPath(layout.patchesDir, "directory");
145
+ return layout;
146
+ }
147
+
148
+ export function createInitialCoordinatorState(parentId: string): CoordinatorState {
149
+ if (!parentId.trim()) throw new Error("parentId is required");
150
+ return {
151
+ schemaVersion: COORDINATOR_SCHEMA_VERSION,
152
+ parentId,
153
+ lastSequence: 0,
154
+ appliedEventIds: [],
155
+ agents: {},
156
+ releasedAgents: {},
157
+ jobs: {},
158
+ turns: {},
159
+ mailboxes: {},
160
+ deliveries: {},
161
+ models: {},
162
+ workspaces: {},
163
+ messages: {},
164
+ };
165
+ }
166
+
167
+ function requireString(value: unknown, field: string): string {
168
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${field} must be a non-empty string`);
169
+ return value;
170
+ }
171
+
172
+ function requireRecord(value: unknown, field: string): Record<string, unknown> {
173
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} must be an object`);
174
+ return value as Record<string, unknown>;
175
+ }
176
+
177
+ function validateEventEnvelope(event: CoordinatorEvent, parentId: string): void {
178
+ if (event.schemaVersion !== COORDINATOR_SCHEMA_VERSION) throw new Error(`event ${event.eventId}: unsupported schemaVersion`);
179
+ if (event.parentId !== parentId) throw new Error(`event ${event.eventId}: parent ownership mismatch`);
180
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= 0) throw new Error(`event ${event.eventId}: invalid sequence`);
181
+ requireString(event.eventId, "eventId");
182
+ requireString(event.timestamp, "timestamp");
183
+ requireRecord(event.payload, "payload");
184
+ }
185
+
186
+ function recordFromPayload<T extends AgentRecord | JobRecord | TurnRecord>(event: CoordinatorEvent): T {
187
+ return clone(requireRecord(event.payload.record, `${event.kind}.record`) as unknown as T);
188
+ }
189
+
190
+ function applyStateTransition<T extends string>(
191
+ label: string,
192
+ current: T,
193
+ payload: Record<string, unknown>,
194
+ allowed: Record<T, ReadonlySet<T>>,
195
+ ): T {
196
+ const from = requireString(payload.from, `${label}.from`) as T;
197
+ const to = requireString(payload.to, `${label}.to`) as T;
198
+ if (from !== current) throw new Error(`${label}: stale transition ${from}->${to}; current=${current}`);
199
+ if (!allowed[current]?.has(to)) throw new Error(`${label}: invalid transition ${from}->${to}`);
200
+ return to;
201
+ }
202
+
203
+ export function applyCoordinatorEvent(current: CoordinatorState, event: CoordinatorEvent): CoordinatorState {
204
+ validateEventEnvelope(event, current.parentId);
205
+ if (event.sequence !== current.lastSequence + 1) throw new Error(`event ${event.eventId}: non-contiguous sequence`);
206
+ if (current.appliedEventIds.includes(event.eventId)) throw new Error(`event ${event.eventId}: duplicate eventId`);
207
+ const state = clone(current);
208
+ const now = event.timestamp;
209
+
210
+ switch (event.kind) {
211
+ case "agent.created": {
212
+ const record = recordFromPayload<AgentRecord>(event);
213
+ assertSafeAgentId(record.id);
214
+ if (record.id !== event.agentId) throw new Error("agent.created ID mismatch");
215
+ if (state.agents[record.id] || state.releasedAgents[record.id]) throw new Error(`${record.id}: duplicate Agent ownership`);
216
+ if (record.state !== "queued") throw new Error(`${record.id}: new Agent must be queued`);
217
+ state.agents[record.id] = record;
218
+ break;
219
+ }
220
+ case "agent.state": {
221
+ const id = requireString(event.agentId, "agentId");
222
+ const record = state.agents[id];
223
+ if (!record) throw new Error(`${id}: unknown Agent`);
224
+ record.state = applyStateTransition(`Agent ${id}`, record.state, event.payload, AGENT_TRANSITIONS);
225
+ if (typeof event.payload.currentTurnId === "string") record.currentTurnId = event.payload.currentTurnId;
226
+ else if (event.payload.currentTurnId === null) delete record.currentTurnId;
227
+ if (typeof event.payload.currentJobId === "string") record.currentJobId = event.payload.currentJobId;
228
+ else if (event.payload.currentJobId === null) delete record.currentJobId;
229
+ record.updatedAt = now;
230
+ break;
231
+ }
232
+ case "agent.session": {
233
+ const id = requireString(event.agentId, "agentId");
234
+ const record = state.agents[id];
235
+ if (!record) throw new Error(`${id}: unknown Agent`);
236
+ const path = requireString(event.payload.path, "agent.session.path");
237
+ if (record.sessionPath && record.sessionPath !== path) throw new Error(`${id}: conflicting child-session ownership`);
238
+ const otherOwner = [...Object.values(state.agents), ...Object.values(state.releasedAgents)].find((candidate) => candidate.id !== id && candidate.sessionPath === path);
239
+ if (otherOwner) throw new Error(`${id}: child session already owned by ${otherOwner.id}`);
240
+ record.sessionPath = path;
241
+ record.updatedAt = now;
242
+ break;
243
+ }
244
+ case "agent.released": {
245
+ const id = requireString(event.agentId, "agentId");
246
+ const record = state.agents[id];
247
+ if (!record) throw new Error(`${id}: unknown Agent`);
248
+ if (record.state === "running" || record.state === "queued") throw new Error(`${id}: active Agent cannot be released`);
249
+ state.releasedAgents[id] = clone(record);
250
+ delete state.agents[id];
251
+ break;
252
+ }
253
+ case "job.created": {
254
+ const record = recordFromPayload<JobRecord>(event);
255
+ if (record.id !== event.jobId || record.agentId !== event.agentId) throw new Error("job.created ID mismatch");
256
+ if (!state.agents[record.agentId]) throw new Error(`${record.id}: unknown owning Agent`);
257
+ if (state.jobs[record.id]) throw new Error(`${record.id}: duplicate job`);
258
+ if (record.state !== "queued") throw new Error(`${record.id}: new job must be queued`);
259
+ state.jobs[record.id] = record;
260
+ break;
261
+ }
262
+ case "job.state": {
263
+ const id = requireString(event.jobId, "jobId");
264
+ const record = state.jobs[id];
265
+ if (!record) throw new Error(`${id}: unknown job`);
266
+ record.state = applyStateTransition(`Job ${id}`, record.state, event.payload, JOB_TRANSITIONS);
267
+ record.updatedAt = now;
268
+ if (typeof event.payload.error === "string") record.error = event.payload.error;
269
+ break;
270
+ }
271
+ case "turn.created": {
272
+ const record = recordFromPayload<TurnRecord>(event);
273
+ if (record.id !== event.turnId || record.agentId !== event.agentId || (record.jobId && record.jobId !== event.jobId)) throw new Error("turn.created ID mismatch");
274
+ if (!state.agents[record.agentId]) throw new Error(`${record.id}: unknown owning Agent`);
275
+ if (record.jobId && !state.jobs[record.jobId]) throw new Error(`${record.id}: unknown owning job`);
276
+ if (state.turns[record.id]) throw new Error(`${record.id}: duplicate turn`);
277
+ if (record.state !== "queued") throw new Error(`${record.id}: new turn must be queued`);
278
+ state.turns[record.id] = record;
279
+ break;
280
+ }
281
+ case "turn.state": {
282
+ const id = requireString(event.turnId, "turnId");
283
+ const record = state.turns[id];
284
+ if (!record) throw new Error(`${id}: unknown turn`);
285
+ record.state = applyStateTransition(`Turn ${id}`, record.state, event.payload, TURN_TRANSITIONS);
286
+ record.updatedAt = now;
287
+ if (typeof event.payload.outcome === "string") record.outcome = event.payload.outcome;
288
+ break;
289
+ }
290
+ case "turn.audit": {
291
+ const id = requireString(event.turnId, "turnId");
292
+ const record = state.turns[id];
293
+ if (!record || record.agentId !== event.agentId) throw new Error(`${id}: unknown or mismatched turn audit owner`);
294
+ if (record.state !== "running") throw new Error(`${id}: turn audit is accepted only while running`);
295
+ record.metadata = { ...(record.metadata ?? {}), ...structuredClone(event.payload) };
296
+ record.updatedAt = now;
297
+ break;
298
+ }
299
+ case "mailbox.put": {
300
+ const id = requireString(event.agentId, "agentId");
301
+ if (!state.agents[id]) throw new Error(`${id}: unknown Agent mailbox`);
302
+ const messages = event.payload.messages;
303
+ if (!Array.isArray(messages)) throw new Error(`${id}: mailbox messages must be an array`);
304
+ if (messages.length > 100) throw new Error(`${id}: mailbox exceeds cap 100`);
305
+ const messageIds = messages.map((message) => requireString(requireRecord(message, `${id}.mailbox.message`).id, `${id}.mailbox.message.id`));
306
+ if (new Set(messageIds).size !== messageIds.length) throw new Error(`${id}: mailbox contains duplicate message IDs`);
307
+ state.mailboxes[id] = { agentId: id, messages: clone(messages as Array<Record<string, unknown>>) };
308
+ break;
309
+ }
310
+ case "message.put": {
311
+ const id = requireString(event.messageId, "messageId");
312
+ const agentId = requireString(event.payload.agentId, "message.agentId");
313
+ if (!state.agents[agentId] && !state.releasedAgents[agentId]) throw new Error(`${id}: unknown message Agent`);
314
+ const status = requireString(event.payload.status, "message.status");
315
+ const existing = state.messages[id];
316
+ if (!existing && status !== "pending") throw new Error(`${id}: first message state must be pending`);
317
+ if (existing) {
318
+ if (existing.agentId !== agentId) throw new Error(`${id}: conflicting message ownership`);
319
+ if (existing.status !== "pending") throw new Error(`${id}: terminal message receipt cannot transition`);
320
+ if (!["delivered", "queued", "failed", "overflow"].includes(status)) throw new Error(`${id}: invalid message transition`);
321
+ }
322
+ state.messages[id] = clone(event.payload);
323
+ break;
324
+ }
325
+ case "delivery.put": {
326
+ const id = requireString(event.deliveryId, "deliveryId");
327
+ const status = requireString(event.payload.status, "delivery.status");
328
+ const existing = state.deliveries[id];
329
+ if (!existing && status !== "pending") throw new Error(`${id}: first delivery state must be pending`);
330
+ if (existing) {
331
+ if (existing.status !== "pending" || status !== "delivered") throw new Error(`${id}: invalid delivery transition`);
332
+ if (existing.agentId !== event.payload.agentId || existing.jobId !== event.payload.jobId) throw new Error(`${id}: conflicting delivery ownership`);
333
+ }
334
+ state.deliveries[id] = clone(event.payload);
335
+ break;
336
+ }
337
+ case "model.put": {
338
+ const id = requireString(event.agentId, "agentId");
339
+ if (!state.agents[id]) throw new Error(`${id}: unknown Agent model override`);
340
+ requireString(event.payload.model, "model override");
341
+ state.models[id] = clone(event.payload);
342
+ break;
343
+ }
344
+ case "model.clear": {
345
+ const id = requireString(event.agentId, "agentId");
346
+ if (!state.agents[id]) throw new Error(`${id}: unknown Agent model override`);
347
+ delete state.models[id];
348
+ break;
349
+ }
350
+ case "workspace.put": {
351
+ const id = requireString(event.agentId, "agentId");
352
+ if (!state.agents[id] && !state.releasedAgents[id]) throw new Error(`${id}: unknown Agent workspace`);
353
+ const existing = state.workspaces[id];
354
+ if (existing) {
355
+ if (existing.mode !== event.payload.mode || existing.root !== event.payload.root || existing.projectRoot !== event.payload.projectRoot) throw new Error(`${id}: conflicting workspace ownership`);
356
+ const allowed: Record<string, string[]> = { active: ["finalized", "cleaned", "cleanup-failed"], finalized: ["cleaned", "cleanup-failed"], cleaned: [], "cleanup-failed": [] };
357
+ if (!allowed[String(existing.status)]?.includes(String(event.payload.status))) throw new Error(`${id}: invalid workspace transition ${String(existing.status)}->${String(event.payload.status)}`);
358
+ } else if (event.payload.status !== "active") {
359
+ throw new Error(`${id}: first workspace state must be active`);
360
+ }
361
+ state.workspaces[id] = clone(event.payload);
362
+ break;
363
+ }
364
+ default:
365
+ throw new Error(`unsupported coordinator event kind: ${(event as CoordinatorEvent).kind}`);
366
+ }
367
+
368
+ state.lastSequence = event.sequence;
369
+ state.appliedEventIds.push(event.eventId);
370
+ return state;
371
+ }
372
+
373
+ async function parseJournal(path: string, parentId: string): Promise<{ events: CoordinatorEvent[]; diagnostics: Omit<ReplayDiagnostics, "snapshotLoaded"> }> {
374
+ let content = "";
375
+ try {
376
+ content = await readFile(path, "utf8");
377
+ } catch (error) {
378
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
379
+ }
380
+ const hasFinalNewline = content.endsWith("\n") || content.length === 0;
381
+ const lines = content.split("\n");
382
+ if (hasFinalNewline) lines.pop();
383
+ const events: CoordinatorEvent[] = [];
384
+ let toleratedFinalPartialLine = false;
385
+ let ignoredBytes = 0;
386
+ for (let index = 0; index < lines.length; index += 1) {
387
+ const line = lines[index]!;
388
+ if (!line.trim()) throw new Error(`coordinator journal line ${index + 1}: empty middle line`);
389
+ let event: CoordinatorEvent;
390
+ try {
391
+ event = JSON.parse(line) as CoordinatorEvent;
392
+ } catch (error) {
393
+ const isFinalUnterminated = index === lines.length - 1 && !hasFinalNewline;
394
+ if (isFinalUnterminated) {
395
+ toleratedFinalPartialLine = true;
396
+ ignoredBytes = Buffer.byteLength(line);
397
+ break;
398
+ }
399
+ throw new Error(`coordinator journal line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
400
+ }
401
+ try {
402
+ validateEventEnvelope(event, parentId);
403
+ } catch (error) {
404
+ throw new Error(`coordinator journal line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
405
+ }
406
+ events.push(event);
407
+ }
408
+ return { events, diagnostics: { toleratedFinalPartialLine, ignoredBytes } };
409
+ }
410
+
411
+ function validateSnapshot(snapshot: CoordinatorSnapshot, parentId: string): CoordinatorState {
412
+ if (snapshot.schemaVersion !== COORDINATOR_SCHEMA_VERSION || snapshot.parentId !== parentId) throw new Error("snapshot schema or parent ownership mismatch");
413
+ if (!snapshot.state || snapshot.state.schemaVersion !== COORDINATOR_SCHEMA_VERSION || snapshot.state.parentId !== parentId) throw new Error("snapshot state ownership mismatch");
414
+ if (snapshot.checkpointSequence !== snapshot.state.lastSequence) throw new Error("snapshot checkpoint mismatch");
415
+ if (!Number.isSafeInteger(snapshot.checkpointSequence) || snapshot.checkpointSequence < 0) throw new Error("snapshot sequence is invalid");
416
+ if (!Array.isArray(snapshot.state.appliedEventIds) || snapshot.state.appliedEventIds.length !== snapshot.state.lastSequence) throw new Error("snapshot event index is invalid");
417
+ if (new Set(snapshot.state.appliedEventIds).size !== snapshot.state.appliedEventIds.length) throw new Error("snapshot contains duplicate event IDs");
418
+ const activeAgents = snapshot.state.agents ?? {};
419
+ const releasedAgents = snapshot.state.releasedAgents ?? {};
420
+ for (const id of Object.keys(activeAgents)) if (releasedAgents[id]) throw new Error(`${id}: snapshot Agent is both active and released`);
421
+ const sessionOwners = new Map<string, string>();
422
+ for (const [id, agent] of [...Object.entries(activeAgents), ...Object.entries(releasedAgents)]) {
423
+ if (agent.id !== id) throw new Error(`${id}: snapshot Agent key mismatch`);
424
+ assertSafeAgentId(id);
425
+ if (!(agent.state in AGENT_TRANSITIONS)) throw new Error(`${id}: snapshot Agent state is invalid`);
426
+ if (agent.sessionPath) {
427
+ const owner = sessionOwners.get(agent.sessionPath);
428
+ if (owner) throw new Error(`${id}: snapshot child session also owned by ${owner}`);
429
+ sessionOwners.set(agent.sessionPath, id);
430
+ }
431
+ }
432
+ for (const [id, job] of Object.entries(snapshot.state.jobs ?? {})) {
433
+ if (job.id !== id || (!activeAgents[job.agentId] && !releasedAgents[job.agentId])) throw new Error(`${id}: snapshot job ownership mismatch`);
434
+ if (!(job.state in JOB_TRANSITIONS)) throw new Error(`${id}: snapshot job state is invalid`);
435
+ }
436
+ for (const [id, turn] of Object.entries(snapshot.state.turns ?? {})) {
437
+ if (turn.id !== id || (!activeAgents[turn.agentId] && !releasedAgents[turn.agentId])) throw new Error(`${id}: snapshot turn ownership mismatch`);
438
+ if (turn.jobId && !snapshot.state.jobs[turn.jobId]) throw new Error(`${id}: snapshot turn job mismatch`);
439
+ if (!(turn.state in TURN_TRANSITIONS)) throw new Error(`${id}: snapshot turn state is invalid`);
440
+ }
441
+ return clone(snapshot.state);
442
+ }
443
+
444
+ async function loadSnapshot(path: string, parentId: string): Promise<CoordinatorState | undefined> {
445
+ let content: string;
446
+ try {
447
+ content = await readFile(path, "utf8");
448
+ } catch (error) {
449
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
450
+ throw error;
451
+ }
452
+ try {
453
+ return validateSnapshot(JSON.parse(content) as CoordinatorSnapshot, parentId);
454
+ } catch (error) {
455
+ throw new Error(`invalid coordinator snapshot: ${error instanceof Error ? error.message : String(error)}`);
456
+ }
457
+ }
458
+
459
+ export async function replayCoordinator(layout: SidecarLayout, parentId: string): Promise<ReplayResult> {
460
+ const [{ events, diagnostics }, snapshotState] = await Promise.all([
461
+ parseJournal(layout.coordinatorPath, parentId),
462
+ loadSnapshot(layout.snapshotPath, parentId),
463
+ ]);
464
+ const seen = new Set<string>();
465
+ let expectedSequence = 1;
466
+ for (const event of events) {
467
+ if (event.sequence !== expectedSequence) throw new Error(`event ${event.eventId}: journal sequence gap at ${expectedSequence}`);
468
+ if (seen.has(event.eventId)) throw new Error(`event ${event.eventId}: duplicate eventId`);
469
+ seen.add(event.eventId);
470
+ expectedSequence += 1;
471
+ }
472
+
473
+ let state = snapshotState ?? createInitialCoordinatorState(parentId);
474
+ if (snapshotState) {
475
+ const snapshotIds = new Set(snapshotState.appliedEventIds);
476
+ for (const event of events.filter((candidate) => candidate.sequence <= snapshotState.lastSequence)) {
477
+ if (!snapshotIds.has(event.eventId)) throw new Error(`snapshot/journal event mismatch at sequence ${event.sequence}`);
478
+ }
479
+ }
480
+ for (const event of events.filter((candidate) => candidate.sequence > state.lastSequence)) {
481
+ state = applyCoordinatorEvent(state, event);
482
+ }
483
+ if (events.length < state.lastSequence) throw new Error("snapshot checkpoint exceeds retained coordinator journal");
484
+ return {
485
+ state,
486
+ events,
487
+ diagnostics: { ...diagnostics, snapshotLoaded: snapshotState !== undefined },
488
+ };
489
+ }
490
+
491
+ export interface CoordinatorJournalOptions {
492
+ clock?: () => Date;
493
+ eventId?: () => string;
494
+ }
495
+
496
+ export class CoordinatorJournal {
497
+ private state: CoordinatorState;
498
+ private tail: Promise<void> = Promise.resolve();
499
+ private readonly clock: () => Date;
500
+ private readonly nextEventId: () => string;
501
+
502
+ private constructor(
503
+ readonly layout: SidecarLayout,
504
+ state: CoordinatorState,
505
+ options: CoordinatorJournalOptions,
506
+ ) {
507
+ this.state = state;
508
+ this.clock = options.clock ?? (() => new Date());
509
+ this.nextEventId = options.eventId ?? randomUUID;
510
+ }
511
+
512
+ static async open(layout: SidecarLayout, parentId: string, options: CoordinatorJournalOptions = {}): Promise<{ journal: CoordinatorJournal; replay: ReplayResult }> {
513
+ const replay = await replayCoordinator(layout, parentId);
514
+ return { journal: new CoordinatorJournal(layout, replay.state, options), replay };
515
+ }
516
+
517
+ getState(): CoordinatorState {
518
+ return clone(this.state);
519
+ }
520
+
521
+ append(input: CoordinatorEventInput): Promise<CoordinatorEvent> {
522
+ const operation = this.tail.then(async () => {
523
+ const event: CoordinatorEvent = {
524
+ schemaVersion: COORDINATOR_SCHEMA_VERSION,
525
+ eventId: this.nextEventId(),
526
+ sequence: this.state.lastSequence + 1,
527
+ timestamp: this.clock().toISOString(),
528
+ parentId: this.state.parentId,
529
+ ...input,
530
+ };
531
+ const nextState = applyCoordinatorEvent(this.state, event);
532
+ await durableAppend(this.layout.coordinatorPath, `${JSON.stringify(event)}\n`);
533
+ this.state = nextState;
534
+ return event;
535
+ });
536
+ // Keep one ordered writer chain. A failed append poisons later operations
537
+ // so they reject rather than hanging or pretending the failed write did
538
+ // not happen.
539
+ this.tail = operation.then(() => undefined);
540
+ void this.tail.catch(() => undefined);
541
+ return operation;
542
+ }
543
+
544
+ async flush(): Promise<void> {
545
+ await this.tail;
546
+ }
547
+
548
+ compact(): Promise<CoordinatorSnapshot> {
549
+ const operation = this.tail.then(async () => {
550
+ const snapshot: CoordinatorSnapshot = {
551
+ schemaVersion: COORDINATOR_SCHEMA_VERSION,
552
+ parentId: this.state.parentId,
553
+ checkpointSequence: this.state.lastSequence,
554
+ createdAt: this.clock().toISOString(),
555
+ state: clone(this.state),
556
+ };
557
+ const temporary = `${this.layout.snapshotPath}.${process.pid}.${randomUUID()}.tmp`;
558
+ const handle = await open(temporary, "wx", 0o600);
559
+ try {
560
+ await handle.writeFile(`${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
561
+ await handle.sync();
562
+ } finally {
563
+ await handle.close();
564
+ }
565
+ await rename(temporary, this.layout.snapshotPath);
566
+ return snapshot;
567
+ });
568
+ this.tail = operation.then(() => undefined);
569
+ void this.tail.catch(() => undefined);
570
+ return operation;
571
+ }
572
+ }
573
+
574
+ function assertChildSessionPathLocation(layout: SidecarLayout, sessionPath: string): string {
575
+ if (!isAbsolute(sessionPath) || !sessionPath.endsWith(".jsonl")) throw new Error("child session path must be an absolute .jsonl file");
576
+ const normalized = resolve(sessionPath);
577
+ if (!isInside(resolve(layout.agentsDir), normalized)) throw new Error(`child session escapes parent-owned agents directory: ${sessionPath}`);
578
+ return normalized;
579
+ }
580
+
581
+ export async function validateExactChildSessionPath(layout: SidecarLayout, sessionPath: string): Promise<string> {
582
+ const normalized = assertChildSessionPathLocation(layout, sessionPath);
583
+ const canonicalAgentsDir = await realpath(layout.agentsDir);
584
+ const stat = await lstat(normalized);
585
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`child session must be a real file: ${sessionPath}`);
586
+ const canonical = await realpath(normalized);
587
+ if (!isInside(canonicalAgentsDir, canonical)) throw new Error(`child session escapes parent-owned agents directory: ${sessionPath}`);
588
+ return canonical;
589
+ }
590
+
591
+ export async function createChildSessionManager(
592
+ layout: SidecarLayout,
593
+ cwd: string,
594
+ agentId: string,
595
+ ): Promise<{ sessionManager: SessionManager; sessionPath: string }> {
596
+ assertSafeAgentId(agentId);
597
+ const sessionManager = SessionManager.create(cwd, layout.agentsDir, {
598
+ id: agentId,
599
+ parentSession: layout.parentSessionPath,
600
+ });
601
+ const sessionPath = sessionManager.getSessionFile();
602
+ if (!sessionPath) throw new Error(`${agentId}: official Pi did not return a persistent child session path`);
603
+ // Official Pi may defer the physical JSONL write until the first persisted
604
+ // entry. Record its exact SDK-returned location now; resume/open validates the
605
+ // real file and canonical path before use.
606
+ return { sessionManager, sessionPath: assertChildSessionPathLocation(layout, sessionPath) };
607
+ }
608
+
609
+ export async function openChildSessionManager(layout: SidecarLayout, sessionPath: string): Promise<SessionManager> {
610
+ const exactPath = await validateExactChildSessionPath(layout, sessionPath);
611
+ return SessionManager.open(exactPath, layout.agentsDir);
612
+ }
613
+
614
+ export async function registerChildSession(journal: CoordinatorJournal, agentId: string, sessionPath: string): Promise<void> {
615
+ await journal.append({ kind: "agent.session", agentId, payload: { path: sessionPath } });
616
+ }
617
+
618
+ export interface ResumeCoordinatorResult {
619
+ journal: CoordinatorJournal;
620
+ replay: ReplayResult;
621
+ reconciled: Array<{ type: "interrupted" | "unexecuted"; agentId: string; id: string }>;
622
+ }
623
+
624
+ async function validateRegisteredSessions(layout: SidecarLayout, state: CoordinatorState): Promise<void> {
625
+ for (const agent of [...Object.values(state.agents), ...Object.values(state.releasedAgents)]) {
626
+ if (agent.sessionPath) await validateExactChildSessionPath(layout, agent.sessionPath);
627
+ }
628
+ }
629
+
630
+ export async function reconcileUnfinishedCoordinator(
631
+ journal: CoordinatorJournal,
632
+ reason: "process-loss" | "graceful-shutdown" = "process-loss",
633
+ ): Promise<ResumeCoordinatorResult["reconciled"]> {
634
+ const reconciled: ResumeCoordinatorResult["reconciled"] = [];
635
+ const initial = journal.getState();
636
+ for (const agent of Object.values(initial.agents)) {
637
+ if (agent.state !== "running" && agent.state !== "queued") continue;
638
+ if (agent.currentTurnId) {
639
+ const turn = journal.getState().turns[agent.currentTurnId];
640
+ if (turn && (turn.state === "running" || turn.state === "queued")) {
641
+ await journal.append({
642
+ kind: "turn.state",
643
+ agentId: agent.id,
644
+ jobId: turn.jobId,
645
+ turnId: turn.id,
646
+ payload: { from: turn.state, to: "interrupted", outcome: `${reason}:no-auto-replay` },
647
+ });
648
+ reconciled.push({ type: "interrupted", agentId: agent.id, id: turn.id });
649
+ }
650
+ }
651
+ if (agent.currentJobId) {
652
+ const job = journal.getState().jobs[agent.currentJobId];
653
+ if (job?.state === "queued") {
654
+ await journal.append({
655
+ kind: "job.state",
656
+ agentId: agent.id,
657
+ jobId: job.id,
658
+ payload: { from: "queued", to: "unexecuted", error: `${reason}:no-auto-replay` },
659
+ });
660
+ reconciled.push({ type: "unexecuted", agentId: agent.id, id: job.id });
661
+ } else if (job?.state === "running") {
662
+ await journal.append({
663
+ kind: "job.state",
664
+ agentId: agent.id,
665
+ jobId: job.id,
666
+ payload: { from: "running", to: "failed", error: `${reason}:interrupted` },
667
+ });
668
+ }
669
+ }
670
+ await journal.append({
671
+ kind: "agent.state",
672
+ agentId: agent.id,
673
+ payload: { from: agent.state, to: "parked", reason: `${reason}:no-auto-replay` },
674
+ });
675
+ }
676
+ return reconciled;
677
+ }
678
+
679
+ export async function resumeCoordinator(
680
+ layout: SidecarLayout,
681
+ parentId: string,
682
+ options: CoordinatorJournalOptions = {},
683
+ ): Promise<ResumeCoordinatorResult> {
684
+ const opened = await CoordinatorJournal.open(layout, parentId, options);
685
+ const { journal, replay } = opened;
686
+ await validateRegisteredSessions(layout, journal.getState());
687
+ const reconciled = await reconcileUnfinishedCoordinator(journal, "process-loss");
688
+ return { journal, replay, reconciled };
689
+ }
690
+
691
+ export interface DisposableLiveSession {
692
+ dispose(): void | Promise<void>;
693
+ }
694
+
695
+ export interface TimerScheduler {
696
+ setTimeout(callback: () => void, delayMs: number): unknown;
697
+ clearTimeout(handle: unknown): void;
698
+ }
699
+
700
+ const defaultScheduler: TimerScheduler = {
701
+ setTimeout(callback, delayMs) {
702
+ return setTimeout(callback, delayMs);
703
+ },
704
+ clearTimeout(handle) {
705
+ clearTimeout(handle as ReturnType<typeof setTimeout>);
706
+ },
707
+ };
708
+
709
+ interface IdleEntry {
710
+ live: DisposableLiveSession;
711
+ onPark: () => void | Promise<void>;
712
+ timer?: unknown;
713
+ }
714
+
715
+ export class IdleLifecycleRegistry {
716
+ private readonly entries = new Map<string, IdleEntry>();
717
+
718
+ constructor(
719
+ readonly idleTtlMs = DEFAULT_IDLE_TTL_MS,
720
+ private readonly scheduler: TimerScheduler = defaultScheduler,
721
+ private readonly onTimerError: (error: unknown) => void = () => undefined,
722
+ ) {}
723
+
724
+ trackIdle(agentId: string, live: DisposableLiveSession, onPark: () => void | Promise<void>): void {
725
+ assertSafeAgentId(agentId);
726
+ this.clear(agentId);
727
+ const entry: IdleEntry = { live, onPark };
728
+ if (this.idleTtlMs > 0) {
729
+ entry.timer = this.scheduler.setTimeout(() => {
730
+ void this.parkNow(agentId).catch(this.onTimerError);
731
+ }, this.idleTtlMs);
732
+ }
733
+ this.entries.set(agentId, entry);
734
+ }
735
+
736
+ hasTimer(agentId: string): boolean {
737
+ return this.entries.get(agentId)?.timer !== undefined;
738
+ }
739
+
740
+ clear(agentId: string): void {
741
+ const entry = this.entries.get(agentId);
742
+ if (entry?.timer !== undefined) this.scheduler.clearTimeout(entry.timer);
743
+ this.entries.delete(agentId);
744
+ }
745
+
746
+ async parkNow(agentId: string): Promise<boolean> {
747
+ const entry = this.entries.get(agentId);
748
+ if (!entry) return false;
749
+ if (entry.timer !== undefined) this.scheduler.clearTimeout(entry.timer);
750
+ this.entries.delete(agentId);
751
+ await entry.live.dispose();
752
+ await entry.onPark();
753
+ return true;
754
+ }
755
+
756
+ async revive<T>(agent: AgentRecord, openExactSession: (sessionPath: string) => T | Promise<T>): Promise<T> {
757
+ if (agent.state === "aborted") throw new Error(`${agent.id}: aborted Agent cannot revive`);
758
+ if (agent.state !== "parked") throw new Error(`${agent.id}: only parked Agent can revive`);
759
+ if (!agent.sessionPath) throw new Error(`${agent.id}: parked Agent has no registered session path`);
760
+ return await openExactSession(agent.sessionPath);
761
+ }
762
+
763
+ async teardown(): Promise<void> {
764
+ const ids = [...this.entries.keys()];
765
+ const failures: unknown[] = [];
766
+ for (const id of ids) {
767
+ try {
768
+ await this.parkNow(id);
769
+ } catch (error) {
770
+ failures.push(error);
771
+ }
772
+ }
773
+ if (failures.length > 0) throw new AggregateError(failures, "one or more idle Agents failed to park during teardown");
774
+ }
775
+ }