spexcode 0.6.5 → 0.6.7

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 (56) hide show
  1. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +2 -1
  2. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +29 -2
  3. package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
  4. package/node_modules/@spexcode/session-core/dist/index.js +5 -0
  5. package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
  6. package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
  7. package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
  8. package/node_modules/@spexcode/session-core/dist/message.js +53 -0
  9. package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
  10. package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
  11. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +62 -0
  12. package/node_modules/@spexcode/session-core/dist/runtime-session.js +326 -0
  13. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +47 -0
  14. package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
  15. package/node_modules/@spexcode/session-core/package.json +33 -0
  16. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +2 -1
  17. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +4 -1
  18. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +13 -4
  19. package/node_modules/@spexcode/spec-cli/dist/cli.js +72 -23
  20. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +2 -1
  21. package/node_modules/@spexcode/spec-cli/dist/client.js +13 -8
  22. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +5 -0
  23. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +112 -0
  24. package/node_modules/@spexcode/spec-cli/dist/doctor.js +7 -1
  25. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +7 -5
  26. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +1 -0
  27. package/node_modules/@spexcode/spec-cli/dist/gateway.js +44 -20
  28. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +2 -1
  29. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +2 -1
  30. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -2
  31. package/node_modules/@spexcode/spec-cli/dist/harness.js +174 -54
  32. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +5 -0
  33. package/node_modules/@spexcode/spec-cli/dist/help.js +26 -6
  34. package/node_modules/@spexcode/spec-cli/dist/index.js +3 -3
  35. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +2 -1
  36. package/node_modules/@spexcode/spec-cli/dist/listen.js +10 -10
  37. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +1 -0
  38. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +7 -0
  39. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
  40. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
  41. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +1 -1
  42. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +6 -46
  43. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +8 -221
  44. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +25 -4
  45. package/node_modules/@spexcode/spec-cli/dist/sessions.js +822 -394
  46. package/node_modules/@spexcode/spec-cli/dist/supervise.js +3 -3
  47. package/node_modules/@spexcode/spec-cli/package.json +6 -4
  48. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  49. package/node_modules/@spexcode/spec-core/dist/git.js +32 -0
  50. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +4 -0
  51. package/node_modules/@spexcode/spec-core/package.json +1 -1
  52. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  53. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  54. package/package.json +3 -3
  55. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.d.ts +0 -0
  56. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.js +0 -0
@@ -1,19 +1,19 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, openSync, closeSync, unlinkSync, writeSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
5
5
  import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { seedWorktreeHostState } from './worktree-sources.js';
8
8
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
9
9
  import { loadConfig, loadSpecs, loadSpecsLite } from '@spexcode/spec-core';
10
- import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
10
+ import { adapterLoadedReferenceState, assertRvSockPath, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
11
11
  import { materialize } from './materialize.js';
12
12
  import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal } from '@spexcode/spec-core';
13
13
  import { readSessionFiles } from './session-files.js';
14
14
  import { readSessionWebs } from './session-web.js';
15
- import { appendSent, recordStatus, lastHumanSendVia, sentDispatchReceipt, settleSentDispatch } from './session-timeline.js';
16
- import { drain, enqueue, ensurePendingWhileLocked, owesDelivery, pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, senderDeliveryRevoked, withDeliveryLocks } from './delivery-queue.js';
15
+ import { acceptMessage, drain, recordStatus, lastHumanSendVia, owesDelivery, pendingMessages } from '@spexcode/session-core';
16
+ import { pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, withDeliveryLocks, trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from '@spexcode/session-core/internal';
17
17
  import { stripRefSigil } from './mentions.js';
18
18
  import { shQuote } from './sh.js';
19
19
  import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js';
@@ -157,12 +157,10 @@ function readPromptFile(id) {
157
157
  return null;
158
158
  }
159
159
  }
160
- // Persist queued launch input across restarts; consume it once the launch begins.
160
+ // The resolved first-turn payload is authoritative across queue drain and recovery. Adapters that mint native
161
+ // identity keep it until they prove identity + first-turn durability; other adapters consume on submission.
161
162
  function writeLaunchFile(id, prompt) {
162
- try {
163
- writeFileSync(join(storeDir(id), 'launch'), prompt);
164
- }
165
- catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
163
+ writeFileSync(join(storeDir(id), 'launch'), prompt);
166
164
  }
167
165
  function readLaunchFile(id) {
168
166
  try {
@@ -244,7 +242,7 @@ export function rawLifecycleStatus(rec) {
244
242
  return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status;
245
243
  }
246
244
  export function canDrainQueued(rec, authority = backendLaunchAuthority()) {
247
- return rec.status === 'queued' && (rec.launchOwner === null || rec.launchOwner === authority);
245
+ return rec.status === 'queued' && !rec.stopped && (rec.launchOwner === null || rec.launchOwner === authority);
248
246
  }
249
247
  // typed read of a session's record from the global store (null if it has none — a self-launched session that
250
248
  // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
@@ -288,157 +286,18 @@ function readLiveRecord(id) {
288
286
  throw new SessionRecordUnusable('retired', rec.session, retired);
289
287
  return rec;
290
288
  }
291
- // Cross-process lifecycle serialization. Hooks and operator commands are separate CLI processes, so the
292
- // in-memory transition tail is only an optimization. This lock covers each read/modify/write or destructive
293
- // transition across archive/resume/stop/close and hook writers. It lives outside the record directory so close
294
- // may remove the record while its lock is held. A dead writer's lock is reclaimed; a live writer is waited for
295
- // with a bounded wall and then fails loudly rather than allowing a stale write to win.
296
- const recordLockRoot = () => join(runtimeRoot(), '.session-locks');
297
- const recordLockPath = (id) => join(recordLockRoot(), `${id}.lock`);
298
- const syncPause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
299
- function acquireRecordLockSync(id, timeoutMs = 30_000) {
300
- mkdirSync(recordLockRoot(), { recursive: true });
301
- const path = recordLockPath(id), deadline = Date.now() + timeoutMs;
302
- for (;;) {
303
- try {
304
- const fd = openSync(path, 'wx');
305
- writeSync(fd, String(process.pid));
306
- closeSync(fd);
307
- return () => { try {
308
- unlinkSync(path);
309
- }
310
- catch { /* another recovery already removed it */ } };
311
- }
312
- catch (e) {
313
- if (e.code !== 'EEXIST')
314
- throw e;
315
- let owner = 0;
316
- try {
317
- owner = Number(readFileSync(path, 'utf8').trim()) || 0;
318
- }
319
- catch { /* race with creator/releaser */ }
320
- if (owner && owner !== process.pid) {
321
- try {
322
- process.kill(owner, 0);
323
- }
324
- catch {
325
- try {
326
- unlinkSync(path);
327
- }
328
- catch { /* race */ }
329
- ;
330
- continue;
331
- }
332
- }
333
- if (Date.now() >= deadline)
334
- throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`);
335
- syncPause(10);
336
- }
337
- }
338
- }
339
- const abortedOperation = (signal) => signal.reason instanceof Error
340
- ? signal.reason
341
- : Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
342
- async function recordLockPause(signal) {
343
- if (!signal) {
344
- await new Promise((resolve) => setTimeout(resolve, 10));
345
- return;
346
- }
347
- if (signal.aborted)
348
- throw abortedOperation(signal);
349
- await new Promise((resolve, reject) => {
350
- const timer = setTimeout(done, 10);
351
- const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(abortedOperation(signal)); };
352
- function done() { signal.removeEventListener('abort', abort); resolve(); }
353
- signal.addEventListener('abort', abort, { once: true });
354
- });
355
- }
356
- async function acquireRecordLock(id, timeoutMs = 30_000, signal) {
357
- mkdirSync(recordLockRoot(), { recursive: true });
358
- const path = recordLockPath(id), deadline = Date.now() + timeoutMs;
359
- for (;;) {
360
- if (signal?.aborted)
361
- throw abortedOperation(signal);
362
- try {
363
- const fd = openSync(path, 'wx');
364
- writeSync(fd, String(process.pid));
365
- closeSync(fd);
366
- return () => { try {
367
- unlinkSync(path);
368
- }
369
- catch { /* another recovery already removed it */ } };
370
- }
371
- catch (e) {
372
- if (e.code !== 'EEXIST')
373
- throw e;
374
- let owner = 0;
375
- try {
376
- owner = Number(readFileSync(path, 'utf8').trim()) || 0;
377
- }
378
- catch { /* race with creator/releaser */ }
379
- if (owner && owner !== process.pid) {
380
- try {
381
- process.kill(owner, 0);
382
- }
383
- catch {
384
- try {
385
- unlinkSync(path);
386
- }
387
- catch { /* race */ }
388
- ;
389
- continue;
390
- }
391
- }
392
- if (Date.now() >= deadline)
393
- throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`);
394
- await recordLockPause(signal);
395
- }
396
- }
397
- }
398
- async function withRecordLock(id, body, signal) {
399
- const release = await acquireRecordLock(id, 30_000, signal);
400
- try {
401
- return await body();
402
- }
403
- finally {
404
- release();
405
- }
406
- }
289
+ const withRecordLock = withSessionRecordLock;
407
290
  export function withSessionRecordLockSync(id, body) {
408
- const release = acquireRecordLockSync(id);
409
- try {
410
- return body();
411
- }
412
- finally {
413
- release();
414
- }
291
+ return coreWithSessionRecordLockSync(id, body);
415
292
  }
416
293
  const withRecordLockSync = withSessionRecordLockSync;
417
- function tryRecordLockSync(id) {
418
- mkdirSync(recordLockRoot(), { recursive: true });
419
- const path = recordLockPath(id);
420
- try {
421
- const fd = openSync(path, 'wx');
422
- writeSync(fd, String(process.pid));
423
- closeSync(fd);
424
- return () => { try {
425
- unlinkSync(path);
426
- }
427
- catch { /* another recovery already removed it */ } };
428
- }
429
- catch (e) {
430
- if (e.code === 'EEXIST')
431
- return null;
432
- throw e;
433
- }
434
- }
435
294
  // Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
436
295
  // enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
437
296
  // and then race a just-queued TUI turn.
438
297
  export function withSessionInputLock(id, body) {
439
298
  // PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
440
299
  // regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
441
- const release = tryRecordLockSync(id);
300
+ const release = trySessionRecordLockSync(id);
442
301
  if (!release)
443
302
  return null;
444
303
  try {
@@ -619,7 +478,10 @@ function readWatchEntries(target) {
619
478
  // The former one-source format cannot name an origin. Its child pointer is the only durable witness
620
479
  // that this watcher was installed for parent supervision; every other legacy row is a manual watch.
621
480
  : [watcher === parent ? 'parent' : 'manual'];
622
- return sources.length ? [{ watcher, createdAt, sources }] : [];
481
+ const snapshotPending = entry.snapshotPending;
482
+ return sources.length ? [{ watcher, createdAt, sources,
483
+ ...(sources.includes('parent') && typeof snapshotPending === 'string' && snapshotPending ? { snapshotPending } : {}),
484
+ }] : [];
623
485
  });
624
486
  }
625
487
  catch {
@@ -642,14 +504,19 @@ function writeWatchEntries(target, entries) {
642
504
  writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
643
505
  renameSync(tmp, path);
644
506
  }
645
- function addWatchSource(entries, watcher, source) {
507
+ function addWatchSource(entries, watcher, source, deferInitialSnapshot = false) {
646
508
  const existing = entries.find((entry) => entry.watcher === watcher);
509
+ const snapshotPending = deferInitialSnapshot && !existing?.sources.includes('manual') ? randomUUID() : undefined;
647
510
  if (!existing)
648
- return { entries: [...entries, { watcher, createdAt: new Date().toISOString(), sources: [source] }], added: true };
511
+ return { entries: [...entries, {
512
+ watcher, createdAt: new Date().toISOString(), sources: [source], ...(snapshotPending ? { snapshotPending } : {}),
513
+ }], added: true };
649
514
  if (existing.sources.includes(source))
650
515
  return { entries, added: false };
651
516
  return {
652
- entries: entries.map((entry) => entry === existing ? { ...entry, sources: [...entry.sources, source] } : entry),
517
+ entries: entries.map((entry) => entry === existing ? {
518
+ ...entry, sources: [...entry.sources, source], ...(snapshotPending ? { snapshotPending } : {}),
519
+ } : entry),
653
520
  added: true,
654
521
  };
655
522
  }
@@ -660,7 +527,12 @@ function removeWatchSource(entries, watcher, source) {
660
527
  return [entry];
661
528
  removed = true;
662
529
  const sources = entry.sources.filter((candidate) => candidate !== source);
663
- return sources.length ? [{ ...entry, sources }] : [];
530
+ if (!sources.length)
531
+ return [];
532
+ if (source !== 'parent')
533
+ return [{ ...entry, sources }];
534
+ const { snapshotPending: _pending, ...withoutParentDebt } = entry;
535
+ return [{ ...withoutParentDebt, sources }];
664
536
  });
665
537
  return { entries: next, removed };
666
538
  }
@@ -677,8 +549,14 @@ function watchMessage(target) {
677
549
  const note = target.note ? ` — ${target.note}` : '';
678
550
  return `[spex watch] ${target.session} is ${status}${note}`;
679
551
  }
552
+ function shouldDeliverWatchTransition(target, sources) {
553
+ // @@@watch-delivery-policy - Relationship setup sends current state; manual opts into working changes.
554
+ return target.status !== 'active' || sources.includes('manual');
555
+ }
680
556
  function scheduleWatchNotifications(target) {
681
- const watchers = readWatchEntries(target.session).map((entry) => entry.watcher);
557
+ const watchers = readWatchEntries(target.session)
558
+ .filter((entry) => !entry.snapshotPending && shouldDeliverWatchTransition(target, entry.sources))
559
+ .map((entry) => entry.watcher);
682
560
  if (!watchers.length)
683
561
  return;
684
562
  queueMicrotask(() => {
@@ -690,22 +568,130 @@ function scheduleWatchNotifications(target) {
690
568
  }
691
569
  });
692
570
  }
571
+ const watchSnapshotState = (target) => JSON.stringify([target.status, target.proposal, target.note]);
572
+ async function deliverPendingWatchSnapshots(targetId, forceCurrent = true) {
573
+ const pending = readWatchEntries(targetId).filter((entry) => entry.snapshotPending);
574
+ for (const original of pending) {
575
+ const token = original.snapshotPending;
576
+ let force = forceCurrent;
577
+ for (;;) {
578
+ const target = readRecord(targetId);
579
+ const entry = readWatchEntries(targetId)
580
+ .find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
581
+ if (!target || !entry)
582
+ break;
583
+ const state = watchSnapshotState(target);
584
+ const shouldDeliver = force || shouldDeliverWatchTransition(target, entry.sources);
585
+ if (!shouldDeliver) {
586
+ let settled = false;
587
+ await withRecordLock(targetId, async () => {
588
+ const current = readRecord(targetId);
589
+ const entries = readWatchEntries(targetId);
590
+ const pendingEntry = entries.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
591
+ if (!current || !pendingEntry || watchSnapshotState(current) !== state)
592
+ return;
593
+ const next = entries.map((candidate) => {
594
+ if (candidate !== pendingEntry)
595
+ return candidate;
596
+ const { snapshotPending: _pending, ...cleared } = candidate;
597
+ return cleared;
598
+ });
599
+ writeWatchEntries(targetId, next);
600
+ settled = true;
601
+ });
602
+ if (settled)
603
+ break;
604
+ force = false;
605
+ continue;
606
+ }
607
+ const identity = `${targetId}\0${entry.watcher}\0${token}\0${state}`;
608
+ const delivered = await sendText(entry.watcher, watchMessage(target), targetId, {
609
+ idempotency: {
610
+ operation: 'watch-initial-snapshot',
611
+ requestDigest: digest(identity),
612
+ payloadHash: digest(`watch-initial-snapshot\0${identity}\0${watchMessage(target)}`),
613
+ },
614
+ acceptGuard: async () => {
615
+ const current = readRecord(targetId);
616
+ const stillPending = readWatchEntries(targetId)
617
+ .some((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
618
+ if (!current || !stillPending || watchSnapshotState(current) !== state)
619
+ throw new ResourceConflict('watch initial snapshot changed before acceptance');
620
+ },
621
+ });
622
+ if (!delivered.ok) {
623
+ if (delivered.error?.includes('watch initial snapshot changed before acceptance'))
624
+ continue;
625
+ console.error(`spex session watch: could not deliver initial ${targetId} state to ${entry.watcher}: ${delivered.error}`);
626
+ break;
627
+ }
628
+ force = false;
629
+ let settled = false;
630
+ await withRecordLock(targetId, async () => {
631
+ const current = readRecord(targetId);
632
+ const entries = readWatchEntries(targetId);
633
+ const pendingEntry = entries.find((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
634
+ if (!current || !pendingEntry) {
635
+ settled = true;
636
+ return;
637
+ }
638
+ const currentState = watchSnapshotState(current);
639
+ if (currentState !== state && shouldDeliverWatchTransition(current, pendingEntry.sources))
640
+ return;
641
+ const next = entries.map((candidate) => {
642
+ if (candidate !== pendingEntry)
643
+ return candidate;
644
+ const { snapshotPending: _pending, ...cleared } = candidate;
645
+ return cleared;
646
+ });
647
+ writeWatchEntries(targetId, next);
648
+ settled = true;
649
+ });
650
+ if (settled)
651
+ break;
652
+ }
653
+ }
654
+ }
655
+ async function clearPendingWatchSnapshots(targetId) {
656
+ await withRecordLock(targetId, async () => {
657
+ const entries = readWatchEntries(targetId);
658
+ const next = entries.map((entry) => {
659
+ if (!entry.snapshotPending)
660
+ return entry;
661
+ const { snapshotPending: _pending, ...settled } = entry;
662
+ return settled;
663
+ });
664
+ if (next.some((entry, index) => entry !== entries[index]))
665
+ writeWatchEntries(targetId, next);
666
+ });
667
+ }
693
668
  export async function subscribeSessionWatch(watcher, targets, source = 'manual') {
694
669
  managedWatchRecord(watcher);
695
670
  const watched = [];
696
671
  for (const target of [...new Set(targets)]) {
697
672
  if (target === watcher)
698
673
  throw new ResourceConflict('a session cannot watch itself');
699
- const targetRecord = managedWatchRecord(target);
674
+ let targetRecord = null;
675
+ let added = false;
676
+ let pending = false;
700
677
  withRecordLockSync(target, () => {
678
+ targetRecord = managedWatchRecord(target);
701
679
  const entries = readWatchEntries(target);
702
- const next = addWatchSource(entries, watcher, source);
680
+ const next = addWatchSource(entries, watcher, source, source === 'parent' && targetRecord.status === 'queued');
703
681
  if (next.added)
704
682
  writeWatchEntries(target, next.entries);
683
+ added = next.added;
684
+ pending = next.entries.some((entry) => entry.watcher === watcher && !!entry.snapshotPending);
705
685
  });
706
- const delivered = await sendText(watcher, watchMessage(targetRecord), target);
707
- if (!delivered.ok)
708
- throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
686
+ if (pending) {
687
+ if (source === 'manual')
688
+ await deliverPendingWatchSnapshots(target);
689
+ }
690
+ else if (source === 'manual' || added) {
691
+ const delivered = await sendText(watcher, watchMessage(targetRecord), target);
692
+ if (!delivered.ok)
693
+ throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
694
+ }
709
695
  watched.push(target);
710
696
  }
711
697
  return { watched };
@@ -827,13 +813,15 @@ export async function reparentSessionRecords(rawChildren, parent) {
827
813
  }
828
814
  }));
829
815
  });
816
+ const notified = [];
830
817
  if (parent)
831
818
  for (const child of notify) {
832
819
  const delivered = await sendText(parent, watchMessage(child), child.session);
833
820
  if (!delivered.ok)
834
821
  throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`);
822
+ notified.push(child.session);
835
823
  }
836
- return { children, parent, notified: notify.map((child) => child.session) };
824
+ return { children, parent, notified };
837
825
  }
838
826
  // tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
839
827
  // 0x1f into `_`, while 3.4 turns a raw 0x1f into the printable escape `\037`. So the field separator is ASKED
@@ -1581,7 +1569,8 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
1581
1569
  // invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
1582
1570
  // parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
1583
1571
  const pidPath = join(storeDir(id), 'agent.pid');
1584
- const born = `sh -c ${shQuote(`printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`;
1572
+ const receiptPath = join(storeDir(id), 'agent.identity.json');
1573
+ const born = `sh -c ${shQuote(`rm -f ${shQuote(receiptPath)}; printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`;
1585
1574
  // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
1586
1575
  // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
1587
1576
  // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
@@ -1686,21 +1675,105 @@ async function withSessionTransition(id, body) {
1686
1675
  }
1687
1676
  }
1688
1677
  let draining = false; // re-entrancy guard: only one drain pass runs at a time (no double-launch)
1689
- // launch a prepared `queued` worktree: feed it its parked launch prompt, flip it to active. Returns false
1690
- // (leaving it queued, to be retried next drain) if the worktree/prompt is gone or the tmux launch threw.
1678
+ function noteQueuedLaunchFailureUnlocked(id, error) {
1679
+ const reason = error instanceof Error ? error.message : String(error);
1680
+ const note = `queued launch readiness failed: ${reason}`;
1681
+ console.error(`spex: session ${id}: ${note}`);
1682
+ const rec = readRecord(id);
1683
+ if (rec && rec.note !== note)
1684
+ writeRecord({ ...rec, note });
1685
+ }
1686
+ function observeQueuedLaunchReadiness(id, harness) {
1687
+ void waitForReady(id, harness)
1688
+ .then(async (readiness) => {
1689
+ if (!readiness) {
1690
+ const committed = !!readRecord(id)?.harnessSessionId;
1691
+ throw new ResourceConflict(harness.launchPayloadProof
1692
+ ? committed
1693
+ ? 'post-proof adapter liveness did not become ready before launch readiness timed out'
1694
+ : 'native identity and first-turn rollout proof did not arrive before launch readiness timed out'
1695
+ : 'adapter liveness did not become ready before launch readiness timed out');
1696
+ }
1697
+ let readyToPublish = false;
1698
+ await withRecordLock(id, async () => {
1699
+ const candidate = readRecord(id);
1700
+ if (!candidate)
1701
+ return;
1702
+ const stillReady = await readiness.validate(() => {
1703
+ const current = readRecord(id);
1704
+ return current ? { ...current, runtimeDir: runtimeRoot() } : null;
1705
+ });
1706
+ if (!stillReady)
1707
+ throw new ResourceConflict('launch readiness changed before queued publication');
1708
+ const current = readRecord(id);
1709
+ if (!current)
1710
+ return;
1711
+ if (current.status === 'queued')
1712
+ writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null });
1713
+ readyToPublish = true;
1714
+ });
1715
+ if (!readyToPublish)
1716
+ return;
1717
+ await deliverPendingWatchSnapshots(id);
1718
+ await drainSession(id);
1719
+ })
1720
+ .catch(async (error) => {
1721
+ try {
1722
+ await withRecordLock(id, async () => noteQueuedLaunchFailureUnlocked(id, error));
1723
+ }
1724
+ catch (recordError) {
1725
+ console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${error instanceof Error ? error.message : String(error)}`);
1726
+ }
1727
+ await clearPendingWatchSnapshots(id);
1728
+ })
1729
+ .finally(() => launching.delete(id));
1730
+ }
1731
+ // Launch a prepared `queued` worktree. Deterministic blockers retire its creation snapshot debt; a transport
1732
+ // attempt that may succeed on the next drain keeps that durable debt for the eventual real outcome.
1691
1733
  async function startQueuedUnlocked(id) {
1692
1734
  if (archiving.has(id))
1693
- return false;
1735
+ return 'retryable';
1694
1736
  const wt = await findWorktree(id);
1695
1737
  if (!wt)
1696
- return false;
1697
- if (archiving.has(id) || wt.rec.archived)
1698
- return false;
1738
+ return 'blocked';
1739
+ if (archiving.has(id))
1740
+ return 'retryable';
1741
+ if (wt.rec.archived)
1742
+ return 'blocked';
1699
1743
  if (!canDrainQueued(wt.rec))
1700
- return false;
1744
+ return 'retryable';
1745
+ const h = harnessById(wt.rec.harness || defaultHarness.id);
1746
+ if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
1747
+ launching.add(id);
1748
+ let readinessOwnsSlot = false;
1749
+ try {
1750
+ try {
1751
+ consumeHarnessLaunchProofUnlocked(id);
1752
+ }
1753
+ catch (error) {
1754
+ noteQueuedLaunchFailureUnlocked(id, error);
1755
+ throw error;
1756
+ }
1757
+ const recovered = readRecord(id) || wt.rec;
1758
+ writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null });
1759
+ observeQueuedLaunchReadiness(id, h);
1760
+ readinessOwnsSlot = true;
1761
+ return 'started';
1762
+ }
1763
+ finally {
1764
+ if (!readinessOwnsSlot)
1765
+ launching.delete(id);
1766
+ }
1767
+ }
1701
1768
  const launchPrompt = readLaunchFile(id);
1702
- if (launchPrompt == null)
1703
- return false; // a queued session always has one; if it's gone, don't spin on it
1769
+ if (launchPrompt == null) {
1770
+ const message = `authoritative resolved launch payload is missing for queued session ${id}; refusing to create an empty thread`;
1771
+ if (wt.rec.note !== message) {
1772
+ console.error(`spex: ${message}`);
1773
+ writeRecord({ ...wt.rec, note: message });
1774
+ }
1775
+ return 'blocked';
1776
+ }
1704
1777
  // a queued worktree can go missing while it waits (a human cleaned up, a disk moved). Draining it would open
1705
1778
  // a window that fast-exits and burn the retry budget every tick, so refuse ONCE, loudly, and stamp the reason
1706
1779
  // on the record — the drainer then leaves it alone instead of spinning on a launch that cannot work.
@@ -1710,28 +1783,36 @@ async function startQueuedUnlocked(id) {
1710
1783
  console.error(`spex: not launching queued session ${id}: ${blocked.message}`);
1711
1784
  writeRecord({ ...wt.rec, note: blocked.message });
1712
1785
  }
1713
- return false;
1786
+ return 'blocked';
1714
1787
  }
1715
1788
  launching.add(id); // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1716
- const h = harnessById(wt.rec.harness || defaultHarness.id); // launch THIS session's chosen harness (also drives waitForReady below)
1789
+ let readinessOwnsSlot = false;
1717
1790
  try {
1718
- const sq = shQuote(launchPrompt);
1719
- await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
1791
+ try {
1792
+ const sq = shQuote(launchPrompt);
1793
+ await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
1794
+ }
1795
+ catch {
1796
+ return 'retryable'; // launch failed → stays `queued`, with its initial debt, for the next drain tick
1797
+ }
1798
+ // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
1799
+ // launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
1800
+ // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1801
+ // headline precedence stands on.
1802
+ const launched = readRecord(id) || wt.rec;
1803
+ writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null });
1804
+ if (!h.launchPayloadProof)
1805
+ removeLaunchFile(id);
1806
+ // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1807
+ // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
1808
+ observeQueuedLaunchReadiness(id, h);
1809
+ readinessOwnsSlot = true;
1810
+ return 'started';
1811
+ }
1812
+ finally {
1813
+ if (!readinessOwnsSlot)
1814
+ launching.delete(id);
1720
1815
  }
1721
- catch {
1722
- launching.delete(id);
1723
- return false; // launch failed → stays `queued`, retried on the next drain tick
1724
- }
1725
- // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
1726
- // launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
1727
- // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1728
- // headline precedence stands on.
1729
- writeRecord({ ...wt.rec, status: 'active', proposal: null, note: null, launchOwner: null });
1730
- removeLaunchFile(id); // consumed
1731
- // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1732
- // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
1733
- void waitForReady(id, h).finally(() => launching.delete(id));
1734
- return true;
1735
1816
  }
1736
1817
  const startQueued = (id) => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)));
1737
1818
  async function drainQueueUnlocked() {
@@ -1742,6 +1823,19 @@ async function drainQueueUnlocked() {
1742
1823
  const cap = maxActive(); // read once per drain pass (spexcode.json → env → default); won't shift mid-burst
1743
1824
  for (;;) {
1744
1825
  const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()]);
1826
+ for (const session of sessions) {
1827
+ const rec = readRecord(session.id);
1828
+ if (!rec || launching.has(session.id) || !readWatchEntries(session.id).some((entry) => entry.snapshotPending))
1829
+ continue;
1830
+ if (rec.status === 'queued')
1831
+ continue;
1832
+ if (rec.status === 'active' && !rec.stopped && !rec.archived) {
1833
+ launching.add(session.id);
1834
+ observeQueuedLaunchReadiness(session.id, harnessById(rec.harness || defaultHarness.id));
1835
+ continue;
1836
+ }
1837
+ await deliverPendingWatchSnapshots(session.id, false);
1838
+ }
1745
1839
  // if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
1746
1840
  // session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
1747
1841
  // MORE compute onto an already-thrashing box. Under load, do the safe thing — launch nothing this pass and
@@ -1749,8 +1843,16 @@ async function drainQueueUnlocked() {
1749
1843
  if (snap.probeFailed)
1750
1844
  break;
1751
1845
  const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0);
1752
- if (occupied >= cap)
1846
+ if (occupied >= cap) {
1847
+ const authority = backendLaunchAuthority();
1848
+ await Promise.all(sessions.filter((session) => {
1849
+ if (session.status !== 'queued')
1850
+ return false;
1851
+ const rec = readRecord(session.id);
1852
+ return !!rec && canDrainQueued(rec, authority);
1853
+ }).map((session) => deliverPendingWatchSnapshots(session.id)));
1753
1854
  break;
1855
+ }
1754
1856
  const authority = backendLaunchAuthority();
1755
1857
  const next = sessions.find((s) => {
1756
1858
  if (s.status !== 'queued' || launching.has(s.id))
@@ -1760,8 +1862,12 @@ async function drainQueueUnlocked() {
1760
1862
  });
1761
1863
  if (!next)
1762
1864
  break;
1763
- if (!(await startQueued(next.id)))
1865
+ const started = await startQueued(next.id);
1866
+ if (started !== 'started') {
1867
+ if (started === 'blocked')
1868
+ await clearPendingWatchSnapshots(next.id);
1764
1869
  break; // launch failed → stop this pass; a later tick retries
1870
+ }
1765
1871
  }
1766
1872
  }
1767
1873
  finally {
@@ -2491,6 +2597,8 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2491
2597
  chosen = resolveLauncher(lname);
2492
2598
  h = harnessById(chosen.harness);
2493
2599
  pinned = h.baseCmd(chosen.cmd);
2600
+ if (h.ownsRendezvous)
2601
+ assertRvSockPath(id);
2494
2602
  }
2495
2603
  catch (error) {
2496
2604
  throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400);
@@ -2613,6 +2721,12 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2613
2721
  throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500);
2614
2722
  throwIfCreateAborted(signal, phase);
2615
2723
  writeRecord(rec);
2724
+ if (rec.parent && readRecord(rec.parent)?.governed) {
2725
+ const watchers = readWatchEntries(id);
2726
+ const next = addWatchSource(watchers, rec.parent, 'parent', true);
2727
+ if (next.added)
2728
+ writeWatchEntries(id, next.entries);
2729
+ }
2616
2730
  published = true;
2617
2731
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
2618
2732
  if (receiptFailure)
@@ -2694,7 +2808,7 @@ const SOCKET_READY_TIMEOUT_MS = 30000; // spans launchScript's bounded fast-fail
2694
2808
  // waitForReady (slot-hold + resume) waits through a daemon-race retry
2695
2809
  // instead of returning before a recovering socket
2696
2810
  const SOCKET_POLL_MS = 200;
2697
- async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEOUT_MS) {
2811
+ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEOUT_MS, recordLockHeld = false) {
2698
2812
  const current = () => {
2699
2813
  const stored = readRecord(id);
2700
2814
  const rec = stored && pending
@@ -2703,6 +2817,20 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
2703
2817
  return rec ? { ...rec, runtimeDir: runtimeRoot() } : null;
2704
2818
  };
2705
2819
  const deadline = Date.now() + timeoutMs;
2820
+ if (harness.launchPayloadProof && !current()?.harnessSessionId) {
2821
+ for (;;) {
2822
+ if (existsSync(sessionArtifactPath(id, 'launch.proof'))) {
2823
+ if (recordLockHeld)
2824
+ consumeHarnessLaunchProofUnlocked(id);
2825
+ else
2826
+ await withRecordLock(id, async () => consumeHarnessLaunchProofUnlocked(id));
2827
+ break;
2828
+ }
2829
+ if (Date.now() >= deadline)
2830
+ return null;
2831
+ await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
2832
+ }
2833
+ }
2706
2834
  if (harness.launchReady)
2707
2835
  return harness.launchReady(current, deadline);
2708
2836
  const genericFence = () => ({
@@ -2723,6 +2851,7 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
2723
2851
  await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
2724
2852
  }
2725
2853
  }
2854
+ const restingLifecycle = (status) => status === 'active' || status === 'queued' ? 'idle' : status;
2726
2855
  async function resumeSessionUnlocked(id, opts = {}) {
2727
2856
  const { force = false, guard = true } = opts;
2728
2857
  let wt;
@@ -2759,6 +2888,15 @@ async function resumeSessionUnlocked(id, opts = {}) {
2759
2888
  if (blocked)
2760
2889
  return { ok: false, refused: true, error: blocked.message };
2761
2890
  const h = harnessById(wt.rec.harness || defaultHarness.id);
2891
+ // A prior adapter process may have proven identity + first-turn durability just before its session owner
2892
+ // died. Consume that receipt before choosing a recovery tail, so retry resumes the proven thread instead of
2893
+ // creating another one with the same first prompt.
2894
+ if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
2895
+ consumeHarnessLaunchProofUnlocked(id);
2896
+ wt = await findWorktree(id);
2897
+ if (!wt)
2898
+ return { ok: false, error: `session ${id} disappeared while recovering native launch proof` };
2899
+ }
2762
2900
  // An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
2763
2901
  // it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
2764
2902
  // resume can never kill a live leaf merely because the record was hidden.
@@ -2799,21 +2937,28 @@ async function resumeSessionUnlocked(id, opts = {}) {
2799
2937
  // Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
2800
2938
  // starting -> online launch path recreates the same conversation.
2801
2939
  const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec;
2802
- const resumed = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false };
2940
+ const resumed = { ...current, archived: false, coldProof: null, status: restingLifecycle(current.status), stopped: false };
2803
2941
  if (force || lv === 'offline') {
2942
+ let resumeTail;
2943
+ try {
2944
+ resumeTail = h.resumeArg(wt.rec, readLaunchFile(id)).trim();
2945
+ }
2946
+ catch (error) {
2947
+ return { ok: false, refused: true, error: error instanceof Error ? error.message : String(error) };
2948
+ }
2804
2949
  await tmuxOk(['kill-session', '-t', id]); // drop a dead/offline pane (or a force-killed live one)
2805
- await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec));
2950
+ await launch(id, wt.path, resumeTail, h, launcherCmd(wt.rec));
2806
2951
  let readiness = null;
2807
2952
  let readinessError = '';
2808
2953
  try {
2809
- readiness = await waitForReady(id, h, resumed);
2954
+ readiness = await waitForReady(id, h, resumed, SOCKET_READY_TIMEOUT_MS, true);
2810
2955
  }
2811
2956
  catch (error) {
2812
2957
  readinessError = error instanceof Error ? error.message : String(error);
2813
2958
  }
2814
2959
  if (!readiness) {
2815
2960
  const failed = readRecord(id) || current;
2816
- writeRecord({ ...failed, ...preResume, launchReadinessPending: null });
2961
+ writeRecord({ ...failed, ...preResume, harnessSessionId: failed.harnessSessionId, launchReadinessPending: null });
2817
2962
  return {
2818
2963
  ok: false,
2819
2964
  refused: true,
@@ -2825,7 +2970,7 @@ async function resumeSessionUnlocked(id, opts = {}) {
2825
2970
  ...latest,
2826
2971
  archived: false,
2827
2972
  coldProof: null,
2828
- status: latest.status === 'active' ? 'idle' : latest.status,
2973
+ status: restingLifecycle(latest.status),
2829
2974
  stopped: false,
2830
2975
  launchReadinessPending: launchReadinessPending(preResume),
2831
2976
  };
@@ -2856,7 +3001,12 @@ async function resumeSessionUnlocked(id, opts = {}) {
2856
3001
  writeRecord(resumed);
2857
3002
  return { ok: true };
2858
3003
  }
2859
- export const resumeSession = (id, opts = {}) => withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, opts)));
3004
+ export const resumeSession = (id, opts = {}) => withSessionTransition(id, async () => {
3005
+ const result = await withRecordLock(id, () => resumeSessionUnlocked(id, opts));
3006
+ if (result.ok)
3007
+ await drainSession(id);
3008
+ return result;
3009
+ });
2860
3010
  export function markState(status, opts = {}) {
2861
3011
  const id = opts.sessionId || ownSessionId();
2862
3012
  if (!id)
@@ -2893,50 +3043,158 @@ export function markHeadlessTurnFailure(sessionId, harness, exitCode) {
2893
3043
  const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`;
2894
3044
  return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`);
2895
3045
  }
2896
- export function markHarnessSessionId(sessionId, harnessSessionId) {
3046
+ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()) {
3047
+ const id = rec.session;
3048
+ if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
3049
+ throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`);
3050
+ const codex = rec.harness === 'codex' || rec.harness === 'codex-headless';
3051
+ const root = runtimeRoot();
3052
+ let priorBinding = null;
3053
+ let registrationPrepared = false;
3054
+ if (codex) {
3055
+ const ledger = readCodexGenerationLedger(root);
3056
+ if (ledger.revision > 0 && !generationId)
3057
+ throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`);
3058
+ priorBinding = codexGenerationBindingForSession(root, id);
3059
+ if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
3060
+ throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`);
3061
+ if (generationId && !priorBinding) {
3062
+ prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3063
+ registrationPrepared = true;
3064
+ }
3065
+ }
3066
+ try {
3067
+ writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
3068
+ }
3069
+ catch (error) {
3070
+ if (codex && generationId && registrationPrepared) {
3071
+ try {
3072
+ bindCodexGeneration(root, id, harnessSessionId, null);
3073
+ }
3074
+ catch (rollback) {
3075
+ throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
3076
+ }
3077
+ }
3078
+ throw error;
3079
+ }
3080
+ if (codex && generationId)
3081
+ commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3082
+ }
3083
+ function readHarnessLaunchProof(id) {
3084
+ try {
3085
+ const proof = JSON.parse(readFileSync(sessionArtifactPath(id, 'launch.proof'), 'utf8'));
3086
+ if (!proof || proof.version !== 1 || typeof proof.sessionId !== 'string'
3087
+ || typeof proof.harnessId !== 'string' || typeof proof.harnessSessionId !== 'string' || !proof.harnessSessionId
3088
+ || typeof proof.launchPayloadHash !== 'string'
3089
+ || (proof.generationId !== null && typeof proof.generationId !== 'string'))
3090
+ throw new Error('invalid receipt shape');
3091
+ return proof;
3092
+ }
3093
+ catch (error) {
3094
+ throw new ResourceConflict(`native launch proof for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
3095
+ }
3096
+ }
3097
+ function sameHarnessLaunchProof(left, right) {
3098
+ return left.version === right.version && left.sessionId === right.sessionId && left.harnessId === right.harnessId
3099
+ && left.harnessSessionId === right.harnessSessionId && left.launchPayloadHash === right.launchPayloadHash
3100
+ && left.generationId === right.generationId;
3101
+ }
3102
+ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPayload) {
2897
3103
  const id = sessionId || ownSessionId();
2898
3104
  if (!id || !harnessSessionId)
2899
3105
  return false;
2900
- return withRecordLockSync(id, () => {
2901
- const rec = readLiveRecord(id);
2902
- if (!rec)
2903
- return false;
2904
- if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
2905
- throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`);
2906
- const codex = rec.harness === 'codex' || rec.harness === 'codex-headless';
2907
- const root = runtimeRoot();
2908
- let priorBinding = null;
2909
- let generationId;
2910
- let registrationPrepared = false;
2911
- if (codex) {
2912
- generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim();
2913
- const ledger = readCodexGenerationLedger(root);
2914
- if (ledger.revision > 0 && !generationId)
2915
- throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`);
2916
- priorBinding = codexGenerationBindingForSession(root, id);
2917
- if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
2918
- throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`);
2919
- if (generationId && !priorBinding) {
2920
- prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId);
2921
- registrationPrepared = true;
2922
- }
2923
- }
3106
+ const rec = readLiveRecord(id);
3107
+ if (!rec)
3108
+ return false;
3109
+ const harness = harnessById(rec.harness || defaultHarness.id);
3110
+ if (!harness.launchPayloadProof)
3111
+ throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload proof`);
3112
+ const pending = readLaunchFile(id);
3113
+ if (pending == null)
3114
+ throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3115
+ if (pending !== launchPayload)
3116
+ throw new ResourceConflict(`refusing native launch proof for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
3117
+ const generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim() || null;
3118
+ if (rec.harness === 'codex' || rec.harness === 'codex-headless') {
3119
+ const ledger = readCodexGenerationLedger(runtimeRoot());
3120
+ if (ledger.revision > 0 && !generationId)
3121
+ throw new ResourceConflict(`refusing native launch proof for ${id}: launch did not provide an exact Codex generation id`);
3122
+ if (generationId && (!ledger.generations[generationId] || ledger.generations[generationId].state === 'reclaimed'))
3123
+ throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: generation ${generationId} is absent or reclaimed`);
3124
+ }
3125
+ const proof = {
3126
+ version: 1,
3127
+ sessionId: id,
3128
+ harnessId: harness.id,
3129
+ harnessSessionId,
3130
+ launchPayloadHash: createHash('sha256').update(launchPayload).digest('hex'),
3131
+ generationId,
3132
+ };
3133
+ const path = sessionArtifactPath(id, 'launch.proof');
3134
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3135
+ writeFileSync(temp, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
3136
+ try {
3137
+ linkSync(temp, path);
3138
+ return true;
3139
+ }
3140
+ catch (error) {
3141
+ if (error.code !== 'EEXIST')
3142
+ throw error;
3143
+ const staged = readHarnessLaunchProof(id);
3144
+ if (sameHarnessLaunchProof(staged, proof))
3145
+ return true;
3146
+ throw new ResourceConflict(`refusing to replace native launch proof for ${id}: the staged session, thread, payload, or generation differs`);
3147
+ }
3148
+ finally {
3149
+ rmSync(temp, { force: true });
3150
+ }
3151
+ }
3152
+ function consumeHarnessLaunchProofUnlocked(id) {
3153
+ const rec = readLiveRecord(id);
3154
+ if (!rec)
3155
+ return false;
3156
+ const harness = harnessById(rec.harness || defaultHarness.id);
3157
+ const proof = readHarnessLaunchProof(id);
3158
+ if (proof.sessionId !== id || proof.harnessId !== harness.id)
3159
+ throw new ResourceConflict(`native launch proof for ${id} does not match the governed adapter identity`);
3160
+ const pending = readLaunchFile(id);
3161
+ if (pending == null && rec.harnessSessionId !== proof.harnessSessionId)
3162
+ throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3163
+ if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
3164
+ throw new ResourceConflict(`native launch proof for ${id} does not match the authoritative resolved launch payload`);
3165
+ bindHarnessSessionIdUnlocked(rec, proof.harnessSessionId, proof.generationId || undefined);
3166
+ if (pending != null) {
2924
3167
  try {
2925
- writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
3168
+ rmSync(sessionArtifactPath(id, 'launch'));
2926
3169
  }
2927
3170
  catch (error) {
2928
- if (codex && generationId && registrationPrepared) {
2929
- try {
2930
- bindCodexGeneration(root, id, harnessSessionId, null);
2931
- }
2932
- catch (rollback) {
2933
- throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
2934
- }
3171
+ if (error.code !== 'ENOENT') {
3172
+ console.error(`spex: native launch proof committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3173
+ return true;
2935
3174
  }
2936
- throw error;
2937
3175
  }
2938
- if (codex && generationId)
2939
- commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3176
+ }
3177
+ try {
3178
+ rmSync(sessionArtifactPath(id, 'launch.proof'));
3179
+ }
3180
+ catch (error) {
3181
+ if (error.code !== 'ENOENT')
3182
+ console.error(`spex: native launch proof committed for ${id}, but launch.proof could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3183
+ }
3184
+ return true;
3185
+ }
3186
+ export function markHarnessSessionId(sessionId, harnessSessionId) {
3187
+ const id = sessionId || ownSessionId();
3188
+ if (!id || !harnessSessionId)
3189
+ return false;
3190
+ return withRecordLockSync(id, () => {
3191
+ const rec = readLiveRecord(id);
3192
+ if (!rec)
3193
+ return false;
3194
+ const harness = harnessById(rec.harness || defaultHarness.id);
3195
+ if (harness.launchPayloadProof)
3196
+ throw new ResourceConflict(`harness ${harness.id} must stage native identity together with authoritative first-turn payload proof`);
3197
+ bindHarnessSessionIdUnlocked(rec, harnessSessionId);
2940
3198
  return true;
2941
3199
  });
2942
3200
  }
@@ -3116,24 +3374,119 @@ export async function mergeSession(id) {
3116
3374
  // then SIGKILL, each bounded. This is also what lets the socket sweep run at all — a still-answering listener
3117
3375
  // is never ours to unlink, so an un-killed agent would otherwise strand its own socket forever.
3118
3376
  // The escalation is IDENTITY-GUARDED: a recorded pid can have been recycled by an unrelated process, so we
3119
- // signal only a pid whose argv still names THIS session. Unidentifiable we signal nothing and let the
3120
- // adapter's proof-of-death rule leave the transport alone; never a blind kill on a stale number.
3377
+ // signal only the immutable pid/start instance whose ownership was witnessed in the exact tmux pane closure.
3378
+ // Unidentifiable → we signal nothing and let the adapter's proof-of-death rule leave the transport alone.
3121
3379
  const AGENT_EXIT_GRACE_MS = 3000;
3380
+ const SESSION_LEAF_RECEIPT_VERSION = 1;
3381
+ const SESSION_LEAF_RECEIPT_KIND = 'session-leaf';
3382
+ export function parseSessionLeafReceipt(raw, sessionId) {
3383
+ let value;
3384
+ try {
3385
+ value = JSON.parse(raw);
3386
+ }
3387
+ catch {
3388
+ return null;
3389
+ }
3390
+ if (!value || typeof value !== 'object' || Array.isArray(value))
3391
+ return null;
3392
+ const row = value;
3393
+ const keys = Object.keys(row).sort();
3394
+ if (keys.join(',') !== 'kind,pid,sessionId,startToken,version')
3395
+ return null;
3396
+ if (row.version !== SESSION_LEAF_RECEIPT_VERSION || row.kind !== SESSION_LEAF_RECEIPT_KIND || row.sessionId !== sessionId)
3397
+ return null;
3398
+ if (!Number.isSafeInteger(row.pid) || row.pid <= 0 || typeof row.startToken !== 'string' || !row.startToken)
3399
+ return null;
3400
+ return row;
3401
+ }
3402
+ function pidInPaneClosure(pid, panePid, procs) {
3403
+ const seen = new Set();
3404
+ for (let current = pid; current > 0 && !seen.has(current);) {
3405
+ if (current === panePid)
3406
+ return true;
3407
+ seen.add(current);
3408
+ const row = procs.get(current);
3409
+ if (!row || row.ppid === current)
3410
+ return false;
3411
+ current = row.ppid;
3412
+ }
3413
+ return false;
3414
+ }
3415
+ export function sessionLeafReceiptCandidate(sessionId, pid, panePid, procs, startBefore, startAfter) {
3416
+ if (!panePid)
3417
+ return { ok: false, reason: 'exact target pane PID is unavailable' };
3418
+ if (!procs)
3419
+ return { ok: false, reason: 'process snapshot is unavailable' };
3420
+ if (!startBefore || !startAfter)
3421
+ return { ok: false, reason: 'leaf process-start identity is unreadable' };
3422
+ if (startBefore !== startAfter)
3423
+ return { ok: false, reason: 'leaf process-start identity changed during ancestry observation' };
3424
+ if (!pidInPaneClosure(pid, panePid, procs))
3425
+ return { ok: false, reason: `registered leaf PID ${pid} is not in exact target pane ${panePid} descendant closure` };
3426
+ return {
3427
+ ok: true,
3428
+ receipt: { version: SESSION_LEAF_RECEIPT_VERSION, kind: SESSION_LEAF_RECEIPT_KIND, sessionId, pid, startToken: startAfter },
3429
+ };
3430
+ }
3431
+ export function sessionLeafReceiptIdentityState(receipt, registeredPid, currentStartToken, liveness) {
3432
+ if (registeredPid == null)
3433
+ return 'registration-missing';
3434
+ if (registeredPid !== receipt.pid)
3435
+ return 'registration-changed';
3436
+ if (liveness === 'unknown')
3437
+ return 'unknown';
3438
+ if (liveness === 'dead')
3439
+ return currentStartToken ? 'unknown' : 'gone';
3440
+ if (!currentStartToken)
3441
+ return 'unknown';
3442
+ return currentStartToken === receipt.startToken ? 'same-live' : 'pid-reused';
3443
+ }
3444
+ const sessionLeafReceiptPath = (id) => sessionArtifactPath(id, 'agent.identity.json');
3445
+ function readSessionLeafReceipt(id) {
3446
+ const path = sessionLeafReceiptPath(id);
3447
+ let raw;
3448
+ try {
3449
+ raw = readFileSync(path, 'utf8');
3450
+ }
3451
+ catch (error) {
3452
+ if (error.code === 'ENOENT')
3453
+ return { state: 'missing' };
3454
+ return { state: 'invalid', reason: `leaf birth receipt is unreadable (${error instanceof Error ? error.message : String(error)})` };
3455
+ }
3456
+ const receipt = parseSessionLeafReceipt(raw, id);
3457
+ return receipt ? { state: 'valid', receipt } : { state: 'invalid', reason: 'leaf birth receipt is malformed or names a different session' };
3458
+ }
3459
+ function writeSessionLeafReceipt(id, receipt) {
3460
+ const path = sessionLeafReceiptPath(id);
3461
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3462
+ try {
3463
+ writeFileSync(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
3464
+ renameSync(temp, path);
3465
+ }
3466
+ finally {
3467
+ rmSync(temp, { force: true });
3468
+ }
3469
+ }
3470
+ function clearSessionLeafArtifacts(id) {
3471
+ rmSync(sessionArtifactPath(id, 'agent.pid'), { force: true });
3472
+ pidRegistry.delete(id);
3473
+ rmSync(sessionLeafReceiptPath(id), { force: true });
3474
+ }
3475
+ const sameSessionLeafReceipt = (left, right) => left.version === right.version && left.kind === right.kind && left.sessionId === right.sessionId
3476
+ && left.pid === right.pid && left.startToken === right.startToken;
3122
3477
  async function killAgentProcess(id, beforeSignal, leaf) {
3123
- const pid = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
3124
- if (pid !== leaf.pid)
3125
- throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`);
3126
- if (!Number.isFinite(pid) || pid <= 0)
3127
- return;
3478
+ const pid = leaf.pid;
3128
3479
  const startToken = leaf.startToken;
3129
3480
  const alive = () => leafAlive(pid);
3130
3481
  const identityState = () => {
3131
- if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid)
3482
+ const stored = readSessionLeafReceipt(id);
3483
+ if (stored.state !== 'valid' || !sameSessionLeafReceipt(stored.receipt, leaf.receipt))
3132
3484
  return 'changed';
3133
- const current = processStartToken(pid);
3134
- if (!current)
3135
- return alive() ? 'changed' : 'gone';
3136
- return current === startToken ? 'same' : 'changed';
3485
+ const registered = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
3486
+ const liveness = leafProcessLiveness(pid);
3487
+ const currentStartToken = sessionLeafStartToken(pid);
3488
+ const state = sessionLeafReceiptIdentityState(stored.receipt, Number.isSafeInteger(registered) ? registered : null, currentStartToken, liveness);
3489
+ return state === 'same-live' ? 'same' : state === 'gone' ? 'gone' : 'changed';
3137
3490
  };
3138
3491
  const initialState = identityState();
3139
3492
  if (initialState === 'gone')
@@ -3150,12 +3503,6 @@ async function killAgentProcess(id, beforeSignal, leaf) {
3150
3503
  };
3151
3504
  if (await gone(AGENT_EXIT_GRACE_MS))
3152
3505
  return; // the pane's SIGHUP took it — the normal path
3153
- const sameAgentInstance = async () => {
3154
- if (processStartToken(pid) !== startToken)
3155
- return false;
3156
- const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '');
3157
- return argv.includes(leaf.ownerNeedle) && processStartToken(pid) === startToken;
3158
- };
3159
3506
  for (const sig of ['SIGTERM', 'SIGKILL']) {
3160
3507
  await beforeSignal();
3161
3508
  const state = identityState();
@@ -3163,8 +3510,6 @@ async function killAgentProcess(id, beforeSignal, leaf) {
3163
3510
  return;
3164
3511
  if (state === 'changed')
3165
3512
  throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed during escalation`);
3166
- if (!await sameAgentInstance())
3167
- throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} no longer proves ownership`);
3168
3513
  try {
3169
3514
  process.kill(pid, sig);
3170
3515
  }
@@ -3174,29 +3519,36 @@ async function killAgentProcess(id, beforeSignal, leaf) {
3174
3519
  if (await gone(sig === 'SIGTERM' ? AGENT_EXIT_GRACE_MS : 1000))
3175
3520
  return;
3176
3521
  }
3522
+ throw new ResourceConflict(`refusing to stop ${id}: exact leaf PID ${pid}@${startToken} remains live after escalation`);
3177
3523
  }
3178
- // @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
3179
- // two: kill the agent's tmux client, make sure the agent itself actually went with it, drop its boot-window
3180
- // stamp (else a just-launched id lingers in the grace window reading `starting` instead of `offline`), and ask
3181
- // the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
3182
- // removes a transport whose listener is PROVEN dead.
3183
- // Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
3184
- // @@@ leafAlive - does this pid name a live process? EPERM counts as alive (a process we may not signal is
3185
- // still a process); only ESRCH is absence. Kept local: git.ts carries its own copy for lock reclamation, and
3186
- // collapsing the two is part of the spec/eval unification lane, not of this fix.
3187
- const leafAlive = (pid) => {
3524
+ const hostLeafProcessLiveness = (pid) => {
3188
3525
  try {
3189
3526
  process.kill(pid, 0);
3190
- return true;
3527
+ return 'alive';
3191
3528
  }
3192
3529
  catch (error) {
3193
- return error?.code !== 'ESRCH';
3530
+ const code = error?.code;
3531
+ if (code === 'ESRCH')
3532
+ return 'dead';
3533
+ if (code === 'EPERM')
3534
+ return 'alive';
3535
+ return 'unknown';
3194
3536
  }
3195
3537
  };
3196
- // One identity seam serves both signal teardown and cold retirement. A PID is only a locator: a live process
3197
- // becomes target-owned when its immutable start identity is readable AND its argv carries the harness-owned
3198
- // native identity. A live PID with a proven different argv is a stale artifact, while malformed/unreadable
3199
- // evidence remains unknown and therefore blocks every destructive path.
3538
+ let sessionLeafProcessProbe = {
3539
+ startToken: processStartToken,
3540
+ liveness: hostLeafProcessLiveness,
3541
+ };
3542
+ export function installSessionLeafProcessProbeForTest(probe) {
3543
+ const previous = sessionLeafProcessProbe;
3544
+ sessionLeafProcessProbe = probe;
3545
+ return () => { sessionLeafProcessProbe = previous; };
3546
+ }
3547
+ const sessionLeafStartToken = (pid) => sessionLeafProcessProbe.startToken(pid);
3548
+ const leafProcessLiveness = (pid) => sessionLeafProcessProbe.liveness(pid);
3549
+ const leafAlive = (pid) => leafProcessLiveness(pid) !== 'dead';
3550
+ // A retained leaf can mint durable ownership only while its registered PID is in the exact target pane's process
3551
+ // closure. The receipt then binds that one immutable process instance across tmux reparenting and crash retry.
3200
3552
  async function inspectSessionLeafIdentity(id, rec) {
3201
3553
  if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter')
3202
3554
  return { state: 'missing' };
@@ -3206,34 +3558,84 @@ async function inspectSessionLeafIdentity(id, rec) {
3206
3558
  raw = readFileSync(path, 'utf8');
3207
3559
  }
3208
3560
  catch (error) {
3209
- if (error?.code === 'ENOENT')
3210
- return { state: 'missing' };
3561
+ if (error?.code === 'ENOENT') {
3562
+ const stored = readSessionLeafReceipt(id);
3563
+ if (stored.state === 'invalid')
3564
+ return { state: 'unknown', reason: stored.reason };
3565
+ if (stored.state === 'missing')
3566
+ return { state: 'missing' };
3567
+ const liveness = leafProcessLiveness(stored.receipt.pid);
3568
+ const current = sessionLeafStartToken(stored.receipt.pid);
3569
+ const state = sessionLeafReceiptIdentityState(stored.receipt, stored.receipt.pid, current, liveness);
3570
+ if (state === 'same-live')
3571
+ return { state: 'unknown', pid: stored.receipt.pid, reason: 'leaf birth receipt remains live but agent.pid registration is missing' };
3572
+ if (state === 'unknown')
3573
+ return { state: 'unknown', pid: stored.receipt.pid, reason: 'leaf birth receipt PID is live but its process-start identity is unreadable' };
3574
+ if (state !== 'gone' && state !== 'pid-reused')
3575
+ return { state: 'unknown', pid: stored.receipt.pid, reason: `leaf birth receipt cannot reconcile a missing registration (${state})` };
3576
+ rmSync(sessionLeafReceiptPath(id), { force: true });
3577
+ return { state: 'dead', pid: stored.receipt.pid };
3578
+ }
3211
3579
  return { state: 'unknown', reason: `leaf PID artifact is unreadable (${error instanceof Error ? error.message : String(error)})` };
3212
3580
  }
3213
3581
  const pid = Number(raw.trim());
3214
3582
  if (!Number.isSafeInteger(pid) || pid <= 0)
3215
3583
  return { state: 'unknown', reason: 'leaf PID artifact is malformed' };
3216
- const startToken = processStartToken(pid);
3217
- if (!startToken)
3218
- return leafAlive(pid) ? { state: 'unknown', pid, reason: `leaf PID ${pid} is alive but its process-start identity is unreadable` } : { state: 'dead', pid };
3219
- const ownerNeedle = harnessById(rec.harness || defaultHarness.id).leafOwnerNeedle?.(rec);
3220
- if (!ownerNeedle)
3221
- return { state: 'unknown', pid, reason: `no exact harness identity is registered for leaf PID ${pid}` };
3222
- let argv;
3223
- try {
3224
- argv = (await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' })).stdout.trim();
3225
- }
3226
- catch {
3227
- return { state: 'unknown', pid, reason: `leaf PID ${pid} argv identity is unreadable` };
3584
+ const stored = readSessionLeafReceipt(id);
3585
+ if (stored.state === 'invalid')
3586
+ return { state: 'unknown', pid, reason: stored.reason };
3587
+ if (stored.state === 'valid') {
3588
+ const liveness = leafProcessLiveness(pid);
3589
+ const current = sessionLeafStartToken(pid);
3590
+ const state = sessionLeafReceiptIdentityState(stored.receipt, pid, current, liveness);
3591
+ if (state === 'same-live')
3592
+ return { state: 'owned', identity: { pid, startToken: current, receipt: stored.receipt } };
3593
+ if (state === 'gone') {
3594
+ clearSessionLeafArtifacts(id);
3595
+ return { state: 'dead', pid };
3596
+ }
3597
+ if (state === 'pid-reused') {
3598
+ const snap = await liveSnapshot(id);
3599
+ if (snap.probeFailed)
3600
+ return { state: 'unknown', pid, reason: 'target pane state is unreadable while reconciling a retired leaf receipt' };
3601
+ if (snap.windows.has(id))
3602
+ return { state: 'unknown', pid, reason: `leaf birth receipt no longer matches PID ${pid} while the exact target pane remains live` };
3603
+ clearSessionLeafArtifacts(id);
3604
+ return { state: 'dead', pid };
3605
+ }
3606
+ if (state === 'unknown')
3607
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} is live but its process-start identity is unreadable` };
3608
+ return { state: 'unknown', pid, reason: 'leaf birth receipt and agent.pid registration disagree' };
3228
3609
  }
3229
- if (!argv)
3230
- return { state: 'unknown', pid, reason: `leaf PID ${pid} argv identity is empty` };
3231
- const endToken = processStartToken(pid);
3232
- if (!endToken || endToken !== startToken)
3233
- return { state: 'unknown', pid, reason: `leaf PID ${pid} process-start identity changed during ownership read` };
3234
- if (argv.includes(ownerNeedle))
3235
- return { state: 'owned', identity: { pid, startToken, ownerNeedle } };
3236
- return { state: 'unrelated', pid, startToken };
3610
+ const snap = await liveSnapshot(id);
3611
+ if (snap.probeFailed)
3612
+ return { state: 'unknown', pid, reason: 'exact target pane is unreadable; leaf birth receipt cannot be minted' };
3613
+ const panePid = snap.windows.get(id)?.panePid ?? null;
3614
+ const startBefore = sessionLeafStartToken(pid);
3615
+ if (!startBefore) {
3616
+ const live = leafProcessLiveness(pid);
3617
+ if (live !== 'dead')
3618
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} process-start identity is unreadable while liveness is ${live}` };
3619
+ clearSessionLeafArtifacts(id);
3620
+ return { state: 'dead', pid };
3621
+ }
3622
+ let procs = null;
3623
+ try {
3624
+ procs = await procSnapshot();
3625
+ }
3626
+ catch { /* fail closed below */ }
3627
+ const startAfter = sessionLeafStartToken(pid);
3628
+ const candidate = sessionLeafReceiptCandidate(id, pid, panePid, procs, startBefore, startAfter);
3629
+ if (!candidate.ok || !candidate.receipt)
3630
+ return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt proof failed' };
3631
+ if (readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
3632
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed before receipt commit` };
3633
+ writeSessionLeafReceipt(id, candidate.receipt);
3634
+ const committed = readSessionLeafReceipt(id);
3635
+ if (committed.state !== 'valid' || !sameSessionLeafReceipt(committed.receipt, candidate.receipt)
3636
+ || readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
3637
+ return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed while receipt committed` };
3638
+ return { state: 'owned', identity: { pid, startToken: candidate.receipt.startToken, receipt: candidate.receipt } };
3237
3639
  }
3238
3640
  async function assertSessionLeafOwned(id, rec) {
3239
3641
  if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter')
@@ -3248,8 +3650,6 @@ async function assertSessionLeafOwned(id, rec) {
3248
3650
  return null;
3249
3651
  if (observed.state === 'owned')
3250
3652
  return observed.identity;
3251
- if (observed.state === 'unrelated')
3252
- throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${observed.pid}@${observed.startToken} does not prove argv ownership`);
3253
3653
  throw new ResourceConflict(`refusing to stop ${id}: ${observed.reason}`);
3254
3654
  }
3255
3655
  async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
@@ -3262,12 +3662,20 @@ async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
3262
3662
  const harness = harnessById(rec.harness || defaultHarness.id);
3263
3663
  const leaf = await assertSessionLeafOwned(id, rec);
3264
3664
  // Adapter-owned headless sessions may have no live leaf PID, but launch still created an exact tmux session
3265
- // wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the PID/argv proof, never the
3665
+ // wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the leaf receipt proof, never the
3266
3666
  // exact tmux teardown.
3267
3667
  await tmuxOk(['kill-session', '-t', id]);
3268
3668
  await assertTargetTmuxAbsent(id, 'after kill');
3269
- if (leaf)
3669
+ if (leaf) {
3270
3670
  await killAgentProcess(id, assertOwned, leaf);
3671
+ const registered = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
3672
+ const liveness = leafProcessLiveness(leaf.pid);
3673
+ const currentStartToken = sessionLeafStartToken(leaf.pid);
3674
+ const finalState = sessionLeafReceiptIdentityState(leaf.receipt, Number.isSafeInteger(registered) ? registered : null, currentStartToken, liveness);
3675
+ if (finalState !== 'gone' && finalState !== 'pid-reused')
3676
+ throw new ResourceConflict(`refusing to stop ${id}: exact leaf teardown remains ${finalState}`);
3677
+ clearSessionLeafArtifacts(id);
3678
+ }
3271
3679
  launchedAt.delete(id);
3272
3680
  await harness.cleanupRuntime(rec);
3273
3681
  if (requireCold) {
@@ -3293,7 +3701,8 @@ async function stopSessionUnlocked(id) {
3293
3701
  const rec = readRecord(id);
3294
3702
  if (rec)
3295
3703
  writeRecord({ ...rec, stopped: true });
3296
- requestQueueDrain(); // a stop frees a slot — start the next queued session if any
3704
+ if (wt.rec.status !== 'queued')
3705
+ requestQueueDrain(); // a live stop frees a slot; a prepared queue never held one
3297
3706
  return !!wt;
3298
3707
  }
3299
3708
  export const stopSession = (id) => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)));
@@ -3489,6 +3898,32 @@ async function assertColdRetirementSafe(id, rec) {
3489
3898
  throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`);
3490
3899
  }
3491
3900
  }
3901
+ async function assertDiscardableWorktree(id, path, branch, kind) {
3902
+ if (existsSync(path)) {
3903
+ const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
3904
+ if (!status.ok)
3905
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree status is unreadable`);
3906
+ if (status.stdout.trim())
3907
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree has dirty work`);
3908
+ }
3909
+ if (branch) {
3910
+ const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
3911
+ if (resolved.ok) {
3912
+ const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
3913
+ if (!count.ok)
3914
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch ancestry is unreadable`);
3915
+ const ahead = Number(count.stdout.trim());
3916
+ if (!Number.isFinite(ahead) || ahead !== 0)
3917
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
3918
+ }
3919
+ else if (resolved.failure !== 'exit') {
3920
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch identity is unreadable`);
3921
+ }
3922
+ else if (existsSync(path)) {
3923
+ throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree exists but branch ${branch} is missing`);
3924
+ }
3925
+ }
3926
+ }
3492
3927
  // A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
3493
3928
  // this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
3494
3929
  // No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
@@ -3511,32 +3946,31 @@ async function assertQueuedRetirementSafe(id, rec, path, branch) {
3511
3946
  const pid = readAgentPid(pidPath);
3512
3947
  throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`);
3513
3948
  }
3514
- if (existsSync(path)) {
3515
- const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
3516
- if (!status.ok)
3517
- throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree status is unreadable`);
3518
- if (status.stdout.trim())
3519
- throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree has dirty work`);
3520
- }
3521
- if (branch) {
3522
- const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
3523
- if (resolved.ok) {
3524
- const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
3525
- if (!count.ok)
3526
- throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch ancestry is unreadable`);
3527
- const ahead = Number(count.stdout.trim());
3528
- if (!Number.isFinite(ahead) || ahead !== 0)
3529
- throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
3530
- }
3531
- else if (resolved.failure !== 'exit') {
3532
- throw new ResourceConflict(`refusing to close queued session ${id}: prepared branch identity is unreadable`);
3533
- }
3534
- else if (existsSync(path)) {
3535
- throw new ResourceConflict(`refusing to close queued session ${id}: prepared worktree exists but branch ${branch} is missing`);
3536
- }
3537
- }
3949
+ await assertDiscardableWorktree(id, path, branch, 'prepared');
3950
+ }
3951
+ // A launch may leave its row active before Codex publishes the native thread binding. This close path owns
3952
+ // only the record's dead local launch residue; an unbound app-server peer stays unowned and untouched.
3953
+ async function assertUnboundRetirementSafe(id, rec, path, branch) {
3954
+ if (harnessById(rec.harness || defaultHarness.id).exactNativeTargetId(rec) || rec.status === 'queued' || rec.archived)
3955
+ throw new ResourceConflict(`refusing to close unbound session ${id}: it is not an unbound live-record residue`);
3956
+ if (rec.adapterRecovery || rec.launchReadinessPending || launching.has(id) || existsSync(sessionArtifactPath(id, 'launch')))
3957
+ throw new ResourceConflict(`refusing to close unbound session ${id}: launch or recovery is still in progress`);
3958
+ const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3959
+ if (snap.probeFailed)
3960
+ throw new ResourceConflict(`refusing to close unbound session ${id}: liveness probe failed; local worker absence is unproven`);
3961
+ const harness = harnessById(rec.harness || defaultHarness.id);
3962
+ if (harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online')
3963
+ throw new ResourceConflict(`refusing to close unbound session ${id}: its adapter still reports a live worker`);
3964
+ if (socket === 'live')
3965
+ throw new ResourceConflict(`refusing to close unbound session ${id}: target rendezvous transport already exists`);
3966
+ if (socket === 'unproven')
3967
+ throw new ResourceConflict(`refusing to close unbound session ${id}: target rendezvous state is ambiguous`);
3968
+ const leaf = await inspectSessionLeafIdentity(id, rec);
3969
+ if (leaf.state !== 'missing' && leaf.state !== 'dead')
3970
+ throw new ResourceConflict(`refusing to close unbound session ${id}: ${leaf.state === 'unknown' ? leaf.reason : 'target leaf identity is live or ambiguous'}`);
3971
+ await assertDiscardableWorktree(id, path, branch, 'unbound');
3538
3972
  }
3539
- async function closeOwnedSessionUnlocked(id, wt, source) {
3973
+ async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false) {
3540
3974
  const root = mainRoot();
3541
3975
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root);
3542
3976
  if (receiptFailure)
@@ -3549,7 +3983,7 @@ async function closeOwnedSessionUnlocked(id, wt, source) {
3549
3983
  await assertColdRetirementSafe(id, wt.rec);
3550
3984
  else if (wt.rec.status === 'queued')
3551
3985
  await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch);
3552
- else
3986
+ else if (!unboundRetired)
3553
3987
  throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`);
3554
3988
  }
3555
3989
  // The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
@@ -3631,28 +4065,38 @@ async function closeSessionUnlocked(id, source) {
3631
4065
  }
3632
4066
  if (!wt)
3633
4067
  return false;
4068
+ let unboundRetired = false;
3634
4069
  if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
3635
- // A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
3636
- // remains non-destructive while a turn is active; close already means discard this session's work.
3637
4070
  const harness = harnessById(wt.rec.harness || defaultHarness.id);
3638
- assertSessionOwnerSafe(id, harness.id);
3639
- const interrupt = harness.interrupt;
3640
- if (interrupt) {
3641
- const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
3642
- if (!result.ok)
3643
- throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
4071
+ if (!harness.exactNativeTargetId(wt.rec)) {
4072
+ await assertUnboundRetirementSafe(id, wt.rec, wt.path, wt.branch);
4073
+ await tmuxOk(['kill-session', '-t', id]);
4074
+ await assertTargetTmuxAbsent(id, 'after unbound residue retirement');
4075
+ await harness.cleanupRuntime(wt.rec);
4076
+ unboundRetired = true;
4077
+ }
4078
+ else {
4079
+ // A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
4080
+ // remains non-destructive while a turn is active; close already means discard this session's work.
4081
+ assertSessionOwnerSafe(id, harness.id);
4082
+ const interrupt = harness.interrupt;
4083
+ if (interrupt) {
4084
+ const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
4085
+ if (!result.ok)
4086
+ throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
4087
+ }
4088
+ const archived = await archiveSessionUnlocked(id);
4089
+ if (!archived)
4090
+ return false;
4091
+ wt = await findWorktree(id);
4092
+ if (!wt)
4093
+ return false;
3644
4094
  }
3645
- const archived = await archiveSessionUnlocked(id);
3646
- if (!archived)
3647
- return false;
3648
- wt = await findWorktree(id);
3649
- if (!wt)
3650
- return false;
3651
4095
  }
3652
4096
  const target = wt;
3653
4097
  return target.branch
3654
- ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source))
3655
- : closeOwnedSessionUnlocked(id, target, source);
4098
+ ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundRetired))
4099
+ : closeOwnedSessionUnlocked(id, target, source, unboundRetired);
3656
4100
  }
3657
4101
  export const closeSession = (id, rawSource) => {
3658
4102
  const source = normalizeCloseSource(rawSource);
@@ -4042,13 +4486,18 @@ export function formatTable(sessions, color = true, scope = { kind: 'sessions' }
4042
4486
  });
4043
4487
  return [heading, header, ...rows, statusLegend(color)].join('\n');
4044
4488
  }
4045
- function keyedPendingMessage(receipt, mid, delivery) {
4046
- return {
4047
- mid,
4048
- text: delivery.text,
4049
- from: delivery.from,
4050
- dispatch: { operation: receipt.operation, requestDigest: receipt.requestDigest },
4051
- };
4489
+ class StrandedDeliveryError extends Error {
4490
+ }
4491
+ async function strandedDeliveryError(rec) {
4492
+ const h = harnessById(rec.harness || defaultHarness.id);
4493
+ if (!h.deliveryTransport)
4494
+ return null;
4495
+ const transport = await h.deliveryTransport({ ...rec, runtimeDir: runtimeRoot() });
4496
+ if (transport.kind !== 'unreachable' || agentAlive(rec.session) !== true)
4497
+ return null;
4498
+ const queued = pendingMessages(rec.session).length;
4499
+ const noun = queued === 1 ? 'message is' : 'messages are';
4500
+ return new StrandedDeliveryError(`session ${rec.session} is stranded: ${transport.reason} while its registered agent process is still alive; ${queued} queued ${noun} waiting with no transport to claim them. Use \`spex session send ${rec.session} --keys "<keys>"\` to steer the live tmux pane, then repair the control transport before sending text.`);
4052
4501
  }
4053
4502
  export async function sendText(id, text, from, opts = {}) {
4054
4503
  if (!text)
@@ -4059,52 +4508,39 @@ export async function sendText(id, text, from, opts = {}) {
4059
4508
  // a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
4060
4509
  // the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
4061
4510
  // name an otherwise-unused lock until a matching session is closed.
4062
- await withRecordLocks([id, ...(from ? [from] : [])].sort(), async () => {
4063
- if (from && senderDeliveryRevoked(from))
4064
- throw new ResourceConflict(`sender session ${from} is closed; prompt NOT delivered`);
4065
- const rec = readRecord(id);
4066
- if (!rec)
4067
- throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
4068
- const accept = async () => {
4069
- if (opts.idempotency) {
4070
- const prior = sentDispatchReceipt(id, opts.idempotency.operation, opts.idempotency.requestDigest);
4071
- if (prior) {
4072
- if (prior.payloadHash !== opts.idempotency.payloadHash) {
4073
- const conflict = new ResourceConflict(`idempotency key is already bound to another ${opts.idempotency.operation} payload`);
4074
- Object.assign(conflict, { code: 'dispatch_key_reused' });
4075
- throw conflict;
4076
- }
4077
- if (prior.delivery && !prior.delivered) {
4078
- ensurePendingWhileLocked(id, keyedPendingMessage(opts.idempotency, prior.mid, prior.delivery));
4079
- }
4080
- replayed = true;
4081
- return;
4082
- }
4083
- }
4511
+ let rec = null;
4512
+ const accepted = await acceptMessage({
4513
+ target: id,
4514
+ text,
4515
+ from,
4516
+ idempotency: opts.idempotency,
4517
+ validate: async () => {
4518
+ rec = readRecord(id);
4519
+ if (!rec)
4520
+ throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
4521
+ },
4522
+ prepare: async () => {
4084
4523
  await opts.acceptGuard?.(rec);
4524
+ const stranded = await strandedDeliveryError(rec);
4525
+ if (stranded)
4526
+ throw stranded;
4085
4527
  // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
4086
4528
  // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
4087
4529
  // a message that was already accepted.
4088
4530
  const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
4089
- const dispatchReceipt = opts.idempotency
4090
- ? { ...opts.idempotency, delivery: { text: prompt.text, from: from ?? null } }
4091
- : undefined;
4092
- const appended = appendSent(id, text, from ?? null, prompt.replyVia, dispatchReceipt);
4093
- enqueue(id, opts.idempotency
4094
- ? keyedPendingMessage(opts.idempotency, appended.mid, dispatchReceipt.delivery)
4095
- : { mid: appended.mid, text: prompt.text, from: from ?? null });
4096
- };
4097
- if (opts.idempotency)
4098
- await withDeliveryLocks([id], accept);
4099
- else
4100
- await accept();
4531
+ return { text: prompt.text, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) };
4532
+ },
4101
4533
  });
4534
+ replayed = accepted.replayed;
4102
4535
  }
4103
4536
  catch (error) {
4104
4537
  const code = error?.code;
4538
+ const detail = error instanceof StrandedDeliveryError
4539
+ ? error.message
4540
+ : `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`;
4105
4541
  return {
4106
4542
  ok: false,
4107
- error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`,
4543
+ error: detail,
4108
4544
  ...(code ? { code } : {}),
4109
4545
  };
4110
4546
  }
@@ -4125,15 +4561,9 @@ export async function drainSession(id) {
4125
4561
  if (!rec)
4126
4562
  return;
4127
4563
  const h = harnessById(rec.harness || defaultHarness.id);
4564
+ if (h.launchPayloadProof && !rec.harnessSessionId)
4565
+ return;
4128
4566
  await drain(id, async (msg) => {
4129
- if (msg.dispatch) {
4130
- const receipt = sentDispatchReceipt(id, msg.dispatch.operation, msg.dispatch.requestDigest);
4131
- if (!receipt || receipt.mid !== msg.mid || !receipt.delivery
4132
- || receipt.delivery.text !== msg.text || receipt.delivery.from !== msg.from)
4133
- return false;
4134
- if (receipt.delivered)
4135
- return true;
4136
- }
4137
4567
  // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
4138
4568
  // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
4139
4569
  // rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
@@ -4145,8 +4575,6 @@ export async function drainSession(id) {
4145
4575
  catch { /* no pane to consult — let the insert itself decide */ }
4146
4576
  }
4147
4577
  const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text);
4148
- if (delivered.ok && msg.dispatch)
4149
- settleSentDispatch(id, msg.mid);
4150
4578
  return delivered.ok;
4151
4579
  });
4152
4580
  }