@cr1ms0n/pi-subagent 0.8.1

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 (46) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/LICENSE +21 -0
  3. package/README.md +543 -0
  4. package/docs/ARCHITECTURE.md +125 -0
  5. package/docs/COST-ACCOUNTING.md +66 -0
  6. package/docs/PLAN.md +325 -0
  7. package/docs/RELEASING.md +32 -0
  8. package/docs/ROADMAP.md +252 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/UI-OVERHAUL.md +186 -0
  11. package/docs/UX.md +141 -0
  12. package/extensions/subagent.ts +1 -0
  13. package/package.json +58 -0
  14. package/skills/subagent/SKILL.md +103 -0
  15. package/src/agents.ts +285 -0
  16. package/src/backend.ts +146 -0
  17. package/src/backends/claude.ts +384 -0
  18. package/src/backends/codex.ts +330 -0
  19. package/src/backends/index.ts +26 -0
  20. package/src/backends/pi.ts +94 -0
  21. package/src/btw.ts +34 -0
  22. package/src/config.ts +254 -0
  23. package/src/distill.ts +222 -0
  24. package/src/extension.ts +1527 -0
  25. package/src/format.ts +365 -0
  26. package/src/index.ts +60 -0
  27. package/src/launch.ts +120 -0
  28. package/src/maintenance.ts +6 -0
  29. package/src/model-policy.ts +157 -0
  30. package/src/notifications.ts +106 -0
  31. package/src/orchestrator.ts +247 -0
  32. package/src/output.ts +124 -0
  33. package/src/persistence.ts +334 -0
  34. package/src/policy.ts +500 -0
  35. package/src/process-lock.ts +687 -0
  36. package/src/protocol.ts +290 -0
  37. package/src/registry.ts +632 -0
  38. package/src/runner.ts +850 -0
  39. package/src/schema.ts +166 -0
  40. package/src/semaphore.ts +123 -0
  41. package/src/structured.ts +169 -0
  42. package/src/transcript.ts +360 -0
  43. package/src/types.ts +197 -0
  44. package/src/ui.ts +545 -0
  45. package/src/usage.ts +274 -0
  46. package/src/worktree.ts +753 -0
@@ -0,0 +1,632 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { Message } from "@earendil-works/pi-ai";
4
+ import type { SubagentConfig } from "./config.js";
5
+ import type { PersistenceAdapter, PersistedResult } from "./persistence.js";
6
+ import { PersistenceLayer } from "./persistence.js";
7
+ import type { ProcessLockManager } from "./process-lock.js";
8
+ import type { RunMode, RunSnapshot, RunState, TaskResult, TaskSpec } from "./types.js";
9
+ import { emptyUsage } from "./types.js";
10
+
11
+ export interface LiveRun {
12
+ id: string;
13
+ sessionKey: string;
14
+ mode: RunMode;
15
+ state: RunState;
16
+ startedAt: number;
17
+ endedAt?: number;
18
+ taskPreviews: string[];
19
+ taskSpecs: TaskSpec[];
20
+ results: TaskResult[];
21
+ summary?: string;
22
+ delivered: boolean;
23
+ promise: Promise<unknown>;
24
+ controller: AbortController;
25
+ childSessionIds: Set<string>;
26
+ lastProgressCheckpoint: number;
27
+ }
28
+
29
+ export interface RunLookupResult {
30
+ status: "found" | "not-found" | "ambiguous";
31
+ run?: LiveRun | RunSnapshot;
32
+ matches?: string[];
33
+ }
34
+
35
+ export interface SessionRuntime {
36
+ sessionKey: string;
37
+ runs: Map<string, LiveRun>;
38
+ snapshots: Map<string, RunSnapshot>;
39
+ activeResumes: Map<string, string>;
40
+ shuttingDown: boolean;
41
+ }
42
+
43
+ export type RegistryEvent =
44
+ | { type: "changed"; sessionKey: string; runId: string }
45
+ | { type: "terminal"; sessionKey: string; runId: string; state: RunState };
46
+
47
+ const terminalStates = new Set<RunState>(["completed", "partial", "failed", "cancelled", "lost", "timeout"]);
48
+
49
+ /** Trailing coalesce window for high-frequency "changed" events. */
50
+ const EMIT_COALESCE_MS = 100;
51
+
52
+ function finalText(messages: Message[], fallback?: string): string | undefined {
53
+ for (let i = messages.length - 1; i >= 0; i--) {
54
+ const message = messages[i];
55
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
56
+ const text = message.content
57
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
58
+ .map((part: any) => part.text)
59
+ .join("");
60
+ if (text) return text;
61
+ }
62
+ return fallback;
63
+ }
64
+
65
+ function utf8Prefix(value: string | undefined, maxBytes: number): string | undefined {
66
+ if (!value) return undefined;
67
+ const buffer = Buffer.from(value, "utf8");
68
+ if (buffer.length <= maxBytes) return value;
69
+ let end = maxBytes;
70
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
71
+ return buffer.subarray(0, end).toString("utf8");
72
+ }
73
+
74
+ /**
75
+ * Full result projection: capped transcripts included. Used for terminal
76
+ * persistence and UI snapshots — the single converter for both paths.
77
+ */
78
+ export function toPersistedResult(result: TaskResult): PersistedResult {
79
+ return {
80
+ label: result.label,
81
+ task: result.task.slice(0, 1_000),
82
+ state: result.state,
83
+ exitCode: result.exitCode,
84
+ stopReason: result.stopReason,
85
+ timeoutPhase: result.timeoutPhase,
86
+ errorMessage: result.errorMessage,
87
+ usage: result.usage,
88
+ model: result.model,
89
+ thinking: result.thinking,
90
+ profile: result.profile,
91
+ backend: result.backend,
92
+ canWrite: result.canWrite,
93
+ outputFile: result.outputFile,
94
+ outputMode: result.outputMode,
95
+ sessionId: result.sessionId,
96
+ process: result.process,
97
+ finalOutput: utf8Prefix(finalText(result.messages, result.liveText), 16_384),
98
+ transcript: utf8Prefix(result.transcript, 32_768),
99
+ worktree: result.worktree,
100
+ wrappedUp: result.wrappedUp,
101
+ stalledSince: result.stalledSince,
102
+ attempts: result.attempts,
103
+ attemptedModels: result.attemptedModels,
104
+ structuredOutput: result.structuredOutput,
105
+ structuredError: result.structuredError,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Memoized per-result projection for live snapshots. High-frequency emitters
111
+ * (footer refresh, streamed tool updates) re-snapshot the whole run on every
112
+ * event; only the task that actually changed should pay the projection cost
113
+ * (message scan + capped-string allocation).
114
+ */
115
+ const projectionCache = new WeakMap<TaskResult, { fingerprint: string; projected: PersistedResult }>();
116
+
117
+ function resultFingerprint(result: TaskResult): string {
118
+ return [
119
+ result.state,
120
+ result.usage.turns,
121
+ result.usage.cost,
122
+ result.sessionId ?? "",
123
+ result.messages.length,
124
+ result.liveText?.length ?? 0,
125
+ result.transcript?.length ?? 0,
126
+ result.errorMessage?.length ?? 0,
127
+ result.stalledSince ?? 0,
128
+ result.attempts ?? 0,
129
+ result.worktree ? 1 : 0,
130
+ result.structuredOutput !== undefined ? 1 : 0,
131
+ result.structuredError?.length ?? 0,
132
+ ].join("|");
133
+ }
134
+
135
+ function toPersistedResultCached(result: TaskResult): PersistedResult {
136
+ const fingerprint = resultFingerprint(result);
137
+ const cached = projectionCache.get(result);
138
+ if (cached && cached.fingerprint === fingerprint) return cached.projected;
139
+ const projected = toPersistedResult(result);
140
+ projectionCache.set(result, { fingerprint, projected });
141
+ return projected;
142
+ }
143
+
144
+ /**
145
+ * Lightweight result projection for checkpoint events: state + usage +
146
+ * pointers only. Keeps checkpoint entries small so the parent session file
147
+ * does not bloat during long runs. Transcripts are persisted once, at terminal.
148
+ */
149
+ export function toCheckpointResult(result: TaskResult): PersistedResult {
150
+ return {
151
+ label: result.label,
152
+ task: result.task.slice(0, 200),
153
+ state: result.state,
154
+ exitCode: result.exitCode,
155
+ stopReason: result.stopReason,
156
+ timeoutPhase: result.timeoutPhase,
157
+ errorMessage: utf8Prefix(result.errorMessage, 1_000),
158
+ usage: result.usage,
159
+ model: result.model,
160
+ thinking: result.thinking,
161
+ profile: result.profile,
162
+ backend: result.backend,
163
+ canWrite: result.canWrite,
164
+ outputFile: result.outputFile,
165
+ outputMode: result.outputMode,
166
+ sessionId: result.sessionId,
167
+ process: result.process,
168
+ worktree: result.worktree,
169
+ wrappedUp: result.wrappedUp,
170
+ stalledSince: result.stalledSince,
171
+ attempts: result.attempts,
172
+ attemptedModels: result.attemptedModels,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * One shared LiveRun → RunSnapshot projection with capped transcripts.
178
+ * Unchanged task results reuse their cached projection (see toPersistedResultCached).
179
+ */
180
+ export function snapshotFromLiveRun(run: LiveRun): RunSnapshot {
181
+ return {
182
+ schemaVersion: 1,
183
+ id: run.id,
184
+ sessionKey: run.sessionKey,
185
+ mode: run.mode,
186
+ state: run.state,
187
+ startedAt: run.startedAt,
188
+ endedAt: run.endedAt,
189
+ taskPreviews: run.taskPreviews,
190
+ summary: run.summary,
191
+ delivered: run.delivered,
192
+ results: run.results.map(toPersistedResultCached),
193
+ };
194
+ }
195
+
196
+ /** Session-owned live state plus immutable, bounded terminal snapshots. */
197
+ export class SessionScopedRunRegistry {
198
+ private readonly runtimes = new Map<string, SessionRuntime>();
199
+ private readonly persistence: PersistenceLayer;
200
+ private readonly listeners = new Set<(event: RegistryEvent) => void>();
201
+ private readonly pendingEmits = new Map<string, NodeJS.Timeout>();
202
+ private readonly locks?: ProcessLockManager;
203
+
204
+ constructor(
205
+ private readonly config: SubagentConfig,
206
+ persistenceAdapter: PersistenceAdapter,
207
+ locks?: ProcessLockManager,
208
+ ) {
209
+ this.persistence = new PersistenceLayer(persistenceAdapter, config);
210
+ this.locks = locks;
211
+ }
212
+
213
+ allocateRunId(): string {
214
+ return randomUUID();
215
+ }
216
+
217
+ subscribe(listener: (event: RegistryEvent) => void): () => void {
218
+ this.listeners.add(listener);
219
+ return () => this.listeners.delete(listener);
220
+ }
221
+
222
+ private emit(event: RegistryEvent): void {
223
+ for (const listener of this.listeners) listener(event);
224
+ }
225
+
226
+ /**
227
+ * Coalesce per-run "changed" bursts (live-text ticks can arrive per stdout
228
+ * chunk) into at most one listener notification per window. Terminal and
229
+ * structural events always flush immediately.
230
+ */
231
+ private emitChanged(sessionKey: string, runId: string, immediate = false): void {
232
+ const key = `${sessionKey}\u0000${runId}`;
233
+ if (immediate) {
234
+ const pending = this.pendingEmits.get(key);
235
+ if (pending) {
236
+ clearTimeout(pending);
237
+ this.pendingEmits.delete(key);
238
+ }
239
+ this.emit({ type: "changed", sessionKey, runId });
240
+ return;
241
+ }
242
+ if (this.pendingEmits.has(key)) return;
243
+ const timer = setTimeout(() => {
244
+ this.pendingEmits.delete(key);
245
+ this.emit({ type: "changed", sessionKey, runId });
246
+ }, EMIT_COALESCE_MS);
247
+ timer.unref?.();
248
+ this.pendingEmits.set(key, timer);
249
+ }
250
+
251
+ private clearPendingEmits(sessionKey?: string): void {
252
+ for (const [key, timer] of this.pendingEmits) {
253
+ if (sessionKey && !key.startsWith(`${sessionKey}\u0000`)) continue;
254
+ clearTimeout(timer);
255
+ this.pendingEmits.delete(key);
256
+ }
257
+ }
258
+
259
+ private getOrCreateRuntime(sessionKey: string): SessionRuntime {
260
+ let runtime = this.runtimes.get(sessionKey);
261
+ if (!runtime) {
262
+ runtime = {
263
+ sessionKey,
264
+ runs: new Map(),
265
+ snapshots: this.persistence.rebuild(sessionKey),
266
+ activeResumes: new Map(),
267
+ shuttingDown: false,
268
+ };
269
+ this.runtimes.set(sessionKey, runtime);
270
+ }
271
+ return runtime;
272
+ }
273
+
274
+ getSessionRuntime(sessionKey: string): SessionRuntime | undefined {
275
+ return this.runtimes.get(sessionKey);
276
+ }
277
+
278
+ getLiveRuns(sessionKey: string): LiveRun[] {
279
+ return [...this.getOrCreateRuntime(sessionKey).runs.values()];
280
+ }
281
+
282
+ getSnapshots(sessionKey: string): RunSnapshot[] {
283
+ return [...this.getOrCreateRuntime(sessionKey).snapshots.values()];
284
+ }
285
+
286
+ /** Live cwds of worktree-isolated tasks; used to protect them from sweeps. */
287
+ getLiveWorktreeCwds(sessionKey: string): Set<string> {
288
+ const cwds = new Set<string>();
289
+ for (const run of this.getOrCreateRuntime(sessionKey).runs.values()) {
290
+ for (const result of run.results) if (result.worktree?.cwd) cwds.add(result.worktree.cwd);
291
+ for (const spec of run.taskSpecs) if (spec.isolation === "worktree" && spec.cwd) cwds.add(spec.cwd);
292
+ }
293
+ return cwds;
294
+ }
295
+
296
+ /** Rebuild terminal history after active-branch navigation. Live runs stay session-owned. */
297
+ refreshSnapshots(sessionKey: string): void {
298
+ const runtime = this.getOrCreateRuntime(sessionKey);
299
+ runtime.snapshots = this.persistence.rebuild(sessionKey);
300
+ this.capSnapshots(runtime);
301
+ this.emitChanged(sessionKey, "branch", true);
302
+ }
303
+
304
+ lookup(idOrPrefix: string, sessionKey: string): RunLookupResult {
305
+ if (!idOrPrefix) return { status: "not-found" };
306
+ const runtime = this.getOrCreateRuntime(sessionKey);
307
+ const exact = runtime.runs.get(idOrPrefix) ?? runtime.snapshots.get(idOrPrefix);
308
+ if (exact) return { status: "found", run: exact };
309
+
310
+ const matches = new Map<string, LiveRun | RunSnapshot>();
311
+ for (const [id, run] of runtime.runs) if (id.startsWith(idOrPrefix)) matches.set(id, run);
312
+ for (const [id, run] of runtime.snapshots) if (id.startsWith(idOrPrefix)) matches.set(id, run);
313
+ if (matches.size === 0) return { status: "not-found" };
314
+ if (matches.size > 1) return { status: "ambiguous", matches: [...matches.keys()].sort() };
315
+ return { status: "found", run: [...matches.values()][0] };
316
+ }
317
+
318
+ start(
319
+ sessionKey: string,
320
+ mode: RunMode,
321
+ specs: TaskSpec[],
322
+ controller: AbortController,
323
+ promise: Promise<unknown>,
324
+ labels: string[] = [],
325
+ id = this.allocateRunId(),
326
+ ): string {
327
+ const runtime = this.getOrCreateRuntime(sessionKey);
328
+ if (runtime.shuttingDown) throw new Error("Cannot start a subagent while the parent session is shutting down");
329
+ if (runtime.runs.has(id) || runtime.snapshots.has(id)) throw new Error(`Duplicate run id ${id}`);
330
+
331
+ const startedAt = Date.now();
332
+ const taskPreviews = specs.map((spec, i) => `${labels[i] || `task-${i + 1}`}: ${spec.task.slice(0, 120)}`);
333
+ const results: TaskResult[] = specs.map((spec, i) => ({
334
+ label: labels[i] || `task-${i + 1}`,
335
+ task: spec.task,
336
+ state: "queued",
337
+ exitCode: null,
338
+ messages: [],
339
+ stderr: "",
340
+ usage: emptyUsage(),
341
+ outputFile: spec.output,
342
+ outputMode: spec.outputMode,
343
+ thinking: spec.thinking,
344
+ profile: spec.profile,
345
+ backend: spec.backend,
346
+ canWrite: spec.canWrite,
347
+ protocol: {
348
+ headerSeen: false,
349
+ assistantEndSeen: false,
350
+ agentEndSeen: false,
351
+ agentSettledSeen: false,
352
+ validEvents: 0,
353
+ parseErrors: 0,
354
+ },
355
+ }));
356
+
357
+ runtime.runs.set(id, {
358
+ id,
359
+ sessionKey,
360
+ mode,
361
+ state: "queued",
362
+ startedAt,
363
+ taskPreviews,
364
+ taskSpecs: [...specs],
365
+ results,
366
+ delivered: false,
367
+ promise,
368
+ controller,
369
+ childSessionIds: new Set(),
370
+ lastProgressCheckpoint: 0,
371
+ });
372
+ this.persistence.persist(id, sessionKey, "start", {
373
+ mode,
374
+ state: "queued",
375
+ startedAt,
376
+ taskPreviews,
377
+ results: results.map(toCheckpointResult),
378
+ });
379
+ this.emitChanged(sessionKey, id, true);
380
+ return id;
381
+ }
382
+
383
+ checkpoint(
384
+ id: string,
385
+ sessionKey: string,
386
+ updates: {
387
+ childSessionId?: string;
388
+ progress?: string;
389
+ turn?: number;
390
+ resultIndex?: number;
391
+ resultUpdate?: Partial<TaskResult>;
392
+ state?: RunState;
393
+ },
394
+ ): boolean {
395
+ const runtime = this.runtimes.get(sessionKey);
396
+ const run = runtime?.runs.get(id);
397
+ if (!runtime || !run || run.sessionKey !== sessionKey || runtime.shuttingDown) return false;
398
+
399
+ const index = updates.resultIndex ?? 0;
400
+ const result = run.results[index];
401
+ const previousTurns = result?.usage.turns ?? 0;
402
+ const previousCost = result?.usage.cost ?? 0;
403
+ const previousRunState = run.state;
404
+ if (result && updates.resultUpdate) Object.assign(result, updates.resultUpdate);
405
+ const usageAdvanced = !!result && (result.usage.turns > previousTurns || result.usage.cost > previousCost);
406
+ if (updates.state) run.state = updates.state;
407
+ else if (run.state === "queued") run.state = "running";
408
+ const stateChanged = run.state !== previousRunState;
409
+
410
+ const childSessionId = updates.childSessionId ?? updates.resultUpdate?.sessionId;
411
+ let newChildSession = false;
412
+ if (childSessionId) {
413
+ newChildSession = !run.childSessionIds.has(childSessionId);
414
+ run.childSessionIds.add(childSessionId);
415
+ if (result) result.sessionId = childSessionId;
416
+ // Crash recovery requirement: session ids are never throttled.
417
+ if (newChildSession) {
418
+ this.persistence.persist(id, sessionKey, "checkpoint", {
419
+ state: run.state,
420
+ resultIndex: index,
421
+ childSessionId,
422
+ results: run.results.map(toCheckpointResult),
423
+ });
424
+ }
425
+ }
426
+
427
+ // Persist lightweight checkpoints (state + usage + pointers, never
428
+ // transcripts) only when billed usage advanced, or on a throttled progress
429
+ // beat. Full transcripts are written exactly once, in the terminal event.
430
+ const now = Date.now();
431
+ if (usageAdvanced || ((updates.progress || updates.turn !== undefined) && now - run.lastProgressCheckpoint >= 500)) {
432
+ run.lastProgressCheckpoint = now;
433
+ this.persistence.persist(id, sessionKey, "checkpoint", {
434
+ state: run.state,
435
+ resultIndex: index,
436
+ progress: utf8Prefix(updates.progress, 200),
437
+ turn: updates.turn,
438
+ results: run.results.map(toCheckpointResult),
439
+ });
440
+ }
441
+ // Structural changes flush immediately; live-text ticks coalesce.
442
+ this.emitChanged(sessionKey, id, usageAdvanced || stateChanged || newChildSession);
443
+ return true;
444
+ }
445
+
446
+ complete(
447
+ id: string,
448
+ sessionKey: string,
449
+ finalState: RunState,
450
+ summary?: string,
451
+ finalResults?: TaskResult[],
452
+ ): boolean {
453
+ const runtime = this.runtimes.get(sessionKey);
454
+ const run = runtime?.runs.get(id);
455
+ if (!runtime || !run || run.sessionKey !== sessionKey) return false;
456
+
457
+ const endedAt = Date.now();
458
+ const results = finalResults ?? run.results;
459
+ const snapshot: RunSnapshot = {
460
+ schemaVersion: 1,
461
+ id,
462
+ sessionKey,
463
+ mode: run.mode,
464
+ state: terminalStates.has(finalState) ? finalState : "failed",
465
+ startedAt: run.startedAt,
466
+ endedAt,
467
+ taskPreviews: run.taskPreviews,
468
+ summary,
469
+ delivered: run.delivered,
470
+ results: results.map(toPersistedResult),
471
+ };
472
+ runtime.snapshots.set(id, snapshot);
473
+ runtime.runs.delete(id);
474
+ this.releaseLocksForRun(runtime, id);
475
+ this.persistence.persist(id, sessionKey, "terminal", {
476
+ mode: snapshot.mode,
477
+ state: snapshot.state,
478
+ startedAt: snapshot.startedAt,
479
+ endedAt,
480
+ taskPreviews: snapshot.taskPreviews,
481
+ summary,
482
+ delivered: snapshot.delivered,
483
+ results: snapshot.results,
484
+ });
485
+ this.capSnapshots(runtime);
486
+ this.clearPendingEmitsForRun(sessionKey, id);
487
+ this.emit({ type: "terminal", sessionKey, runId: id, state: snapshot.state });
488
+ return true;
489
+ }
490
+
491
+ private clearPendingEmitsForRun(sessionKey: string, runId: string): void {
492
+ const key = `${sessionKey}\u0000${runId}`;
493
+ const pending = this.pendingEmits.get(key);
494
+ if (pending) {
495
+ clearTimeout(pending);
496
+ this.pendingEmits.delete(key);
497
+ }
498
+ }
499
+
500
+ /** Returns false when this result was already delivered. */
501
+ markDelivered(id: string, sessionKey: string): boolean {
502
+ const runtime = this.getOrCreateRuntime(sessionKey);
503
+ const run = runtime.runs.get(id) ?? runtime.snapshots.get(id);
504
+ if (!run || run.delivered) return false;
505
+ run.delivered = true;
506
+ this.persistence.markDelivered(id, sessionKey);
507
+ this.emitChanged(sessionKey, id, true);
508
+ return true;
509
+ }
510
+
511
+ markDismissed(id: string, sessionKey: string): boolean {
512
+ return this.markDelivered(id, sessionKey);
513
+ }
514
+
515
+ acquireResumeLocks(childSessionIds: string[], runId: string, sessionKey: string): {
516
+ ok: boolean;
517
+ conflict?: { sessionId: string; runId: string };
518
+ } {
519
+ const runtime = this.getOrCreateRuntime(sessionKey);
520
+ const unique = [...new Set(childSessionIds.filter(Boolean))];
521
+ // Block resume of runs whose ownership is not yet proven dead.
522
+ for (const snapshot of runtime.snapshots.values()) {
523
+ if (!snapshot.resumeBlocked) continue;
524
+ for (const result of snapshot.results) {
525
+ if (result.sessionId && unique.includes(result.sessionId)) {
526
+ return {
527
+ ok: false,
528
+ conflict: {
529
+ sessionId: result.sessionId,
530
+ runId: snapshot.id,
531
+ },
532
+ };
533
+ }
534
+ }
535
+ }
536
+ // In-memory lock first (fast path within one process).
537
+ for (const sessionId of unique) {
538
+ const holder = runtime.activeResumes.get(sessionId);
539
+ if (holder && holder !== runId) return { ok: false, conflict: { sessionId, runId: holder } };
540
+ }
541
+ // Durable, machine-wide lock — survival across parent crashes and cross-process contention.
542
+ const durableHeld: string[] = [];
543
+ if (this.locks) {
544
+ for (const sessionId of unique) {
545
+ const acquired = this.locks.acquireSessionLock(sessionId, {
546
+ ownerId: `${sessionKey}:${runId}`,
547
+ runId,
548
+ parentSessionKey: sessionKey,
549
+ });
550
+ if (!acquired.ok) {
551
+ for (const held of durableHeld) this.locks.releaseSessionLock(held, runId);
552
+ return {
553
+ ok: false,
554
+ conflict: { sessionId: acquired.conflict.childSessionId, runId: acquired.conflict.runId },
555
+ };
556
+ }
557
+ durableHeld.push(sessionId);
558
+ }
559
+ }
560
+ for (const sessionId of unique) runtime.activeResumes.set(sessionId, runId);
561
+ return { ok: true };
562
+ }
563
+
564
+ acquireResumeLock(childSessionId: string, runId: string, sessionKey: string, isFork = false): boolean {
565
+ if (isFork) return true;
566
+ return this.acquireResumeLocks([childSessionId], runId, sessionKey).ok;
567
+ }
568
+
569
+ releaseResumeLock(childSessionId: string, sessionKey: string, runId?: string): void {
570
+ const runtime = this.runtimes.get(sessionKey);
571
+ if (!runtime) return;
572
+ if (!runId || runtime.activeResumes.get(childSessionId) === runId) runtime.activeResumes.delete(childSessionId);
573
+ if (this.locks) this.locks.releaseSessionLock(childSessionId, runId);
574
+ }
575
+
576
+ private releaseLocksForRun(runtime: SessionRuntime, runId: string): void {
577
+ for (const [sessionId, holder] of [...runtime.activeResumes]) {
578
+ if (holder === runId) {
579
+ runtime.activeResumes.delete(sessionId);
580
+ this.locks?.releaseSessionLock(sessionId, runId);
581
+ }
582
+ }
583
+ }
584
+
585
+ /** Clear the resume-blocked flag after orphan reconcile has proven the child dead. */
586
+ clearResumeBlock(id: string, sessionKey: string): void {
587
+ const runtime = this.getOrCreateRuntime(sessionKey);
588
+ const snapshot = runtime.snapshots.get(id);
589
+ if (!snapshot || !snapshot.resumeBlocked) return;
590
+ snapshot.resumeBlocked = false;
591
+ this.persistence.persist(id, sessionKey, "checkpoint", { resumeBlocked: false, state: snapshot.state });
592
+ this.emitChanged(sessionKey, id, true);
593
+ }
594
+
595
+ async shutdown(sessionKey: string, graceMs = 8_000): Promise<void> {
596
+ const runtime = this.runtimes.get(sessionKey);
597
+ if (!runtime) return;
598
+ runtime.shuttingDown = true;
599
+ const live = [...runtime.runs.values()];
600
+ for (const run of live) run.controller.abort();
601
+
602
+ let timer: NodeJS.Timeout | undefined;
603
+ await Promise.race([
604
+ Promise.allSettled(live.map((run) => run.promise)),
605
+ new Promise<void>((resolve) => {
606
+ timer = setTimeout(resolve, graceMs);
607
+ timer.unref?.();
608
+ }),
609
+ ]);
610
+ if (timer) clearTimeout(timer);
611
+
612
+ // Any orchestration promise that did not call complete is snapshotted as cancelled.
613
+ for (const run of [...runtime.runs.values()]) {
614
+ this.complete(run.id, sessionKey, "cancelled", "Cancelled when the parent session shut down", run.results);
615
+ }
616
+ runtime.activeResumes.clear();
617
+ runtime.shuttingDown = false;
618
+ this.clearPendingEmits(sessionKey);
619
+ }
620
+
621
+ planSessionRetention(referencedSessionIds = new Set<string>()): { keep: string[]; candidates: string[] } {
622
+ return this.persistence.planRetention(referencedSessionIds);
623
+ }
624
+
625
+ private capSnapshots(runtime: SessionRuntime): void {
626
+ while (runtime.snapshots.size > this.config.maxCompletedInMemory) {
627
+ const oldest = [...runtime.snapshots.values()].sort((a, b) => a.startedAt - b.startedAt)[0];
628
+ if (!oldest) break;
629
+ runtime.snapshots.delete(oldest.id);
630
+ }
631
+ }
632
+ }