@sublang/playbook 12.3.0 → 13.0.0

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.
@@ -7,6 +7,12 @@
7
7
  // complete retained-generation map under guarded source-first publication.
8
8
 
9
9
  import { createHash, randomUUID } from 'node:crypto';
10
+ import {
11
+ SESSION_MANIFEST_VERSION, EMPTY_REPLAY_SHA256, sha256,
12
+ validateSessionManifest, recoveryFromManifest, manifestFromRecovery,
13
+ contextFromRecovery, validateSessionContext, validateSessionHints,
14
+ projectRecovery, isRecordedAbsolutePath,
15
+ } from './portable-codec.js';
10
16
  import { constants } from 'node:fs';
11
17
  import {
12
18
  access,
@@ -219,8 +225,7 @@ export function defaultCaptainSessionsDir(
219
225
  env = process.env,
220
226
  home = env.HOME ?? homedir(),
221
227
  ) {
222
- const stateHome = env.XDG_STATE_HOME || join(home, '.local', 'state');
223
- return join(stateHome, 'playbook', 'sessions');
228
+ return join(typeof env.SPEX_HOME === 'string' && env.SPEX_HOME.trim() !== '' ? env.SPEX_HOME : join(home, '.spex'), 'sessions');
224
229
  }
225
230
 
226
231
  // PBCLI-78: front-end bootstrap checks the same private filesystem boundary
@@ -235,6 +240,7 @@ export async function assertCaptainSessionsDirectoryUsable(
235
240
  }
236
241
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
237
242
  try {
243
+ await prepareSessionPermissions(sessionsDir, fs);
238
244
  await assertPrivateDirectory(sessionsDir, fs);
239
245
  await fs.access(
240
246
  sessionsDir,
@@ -630,6 +636,7 @@ export function createCaptainSessionStore(options = {}) {
630
636
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
631
637
  const replayReadCursors = new Map();
632
638
  const replayReadQueues = new Map();
639
+ const portableWriters = new Map();
633
640
 
634
641
  if (!isAbsolute(sessionsDir)) {
635
642
  throw new Error('Captain session store path must be absolute');
@@ -665,7 +672,7 @@ export function createCaptainSessionStore(options = {}) {
665
672
  return join(sessionsDir, `.${sessionId}.lock.retired.${ownerToken}`);
666
673
  };
667
674
 
668
- const readRecord = async (sessionId, { missing = 'error' } = {}) => {
675
+ const readRecord = async (sessionId, { missing = 'error', requirePortable = false } = {}) => {
669
676
  const path = recordPathFor(sessionId);
670
677
  let text;
671
678
  try {
@@ -690,7 +697,16 @@ export function createCaptainSessionStore(options = {}) {
690
697
  }
691
698
  let record;
692
699
  try {
693
- record = validateCaptainSessionRecord(value);
700
+ if (value.schemaVersion === SESSION_MANIFEST_VERSION && value.state === 'history-only') {
701
+ const manifest = validateSessionManifest(value);
702
+ throw new CaptainSessionRecordNonresumableError(7, manifest.reason, captainSessionOrderingBoundary(manifest));
703
+ }
704
+ record = value.schemaVersion === SESSION_MANIFEST_VERSION
705
+ ? recoveryFromManifest(value)
706
+ : validateCaptainSessionRecord(value);
707
+ if (requirePortable && value.schemaVersion !== SESSION_MANIFEST_VERSION) {
708
+ throw new CaptainSessionRecordNonresumableError(value.schemaVersion, `schema ${value.schemaVersion} requires explicit migration; run playbook migrate-session ${sessionId}`, captainSessionOrderingBoundary(record));
709
+ }
694
710
  } catch (cause) {
695
711
  const context =
696
712
  `Captain session ${JSON.stringify(sessionId)} at ` +
@@ -734,6 +750,70 @@ export function createCaptainSessionStore(options = {}) {
734
750
  return record;
735
751
  };
736
752
 
753
+ const readManifest = async (sessionId) => {
754
+ let bytes;
755
+ try { await assertPrivateDirectory(sessionsDir, fs); bytes = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'record'); }
756
+ catch (cause) { if (cause?.code === 'ENOENT') throw new CaptainSessionNotFoundError(sessionId, recordPathFor(sessionId)); throw cause; }
757
+ const value = JSON.parse(bytes);
758
+ if (value.sessionId !== sessionId) throw new Error('session manifest identity does not match its filename');
759
+ return value.schemaVersion === 7 ? validateSessionManifest(value) : value;
760
+ };
761
+ const prepare = () => prepareSessionPermissions(sessionsDir, fs);
762
+ const readHistory = async (sessionId, options = {}) => {
763
+ assertSessionId(sessionId);
764
+ const history = await readSessionHistory({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: options.afterSeq ?? 0 });
765
+ if (!history.missing) return history;
766
+ let legacy;
767
+ try {
768
+ legacy = JSON.parse(await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'legacy record'));
769
+ if (![2, 3, 4, 5, 6].includes(legacy.schemaVersion)) return history;
770
+ if (legacy.kind !== CAPTAIN_SESSION_RECORD_KIND || legacy.sessionId !== sessionId || !Array.isArray(legacy.snapshot?.journal) || !Number.isFinite(Date.parse(legacy.updatedAt))) return history;
771
+ } catch { return history; }
772
+ return legacyJournalHistory(legacy, options.afterSeq ?? 0, history);
773
+ };
774
+ const validate = async (sessionId, context = {}) => {
775
+ const manifest = await readManifest(sessionId);
776
+ const history = await readHistory(sessionId);
777
+ const reasons = [];
778
+ let integrityValid = !history.missing && !history.incomplete;
779
+ if (history.missing) reasons.push('session replay file is missing');
780
+ if (history.pendingTail) reasons.push('session replay has an unfinished final record');
781
+ if (manifest.schemaVersion !== 7) {
782
+ if ([2, 3, 4, 5, 6].includes(manifest.schemaVersion)) {
783
+ try { validateCaptainSessionRecord(manifest); }
784
+ catch (cause) { if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause; reasons.push(cause.message); }
785
+ reasons.push(`schema ${manifest.schemaVersion} requires explicit migration; run playbook migrate-session ${sessionId}`);
786
+ } else reasons.push(`unsupported session schema ${manifest.schemaVersion}`);
787
+ }
788
+ else {
789
+ if (history.entries.some(({ record }) => !isDeepStrictEqual(record, sanitizeReplayRecord(record)))) {
790
+ integrityValid = false;
791
+ reasons.push('session replay contains provider continuation fields');
792
+ }
793
+ if (manifest.state === 'history-only') reasons.push(manifest.reason);
794
+ if (manifest.replay.incomplete || history.incomplete) reasons.push('session replay is incomplete');
795
+ if (history.digests[manifest.replay.seq] !== manifest.replay.sha256) { integrityValid = false; reasons.push('session replay checkpoint digest does not match'); }
796
+ if (manifest.contextSeq !== null) {
797
+ const contextRecord = history.entries.find((entry) => entry.seq === manifest.contextSeq)?.record;
798
+ try {
799
+ const supported = validateSessionContext(contextRecord);
800
+ if (manifest.state !== 'history-only') {
801
+ const execution = manifest.state === 'uncertain'
802
+ ? manifest.uncertain.attemptedExecutionProjection : manifest.lastAppliedExecutionProjection;
803
+ if (!isDeepStrictEqual(supported.configuration, execution) || supported.captainId !== manifest.snapshot.captain.sessionId) { integrityValid = false; reasons.push('required session context differs from checkpoint recovery'); }
804
+ }
805
+ } catch { reasons.push('required session context is absent or unsupported'); }
806
+ }
807
+ if (!isAbsolute(manifest.cwd) || resolve(manifest.cwd) !== manifest.cwd) reasons.push('recorded working directory is not native; checkpoint relocation is unsupported');
808
+ if (context.cwd !== undefined && context.cwd !== manifest.cwd) reasons.push('recorded working directory differs; checkpoint relocation is unsupported');
809
+ if (context.executionProjection !== undefined && manifest.state !== 'history-only') {
810
+ try { assertCaptainSessionExecutionCompatible(manifest.structuralProjection, context.executionProjection); }
811
+ catch (cause) { reasons.push(errorMessage(cause)); }
812
+ }
813
+ }
814
+ return Object.freeze({ sessionId, integrityValid, resumable: integrityValid && reasons.length === 0, reasons: Object.freeze(reasons), manifest, history });
815
+ };
816
+
737
817
  const read = (sessionId) => readRecord(sessionId);
738
818
 
739
819
  const readStream = async (sessionId, options) => {
@@ -823,15 +903,16 @@ export function createCaptainSessionStore(options = {}) {
823
903
  // Canonically named records are store-owned. Corruption must not make
824
904
  // --continue silently select an older logical session.
825
905
  try {
826
- candidates.push(await readRecord(sessionId));
906
+ candidates.push(await readRecord(sessionId, { requirePortable: true }));
827
907
  } catch (error) {
828
908
  if (error instanceof CaptainSessionRecordNonresumableError) {
829
- await onLegacyOrderingBoundary?.(error.orderingBoundary);
909
+ if (error.orderingBoundary) await onLegacyOrderingBoundary?.(error.orderingBoundary);
830
910
  await onLegacyRecord?.(
831
911
  Object.freeze({
832
912
  sessionId,
833
913
  path: recordPathFor(sessionId),
834
914
  schemaVersion: error.schemaVersion,
915
+ ...(error.schemaVersion >= 6 ? { reason: error.cause?.message ?? error.message } : {}),
835
916
  }),
836
917
  );
837
918
  continue;
@@ -865,37 +946,24 @@ export function createCaptainSessionStore(options = {}) {
865
946
  );
866
947
  };
867
948
 
868
- const readSummary = async (sessionId) =>
869
- projectPlaybookSessionSummary(await readRecord(sessionId));
949
+ const readSummary = async (sessionId) => {
950
+ const manifest = await readManifest(sessionId);
951
+ if (manifest.schemaVersion !== 7) validateCaptainSessionRecord(manifest);
952
+ return projectPlaybookSessionSummary(manifest);
953
+ };
870
954
 
871
955
  const listSummaries = async () => {
872
- const skipped = [];
873
- const records = await listRecords({
874
- onLegacyRecord: ({ sessionId, schemaVersion }) => {
875
- skipped.push(
876
- Object.freeze({
877
- sessionId,
878
- reason:
879
- `Captain session schema ${schemaVersion} is below current ` +
880
- `schema ${CAPTAIN_SESSION_RECORD_SCHEMA_VERSION}`,
881
- }),
882
- );
883
- },
884
- onInvalidRecord: ({ sessionId, reason }) => {
885
- skipped.push(Object.freeze({ sessionId, reason }));
886
- },
887
- skipInvalidRecords: true,
888
- });
889
- return Object.freeze({
890
- sessions: Object.freeze(
891
- sortCaptainSessionRecords(records).map(projectPlaybookSessionSummary),
892
- ),
893
- skipped: Object.freeze(
894
- skipped.sort((left, right) =>
895
- left.sessionId.localeCompare(right.sessionId),
896
- ),
897
- ),
898
- });
956
+ const sessions = [], skipped = [];
957
+ let names;
958
+ try { await assertPrivateDirectory(sessionsDir, fs); names = await fs.readdir(sessionsDir); }
959
+ catch (cause) { if (cause?.code === 'ENOENT') return { sessions, skipped }; throw cause; }
960
+ for (const name of names.sort()) {
961
+ const sessionId = name.slice(0, -5);
962
+ if (!name.endsWith('.json') || !SESSION_ID_PATTERN.test(sessionId)) continue;
963
+ try { sessions.push(await readSummary(sessionId)); }
964
+ catch (cause) { skipped.push({ sessionId, reason: errorMessage(cause) }); }
965
+ }
966
+ return Object.freeze({ sessions: Object.freeze(sortCaptainSessionRecords(sessions)), skipped: Object.freeze(skipped) });
899
967
  };
900
968
 
901
969
  const scanAdoptionPredecessor = async (
@@ -930,7 +998,7 @@ export function createCaptainSessionStore(options = {}) {
930
998
  ...legacyOrderingBoundaries.filter(
931
999
  (candidate) =>
932
1000
  candidate.sessionId !== target.sessionId &&
933
- candidate.state === 'settled' &&
1001
+ candidate.state !== 'uncertain' &&
934
1002
  candidate.cwd === target.cwd,
935
1003
  ),
936
1004
  ]);
@@ -954,9 +1022,12 @@ export function createCaptainSessionStore(options = {}) {
954
1022
 
955
1023
  const writeRecord = async (
956
1024
  recordValue,
957
- { noReplace, onPublished },
1025
+ { noReplace, onPublished, portable = false },
958
1026
  ) => {
959
- const record = validateCaptainSessionRecord(recordValue);
1027
+ const record = portable
1028
+ ? validateSessionManifest(recordValue)
1029
+ : await portableWriters.get(recordValue.sessionId)?.checkpoint(recordValue);
1030
+ if (record === undefined) throw new Error('session persistence requires its owning lifecycle');
960
1031
  const destination = recordPathFor(record.sessionId);
961
1032
  await ensurePrivateDirectory(sessionsDir, fs);
962
1033
 
@@ -1020,6 +1091,7 @@ export function createCaptainSessionStore(options = {}) {
1020
1091
  onPublished?.();
1021
1092
  }
1022
1093
  await syncDirectory(sessionsDir, fs);
1094
+ portableWriters.get(record.sessionId)?.published(record);
1023
1095
  return record;
1024
1096
  } catch (cause) {
1025
1097
  try {
@@ -1039,10 +1111,11 @@ export function createCaptainSessionStore(options = {}) {
1039
1111
  };
1040
1112
 
1041
1113
  const deleteRecord = async (sessionId) => {
1042
- const path = recordPathFor(sessionId);
1043
- await assertPrivateRegularPath(path, 0o600, fs, 'record');
1044
- await fs.unlink(path);
1045
- await syncDirectory(sessionsDir, fs);
1114
+ for (const suffix of ['.records.jsonl', '.hints.json', '.spex.json', '.json']) {
1115
+ const path = join(sessionsDir, `${sessionId}${suffix}`);
1116
+ try { await assertPrivateRegularPath(path, 0o600, fs, 'session file'); await fs.unlink(path); await syncDirectory(sessionsDir, fs); }
1117
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1118
+ }
1046
1119
  };
1047
1120
 
1048
1121
  const readLeaseDirectory = async (
@@ -1097,6 +1170,26 @@ export function createCaptainSessionStore(options = {}) {
1097
1170
  const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
1098
1171
  readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
1099
1172
 
1173
+ const readLeaseState = async (sessionId) => {
1174
+ assertSessionId(sessionId);
1175
+ try {
1176
+ await assertPrivateDirectory(sessionsDir, fs);
1177
+ await fs.lstat(leasePathFor(sessionId));
1178
+ } catch (cause) { return cause?.code === 'ENOENT' ? 'idle' : 'unknown'; }
1179
+ try {
1180
+ const owner = await readLeaseOwner(sessionId);
1181
+ if (owner.hostname !== localHostname) return 'unknown';
1182
+ let state = 'active';
1183
+ try { await probeProcess(owner.pid); }
1184
+ catch (cause) {
1185
+ if (cause?.code !== 'ESRCH') return 'unknown';
1186
+ state = 'idle';
1187
+ }
1188
+ const current = await readLeaseOwner(sessionId);
1189
+ return current.ownerToken === owner.ownerToken ? state : 'unknown';
1190
+ } catch { return 'unknown'; }
1191
+ };
1192
+
1100
1193
  const activeLeaseErrorFor = async (sessionId, owner) => {
1101
1194
  if (owner.hostname !== localHostname) {
1102
1195
  return foreignCaptainSessionLeaseActiveError(
@@ -1300,8 +1393,9 @@ export function createCaptainSessionStore(options = {}) {
1300
1393
  return publishedOwner;
1301
1394
  };
1302
1395
 
1303
- const acquire = async (sessionId) => {
1396
+ const acquire = async (sessionId, management = false) => {
1304
1397
  assertSessionId(sessionId);
1398
+ await prepareSessionPermissions(sessionsDir, fs, sessionId);
1305
1399
  let stage;
1306
1400
  let stagePublished = false;
1307
1401
  try {
@@ -1330,6 +1424,21 @@ export function createCaptainSessionStore(options = {}) {
1330
1424
  await publishLeaseStage(sessionId, stage, () => {
1331
1425
  stagePublished = true;
1332
1426
  });
1427
+ if (management) {
1428
+ let released = false;
1429
+ const assertOwner = async () => {
1430
+ if (released) throw new Error('session management lease was released');
1431
+ const current = await readLeaseOwner(sessionId);
1432
+ if (current.ownerToken !== stage.owner.ownerToken) throw new Error('session management ownership changed');
1433
+ return current;
1434
+ };
1435
+ return Object.freeze({ sessionId, ownerToken: stage.owner.ownerToken, assertOwner, async release() {
1436
+ if (released) return;
1437
+ const current = await assertOwner();
1438
+ await retireObservedLease(sessionId, current);
1439
+ released = true;
1440
+ } });
1441
+ }
1333
1442
  return await createLease({
1334
1443
  sessionId,
1335
1444
  owner: stage.owner,
@@ -1338,6 +1447,10 @@ export function createCaptainSessionStore(options = {}) {
1338
1447
  replayFs: fs,
1339
1448
  readReplayStream: (options) => readStream(sessionId, options),
1340
1449
  readRecord,
1450
+ readManifest,
1451
+ readHistory,
1452
+ validateSession: validate,
1453
+ portableWriters,
1341
1454
  writeRecord,
1342
1455
  syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
1343
1456
  deleteRecord,
@@ -1375,14 +1488,219 @@ export function createCaptainSessionStore(options = {}) {
1375
1488
  }
1376
1489
  };
1377
1490
 
1491
+ const migrate = async (sessionId, migration = {}) => {
1492
+ assertSessionId(sessionId);
1493
+ await prepareSessionPermissions(sessionsDir, fs, sessionId, true);
1494
+ const sourcePath = migration.sourcePath ?? recordPathFor(sessionId);
1495
+ const sidecar = sourcePath.endsWith('.spex.json');
1496
+ const sourceDir = dirname(sourcePath);
1497
+ const sourceReplayPath = join(sourceDir, `${sessionId}.records.jsonl`);
1498
+ const external = sourceDir !== sessionsDir;
1499
+ if (!isAbsolute(sourcePath) || sourcePath !== join(sourceDir, `${sessionId}${sidecar ? '.spex' : ''}.json`)) throw new Error('migration source must be the canonical session file');
1500
+ const backupDir = migration.backupDir ?? join(dirname(sessionsDir), 'local', 'migrations', sessionId);
1501
+ const inputsDir = join(backupDir, 'inputs');
1502
+ const receiptPath = join(backupDir, 'receipt.json');
1503
+ const decodeSource = (sourceBytes) => {
1504
+ const source = JSON.parse(sourceBytes);
1505
+ if (!sidecar && source.schemaVersion === 7) {
1506
+ if (external) throw new Error('source is already portable; select its complete bundle instead of legacy migration');
1507
+ return { source };
1508
+ }
1509
+ let recovery, metadata;
1510
+ if (sidecar) {
1511
+ const value = source.session ?? source;
1512
+ if (source.v !== 1 || value.id !== sessionId || !Number.isFinite(value.createdAt) || typeof migration.cwd !== 'string' || !isAbsolute(migration.cwd) || resolve(migration.cwd) !== migration.cwd) throw new Error('invalid legacy desktop sidecar or missing normalized cwd');
1513
+ metadata = { cwd: migration.cwd, createdAt: new Date(value.createdAt).toISOString(), updatedAt: new Date(value.endedAt ?? value.createdAt).toISOString(), reason: 'legacy desktop history lacks complete durable recovery', journal: value.snapshot?.shell?.journal ?? [] };
1514
+ } else {
1515
+ if (source.sessionId !== sessionId) throw new Error('migration session identity mismatch');
1516
+ try { recovery = validateCaptainSessionRecord(source); }
1517
+ catch (cause) {
1518
+ if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause;
1519
+ metadata = { cwd: source.cwd, createdAt: source.createdAt, updatedAt: source.updatedAt, reason: `legacy schema ${source.schemaVersion} has no supported recovery`, journal: source.snapshot?.journal ?? [] };
1520
+ }
1521
+ if (recovery && source.schemaVersion !== 6) throw new Error('only schema 6 supports executable migration');
1522
+ }
1523
+ return { source, recovery, metadata };
1524
+ };
1525
+ if (external) await prepareSessionPermissions(sourceDir, fs, sessionId, true);
1526
+ // Refusal needs no ownership or persistent memo. Reread after acquiring
1527
+ // the lease below; a preflight read never authorizes migration writes.
1528
+ try { decodeSource(await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source')); }
1529
+ catch (cause) { if (cause?.code !== 'ENOENT' || !sidecar && !external) throw cause; }
1530
+ let sourceLease, lease;
1531
+ try {
1532
+ if (external) {
1533
+ const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs, hostname: localHostname, pid: localPid, probeProcess });
1534
+ sourceLease = await sourceStore.acquireManagement(sessionId);
1535
+ }
1536
+ lease = await acquire(sessionId, true);
1537
+ await lease.assertOwner();
1538
+ let sourceBytes;
1539
+ try { sourceBytes = await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source'); }
1540
+ catch (cause) {
1541
+ if ((!sidecar && !external) || cause?.code !== 'ENOENT') throw cause;
1542
+ let receipt;
1543
+ if (external) {
1544
+ receipt = JSON.parse(await readPrivateRegularFile(receiptPath, 0o600, fs, 'migration receipt'));
1545
+ if (receipt.v !== 1 || receipt.id !== sessionId || !Array.isArray(receipt.inputs) || receipt.inputs[0]?.path !== sourcePath || ![1, 2].includes(receipt.inputs.length)) throw new Error('missing migration source has no matching receipt');
1546
+ for (const [index, input] of receipt.inputs.entries()) {
1547
+ const bytes = await readPrivateRegularFile(join(inputsDir, String(index)), 0o600, fs, 'retained migration input', true);
1548
+ if (sha256(bytes) !== input.sha256) throw new Error('retained migration input differs');
1549
+ }
1550
+ }
1551
+ const current = await validate(sessionId);
1552
+ if (!current.integrityValid) throw new Error('completed migration destination failed validation');
1553
+ if (receipt?.complete === false) await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1554
+ return { manifest: current.manifest, migrated: false, reasons: current.reasons };
1555
+ }
1556
+ let { source, recovery, metadata } = decodeSource(sourceBytes);
1557
+ if (!sidecar && source.schemaVersion === 7) {
1558
+ const current = await validate(sessionId);
1559
+ return { manifest: current.manifest, migrated: false, reasons: current.reasons };
1560
+ }
1561
+ const sourceSnapshot = await readReplaySnapshot({ sessionsDir: sourceDir, path: sourceReplayPath, fs, afterSeq: 0, forceFullRead: true });
1562
+ const snapshot = external ? await readReplaySnapshot({ sessionsDir, path: recordsPathFor(sessionId), fs, afterSeq: 0, forceFullRead: true }) : sourceSnapshot;
1563
+ const currentReplay = snapshot.absent ? undefined : snapshot.bytes;
1564
+ let originalReplay = sourceSnapshot.absent ? undefined : sourceSnapshot.bytes;
1565
+ let priorReceipt;
1566
+ try { priorReceipt = JSON.parse(await readPrivateRegularFile(receiptPath, 0o600, fs, 'migration receipt')); }
1567
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1568
+ if (priorReceipt !== undefined) {
1569
+ if (priorReceipt.v !== 1 || priorReceipt.id !== sessionId || !Array.isArray(priorReceipt.inputs) || priorReceipt.inputs[0]?.path !== sourcePath || priorReceipt.inputs[0]?.sha256 !== sha256(Buffer.from(sourceBytes))) throw new Error('migration source differs from retained input');
1570
+ const retainedSource = await readPrivateRegularFile(join(inputsDir, '0'), 0o600, fs, 'retained migration source');
1571
+ if (retainedSource !== sourceBytes) throw new Error('migration retained source differs');
1572
+ if (priorReceipt.inputs.length === 2) {
1573
+ originalReplay = await readPrivateRegularFile(join(inputsDir, '1'), 0o600, fs, 'retained migration replay', true);
1574
+ if (priorReceipt.inputs[1].path !== sourceReplayPath || sha256(originalReplay) !== priorReceipt.inputs[1].sha256) throw new Error('retained migration replay differs');
1575
+ } else if (priorReceipt.inputs.length === 1) originalReplay = undefined;
1576
+ else throw new Error('invalid migration inputs');
1577
+ }
1578
+ if (external && priorReceipt && !sourceSnapshot.absent && !sourceSnapshot.bytes.equals(originalReplay ?? Buffer.alloc(0))) throw new Error('migration source replay differs from retained input');
1579
+ let replayBytes = originalReplay ?? Buffer.alloc(0);
1580
+ if (originalReplay === undefined) {
1581
+ const journal = metadata?.journal ?? recovery?.snapshot?.journal ?? [];
1582
+ const updatedAt = source.updatedAt ?? metadata.updatedAt;
1583
+ const projected = legacyJournalHistory({ updatedAt, snapshot: { journal } }, 0, {}).entries;
1584
+ const records = [...projected.map(({ record }) => record), ...journal.map((entry) => ({ type: 'legacy_journal', timestamp: Date.parse(updatedAt), entry }))];
1585
+ replayBytes = Buffer.from(records.map((record, index) => `${JSON.stringify({ v: 1, seq: index + 1, record: sanitizeReplayRecord(record) })}\n`).join(''));
1586
+ }
1587
+ let history = parseSessionHistory(replayBytes);
1588
+ if (history.pendingTail) {
1589
+ try {
1590
+ parseReplayEnvelope(replayBytes.subarray(history.completeBytes), history.lastReadableSeq + 1);
1591
+ replayBytes = Buffer.concat([replayBytes, Buffer.from('\n')]);
1592
+ history = parseSessionHistory(replayBytes);
1593
+ } catch { /* Preserve a torn or invalid tail in the retained input only. */ }
1594
+ }
1595
+ const completeReplay = replayBytes.subarray(0, history.completeBytes);
1596
+ let offset = 0;
1597
+ replayBytes = Buffer.concat(history.entries.map((entry) => {
1598
+ const end = completeReplay.indexOf(0x0a, offset) + 1;
1599
+ const line = completeReplay.subarray(offset, end);
1600
+ offset = end;
1601
+ const record = sanitizeReplayRecord(entry.record);
1602
+ return isDeepStrictEqual(record, entry.record)
1603
+ ? line : Buffer.from(`${JSON.stringify({ ...entry, record })}\n`);
1604
+ }));
1605
+ let manifest;
1606
+ if (recovery && !history.incomplete && !history.pendingTail) {
1607
+ const context = contextFromRecovery(recovery);
1608
+ context.timestamp = Date.parse(recovery.updatedAt);
1609
+ const contextSeq = history.lastReadableSeq + 1;
1610
+ replayBytes = Buffer.concat([replayBytes, Buffer.from(`${JSON.stringify({v:1,seq:contextSeq,record:context})}\n`)]);
1611
+ manifest = manifestFromRecovery(recovery, { seq: contextSeq, sha256: sha256(replayBytes), incomplete: false }, contextSeq);
1612
+ } else {
1613
+ if (recovery) metadata = { cwd: recovery.cwd, createdAt: recovery.createdAt, updatedAt: recovery.updatedAt, reason: 'legacy replay is incomplete' };
1614
+ manifest = validateSessionManifest({ schemaVersion: 7, kind: 'captain-session', sessionId, state: 'history-only', cwd: metadata.cwd, createdAt: metadata.createdAt, updatedAt: metadata.updatedAt, reason: metadata.reason, replay: { seq: history.lastReadableSeq, sha256: sha256(replayBytes), incomplete: history.incomplete || history.pendingTail }, contextSeq: null });
1615
+ }
1616
+ if (priorReceipt && currentReplay !== undefined && !currentReplay.equals(originalReplay ?? Buffer.alloc(0)) && !currentReplay.equals(replayBytes)) throw new Error('migration destination replay diverged');
1617
+ if (external && currentReplay !== undefined && !currentReplay.equals(replayBytes)) throw new Error('migration destination replay diverged');
1618
+ if (sidecar || external) {
1619
+ try {
1620
+ const existing = await readPrivateRegularFile(recordPathFor(sessionId), 0o600, fs, 'migration destination');
1621
+ if (existing !== `${JSON.stringify(manifest)}\n`) throw new Error('migration destination manifest diverged');
1622
+ } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1623
+ }
1624
+ await ensurePrivateDirectory(inputsDir, fs);
1625
+ const inputs = [{ path: sourcePath, bytes: Buffer.from(sourceBytes, 'utf8') }, ...(originalReplay === undefined ? [] : [{ path: sourceReplayPath, bytes: originalReplay }])];
1626
+ for (const [index, input] of inputs.entries()) {
1627
+ const path = join(inputsDir, String(index));
1628
+ try {
1629
+ const retained = await readPrivateRegularFile(path, 0o600, fs, 'retained migration source', true);
1630
+ if (!retained.equals(input.bytes)) throw new Error('migration retained source differs');
1631
+ } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; await writePrivateBytes(path, input.bytes, fs); }
1632
+ }
1633
+ const receipt = { v: 1, id: sessionId, inputs: inputs.map(({ path, bytes }) => ({ path, sha256: sha256(bytes) })), complete: false };
1634
+ await writePrivateJson(receiptPath, receipt, fs);
1635
+ await lease.assertOwner();
1636
+ await writePrivateBytes(recordsPathFor(sessionId), replayBytes, fs);
1637
+ await writePrivateBytes(recordPathFor(sessionId), Buffer.from(`${JSON.stringify(manifest)}\n`), fs);
1638
+ const published = await validate(sessionId);
1639
+ if (!published.integrityValid) throw new Error('published migration bundle failed validation');
1640
+ if (external) {
1641
+ await sourceLease.assertOwner();
1642
+ try { await assertPrivateRegularPath(sourceReplayPath, 0o600, fs, 'migration source replay'); await fs.unlink(sourceReplayPath); await syncDirectory(sourceDir, fs); }
1643
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1644
+ }
1645
+ if (sidecar || external) { await fs.unlink(sourcePath); await syncDirectory(sourceDir, fs); }
1646
+ await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1647
+ return { manifest, migrated: true, reasons: [] };
1648
+ } finally {
1649
+ try { await lease?.release(); } finally { await sourceLease?.release(); }
1650
+ }
1651
+ };
1652
+
1653
+ const migrateLegacyDefault = async (migration = {}) => {
1654
+ const sourceEnv = migration.env ?? env;
1655
+ const sourceHome = migration.homeDir ?? home;
1656
+ const sourceDir = join(sourceEnv.XDG_STATE_HOME || join(sourceHome, '.local', 'state'), 'playbook', 'sessions');
1657
+ const result = { sourceDir, migrated: [], skipped: [] };
1658
+ if (sourceDir === sessionsDir) return result;
1659
+ const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, hostname: localHostname, pid: localPid, probeProcess });
1660
+ await sourceStore.prepare();
1661
+ try { await assertPrivateDirectory(sourceDir, fs); }
1662
+ catch (cause) { if (cause?.code === 'ENOENT') return result; throw cause; }
1663
+ const names = await fs.readdir(sourceDir);
1664
+ const ids = [...new Set(names.map((name) => name.endsWith('.records.jsonl') ? name.slice(0, -14) : name.endsWith('.json') ? name.slice(0, -5) : '').filter((id) => SESSION_ID_PATTERN.test(id)))].sort();
1665
+ for (const sessionId of ids) {
1666
+ const state = await sourceStore.readLeaseState(sessionId);
1667
+ if (state !== 'idle') throw new Error(`legacy session ${sessionId} ownership is ${state}; stop all old writers before migration`);
1668
+ const sourcePath = join(sourceDir, `${sessionId}.json`);
1669
+ try {
1670
+ const source = JSON.parse(await readPrivateRegularFile(sourcePath, 0o600, fs, 'legacy default manifest'));
1671
+ if (![2, 3, 4, 5, 6].includes(source.schemaVersion)) throw new Error(`unsupported legacy schema ${source.schemaVersion}`);
1672
+ try { validateCaptainSessionRecord(source); }
1673
+ catch (cause) { if (!(cause instanceof CaptainSessionRecordNonresumableError)) throw cause; }
1674
+ } catch (cause) { result.skipped.push({ sessionId, reason: errorMessage(cause) }); continue; }
1675
+ await migrate(sessionId, { sourcePath });
1676
+ result.migrated.push(sessionId);
1677
+ }
1678
+ return result;
1679
+ };
1680
+
1681
+ const remove = async (sessionId) => {
1682
+ const lease = await acquire(sessionId, true);
1683
+ try { await lease.assertOwner(); await deleteRecord(sessionId); }
1684
+ finally { await lease.release(); }
1685
+ };
1686
+
1378
1687
  return Object.freeze({
1379
1688
  sessionsDir,
1689
+ prepare,
1690
+ migrate,
1691
+ migrateLegacyDefault,
1692
+ readManifest,
1693
+ readHistory,
1694
+ readLeaseState,
1695
+ validate,
1696
+ delete: remove,
1380
1697
  listSummaries,
1381
1698
  readSummary,
1382
1699
  read,
1383
1700
  readStream,
1384
1701
  latest,
1385
1702
  acquire,
1703
+ acquireManagement: (sessionId) => acquire(sessionId, true),
1386
1704
  });
1387
1705
  }
1388
1706
 
@@ -2077,7 +2395,7 @@ function canonicalReplayJson(value) {
2077
2395
  return JSON.stringify(value);
2078
2396
  }
2079
2397
 
2080
- function sanitizeReplayValue(value, path, ancestors) {
2398
+ function sanitizeReplayValue(value, path, ancestors, scope = 'record') {
2081
2399
  if (
2082
2400
  value === null ||
2083
2401
  typeof value === 'string' ||
@@ -2132,6 +2450,7 @@ function sanitizeReplayValue(value, path, ancestors) {
2132
2450
  descriptor.value,
2133
2451
  `${path}[${index}]`,
2134
2452
  nextAncestors,
2453
+ scope,
2135
2454
  ),
2136
2455
  );
2137
2456
  }
@@ -2165,10 +2484,19 @@ function sanitizeReplayValue(value, path, ancestors) {
2165
2484
  throw new TypeError(`${path} must not contain symbol-keyed properties`);
2166
2485
  }
2167
2486
  const nextAncestors = new Set(ancestors).add(value);
2487
+ // Cligent session IDs are provider continuations. Playbook trace and
2488
+ // checkpoint session IDs are logical identities and must survive.
2489
+ const type = descriptors.type?.value;
2490
+ const adapterEvent = scope !== 'content' &&
2491
+ typeof type === 'string' && typeof descriptors.agent?.value === 'string' &&
2492
+ Number.isFinite(descriptors.timestamp?.value) && Object.hasOwn(descriptors, 'payload');
2493
+ const provider = scope === 'provider' || scope === 'identity' || adapterEvent;
2494
+ const observed = type === 'captain_event' || type === 'player_event';
2168
2495
  const copy = {};
2169
2496
  for (const key of keys) {
2170
2497
  if (typeof key !== 'string') continue;
2171
2498
  if (key === 'resumeToken') continue;
2499
+ if (provider && (['sessionid', 'nativesessionid', 'threadid', 'conversationid'].includes(key.replaceAll('_', '').toLowerCase()) || (scope === 'identity' && key === 'id'))) continue;
2172
2500
  const descriptor = descriptors[key];
2173
2501
  if (
2174
2502
  descriptor === undefined ||
@@ -2186,6 +2514,11 @@ function sanitizeReplayValue(value, path, ancestors) {
2186
2514
  descriptor.value,
2187
2515
  `${path}.${key}`,
2188
2516
  nextAncestors,
2517
+ scope === 'content' || (provider && (key === 'input' || key === 'output'))
2518
+ ? 'content'
2519
+ : provider
2520
+ ? ['session', 'thread', 'conversation'].includes(key) ? 'identity' : 'provider'
2521
+ : observed && key === 'event' ? 'provider' : 'record',
2189
2522
  ),
2190
2523
  enumerable: true,
2191
2524
  configurable: true,
@@ -2203,6 +2536,10 @@ async function createLease({
2203
2536
  replayFs,
2204
2537
  readReplayStream,
2205
2538
  readRecord,
2539
+ readManifest,
2540
+ readHistory,
2541
+ validateSession,
2542
+ portableWriters,
2206
2543
  writeRecord,
2207
2544
  syncRecordDirectory,
2208
2545
  deleteRecord,
@@ -2253,9 +2590,102 @@ async function createLease({
2253
2590
  readStream: readReplayStream,
2254
2591
  });
2255
2592
 
2593
+ let contextSeq;
2594
+ let previousManifest;
2595
+ let acknowledgedHints = { players: {} };
2596
+ try { previousManifest = await readManifest(sessionId); contextSeq = previousManifest.contextSeq; }
2597
+ catch { /* Unsupported recovery still permits leased migration/deletion. */ }
2598
+
2599
+ const recordContext = async (value) => {
2600
+ const context = validateSessionContext(value);
2601
+ if (contextSeq !== undefined && contextSeq !== null) {
2602
+ const previous = (await readHistory(sessionId)).entries.find((entry) => entry.seq === contextSeq)?.record;
2603
+ if (previous) {
2604
+ const { timestamp: _previousTimestamp, ...oldContext } = previous;
2605
+ const { timestamp: _newTimestamp, ...newContext } = context;
2606
+ if (isDeepStrictEqual(oldContext, newContext)) return contextSeq;
2607
+ }
2608
+ }
2609
+ await replayWriter.append(context);
2610
+ await replayWriter.checkpoint();
2611
+ const status = replayWriter.status();
2612
+ if (status.incomplete || status.lastDurableSeq === null) throw new Error('cannot persist session execution context');
2613
+ contextSeq = status.lastDurableSeq;
2614
+ return contextSeq;
2615
+ };
2616
+ const checkpoint = async (value) => {
2617
+ const recovery = validateCaptainSessionRecord(value);
2618
+ const required = contextFromRecovery(recovery);
2619
+ let contextHistory;
2620
+ try { contextHistory = await readHistory(sessionId); } catch { /* Preserve the last proven context on replay failure. */ }
2621
+ const applicable = contextHistory?.entries.findLast(({ record }) =>
2622
+ record.type === 'session_context' && record.contextVersion === 1 &&
2623
+ record.captainId === required.captainId && isDeepStrictEqual(record.configuration, required.configuration));
2624
+ if (applicable) contextSeq = applicable.seq;
2625
+ else if (contextSeq === undefined || contextSeq === null || !replayWriter.status().incomplete) await recordContext(required);
2626
+ await replayWriter.checkpoint();
2627
+ const status = replayWriter.status();
2628
+ let history;
2629
+ try { history = await readHistory(sessionId); }
2630
+ catch { history = { digests: [], incomplete: true }; }
2631
+ const durableSeq = status.lastDurableSeq ?? previousManifest?.replay?.seq ?? 0;
2632
+ const seq = history.digests[durableSeq] === undefined ? previousManifest?.replay?.seq ?? 0 : durableSeq;
2633
+ const digest = history.digests[seq] ?? previousManifest?.replay?.sha256 ?? EMPTY_REPLAY_SHA256;
2634
+ const replay = { seq, sha256: digest, incomplete: previousManifest?.replay?.incomplete === true || status.incomplete || history.incomplete || history.digests[seq] === undefined };
2635
+ const manifest = manifestFromRecovery(recovery, replay, contextSeq);
2636
+ return manifest;
2637
+ };
2638
+ portableWriters.set(sessionId, { checkpoint, sync: replayWriter.checkpoint, published: (manifest) => { previousManifest = manifest; } });
2639
+
2640
+ const hintsPath = join(sessionsDir, `${sessionId}.hints.json`);
2641
+ const readHints = async () => {
2642
+ try {
2643
+ const bytes = await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, replayFs, 'record');
2644
+ const manifest = validateSessionManifest(JSON.parse(bytes));
2645
+ const hints = JSON.parse(await readPrivateRegularFile(hintsPath, 0o600, replayFs, 'hints'));
2646
+ return validateSessionHints(hints, bytes, manifest);
2647
+ } catch { return { players: {} }; }
2648
+ };
2649
+ const writeHints = async (hints) => {
2650
+ const bytes = await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, replayFs, 'record');
2651
+ const manifest = validateSessionManifest(JSON.parse(bytes));
2652
+ const value = { v: 1, sessionId, checkpointSha256: sha256(bytes), players: hints.players, ...(hints.captain ? { captain: hints.captain } : {}) };
2653
+ validateSessionHints(value, bytes, manifest);
2654
+ await writePrivateJson(hintsPath, value, replayFs);
2655
+ };
2656
+ const consumeHints = () => runExclusive(async () => {
2657
+ await assertOwnerUnchecked();
2658
+ const hints = await readHints();
2659
+ await writeHints({ players: {} });
2660
+ // Unused conversations cannot advance. Retain that proof until the
2661
+ // participant's before-call hook clears it; crashes still lose the hints.
2662
+ acknowledgedHints = structuredClone(hints);
2663
+ return hints;
2664
+ });
2665
+ const acknowledgeHint = (participantId, token) => {
2666
+ if (typeof token !== 'string' || token.length === 0) return;
2667
+ if (participantId === 'captain') acknowledgedHints.captain = { kind: 'pinned', token };
2668
+ else acknowledgedHints.players[participantId] = token;
2669
+ };
2670
+ const clearHint = (participantId) => {
2671
+ if (participantId === 'captain') delete acknowledgedHints.captain;
2672
+ else delete acknowledgedHints.players[participantId];
2673
+ };
2674
+ const assertContinuable = async (context = {}) => {
2675
+ const result = await validateSession(sessionId, context);
2676
+ if (!result.resumable) throw new Error(result.reasons.join('; '));
2677
+ return result;
2678
+ };
2679
+ const append = (record, role) => replayWriter.append(
2680
+ contextSeq !== undefined && record?.type !== 'session_context' && typeof record?.type === 'string' && Number.isFinite(record?.timestamp)
2681
+ ? { ...record, contextSeq } : record,
2682
+ role,
2683
+ );
2684
+
2256
2685
  const finishSettlement = async (record) => {
2257
2686
  await replayWriter.checkpoint();
2258
- return record;
2687
+ try { await writeHints(acknowledgedHints); } catch { /* Hints are optional; missing hints start fresh. */ }
2688
+ return validateCaptainSessionRecord(projectRecovery(record));
2259
2689
  };
2260
2690
 
2261
2691
  const read = () =>
@@ -2367,6 +2797,7 @@ async function createLease({
2367
2797
  const initializeSettledWithPredecessor = (options = {}) =>
2368
2798
  runExclusive(async () => {
2369
2799
  const target = freshSettledRecord(options);
2800
+ if (options.context !== undefined) await recordContext(options.context);
2370
2801
  await assertOwnerUnchecked();
2371
2802
  if (
2372
2803
  (await readRecord(sessionId, { missing: 'undefined' })) !== undefined
@@ -2602,6 +3033,7 @@ async function createLease({
2602
3033
  'Captain session attempted execution projection',
2603
3034
  );
2604
3035
  await assertOwnerUnchecked();
3036
+ await assertContinuable();
2605
3037
  const prior = await readRecord(sessionId, { missing: 'undefined' });
2606
3038
  if (prior === undefined) {
2607
3039
  throw new Error('Captain session does not exist for continuation');
@@ -2649,6 +3081,7 @@ async function createLease({
2649
3081
  throw new Error('Captain session retry requires a fresh attempt id');
2650
3082
  }
2651
3083
  await assertOwnerUnchecked();
3084
+ await assertContinuable();
2652
3085
  const prior = await requireUncertainRecord(
2653
3086
  await readRecord(sessionId, { missing: 'undefined' }),
2654
3087
  expectedAttemptId,
@@ -2937,7 +3370,7 @@ async function createLease({
2937
3370
  } = {}) =>
2938
3371
  runExclusive(async () => {
2939
3372
  assertUuid(attemptId, 'Captain session attempt id');
2940
- const updates = validateRetainedGenerationUpdates(retentionUpdates);
3373
+ const rawUpdates = validateRetainedGenerationUpdates(retentionUpdates);
2941
3374
  const settledUnresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
2942
3375
  unresolvedEffects,
2943
3376
  );
@@ -2960,7 +3393,19 @@ async function createLease({
2960
3393
  'Captain session abandonment settlement attempt differs from its durable marker',
2961
3394
  );
2962
3395
  }
2963
- const settledSnapshot = assertPlaybookCaptainShellSnapshot(snapshot);
3396
+ // Compare durable recovery forms, not provider-local continuation hints.
3397
+ // Validate before projection so removing a token cannot legalize bad input.
3398
+ const rawSnapshot = assertPlaybookCaptainShellSnapshot(snapshot);
3399
+ const rawRetained = applyRetainedGenerationUpdates(
3400
+ current.retainedGenerations ?? {}, rawUpdates, current.structuralProjection);
3401
+ validateRetainedGenerations(rawRetained, current.structuralProjection, current.effectLedger);
3402
+ const projected = projectRecovery({ ...current, snapshot: rawSnapshot,
3403
+ retainedGenerations: rawRetained,
3404
+ });
3405
+ const settledSnapshot = assertPlaybookCaptainShellSnapshot(projected.snapshot);
3406
+ const updates = rawUpdates.map((update) => update.kind === 'retain'
3407
+ ? { ...update, generation: projected.retainedGenerations[update.rootPlaybookId] }
3408
+ : update);
2964
3409
  if (settledAbandonment !== undefined) {
2965
3410
  requireAbandonmentSettlement(
2966
3411
  settledAbandonment,
@@ -3186,8 +3631,7 @@ async function createLease({
3186
3631
  await assertOwnerUnchecked();
3187
3632
  return undefined;
3188
3633
  }
3189
- // writeRecord's stable key order reconstructs the exact prior settled
3190
- // bytes from the baseline carried by the uncertain record.
3634
+ // Restore the prior recovery baseline while retaining attempt history.
3191
3635
  const record = validateCaptainSessionRecord({
3192
3636
  schemaVersion: prior.schemaVersion,
3193
3637
  kind: CAPTAIN_SESSION_RECORD_KIND,
@@ -3214,9 +3658,14 @@ async function createLease({
3214
3658
  replayWriter.closeAppendAdmission();
3215
3659
  return runExclusive(async () => {
3216
3660
  await replayWriter.prepareRelease();
3661
+ if (replayWriter.status().incomplete && previousManifest?.replay?.incomplete !== true) {
3662
+ const record = await readRecord(sessionId, { missing: 'undefined' });
3663
+ if (record !== undefined) await writeRecord(record, { noReplace: false });
3664
+ }
3217
3665
  const current = await assertOwnerUnchecked();
3218
3666
  await retireObservedLease(sessionId, current);
3219
3667
  released = true;
3668
+ portableWriters.delete(sessionId);
3220
3669
  return replayWriter.status();
3221
3670
  });
3222
3671
  };
@@ -3224,7 +3673,13 @@ async function createLease({
3224
3673
  return Object.freeze({
3225
3674
  sessionId,
3226
3675
  ownerToken: owner.ownerToken,
3227
- append: replayWriter.append,
3676
+ append,
3677
+ recordContext,
3678
+ consumeHints,
3679
+ acknowledgeHint,
3680
+ clearHint,
3681
+ assertContinuable,
3682
+ readManifest: () => readManifest(sessionId),
3228
3683
  readStream: replayWriter.read,
3229
3684
  streamStatus: replayWriter.status,
3230
3685
  read,
@@ -3401,6 +3856,7 @@ export function captainSessionSelectedMembers(value) {
3401
3856
  }
3402
3857
 
3403
3858
  export function validateCaptainSessionRecord(value) {
3859
+ if (value?.schemaVersion === 7) return recoveryFromManifest(value);
3404
3860
  const record = requireRecord(
3405
3861
  snapshotJsonValue(value, 'Captain session record'),
3406
3862
  'Captain session record',
@@ -3514,12 +3970,7 @@ function validateCanonicalCaptainSessionRecord(
3514
3970
  'settled Captain session updatedAt must follow its creation marker',
3515
3971
  );
3516
3972
  }
3517
- if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
3518
- throw new Error('Captain session record cwd must be an absolute path');
3519
- }
3520
- if (resolve(record.cwd) !== record.cwd) {
3521
- throw new Error('Captain session record cwd must be normalized');
3522
- }
3973
+ if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
3523
3974
  const structural = validateCaptainSessionStructuralProjectionWithSchemas(
3524
3975
  record.structuralProjection,
3525
3976
  'Captain session record structuralProjection',
@@ -4713,12 +5164,7 @@ function assertReleasedSchema2CaptainSessionRecord(record) {
4713
5164
  'settled Captain session updatedAt must follow its creation marker',
4714
5165
  );
4715
5166
  }
4716
- if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
4717
- throw new Error('Captain session record cwd must be an absolute path');
4718
- }
4719
- if (resolve(record.cwd) !== record.cwd) {
4720
- throw new Error('Captain session record cwd must be normalized');
4721
- }
5167
+ if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
4722
5168
  requireRecord(record.config, 'Captain session record config');
4723
5169
  requireRecord(record.snapshot, 'Captain session record snapshot');
4724
5170
 
@@ -5900,8 +6346,8 @@ function nextTimestamp(value, previous) {
5900
6346
  return new Date(Date.parse(previous) + 1).toISOString();
5901
6347
  }
5902
6348
 
5903
- async function readPrivateRegularFile(path, mode, fs, label) {
5904
- await assertPrivateRegularPath(path, mode, fs, label);
6349
+ async function readPrivateRegularFile(path, mode, fs, label, raw = false) {
6350
+ const before = await assertPrivateRegularPath(path, mode, fs, label);
5905
6351
  const handle = await fs.open(
5906
6352
  path,
5907
6353
  constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
@@ -5914,7 +6360,12 @@ async function readPrivateRegularFile(path, mode, fs, label) {
5914
6360
  if ((stat.mode & 0o7777) !== mode) {
5915
6361
  throw new Error(`${label} permissions must be ${octal(mode)}`);
5916
6362
  }
5917
- return await handle.readFile('utf8');
6363
+ if (!sameFileIdentity(before, stat)) throw new Error(`${label} changed during open`);
6364
+ const bytes = await handle.readFile();
6365
+ const after = await handle.stat();
6366
+ const current = await assertPrivateRegularPath(path, mode, fs, label);
6367
+ if (!sameFileIdentity(after, current) || after.size !== stat.size) throw new Error(`${label} changed during read`);
6368
+ return raw ? bytes : new TextDecoder('utf-8', { fatal: true }).decode(bytes);
5918
6369
  } finally {
5919
6370
  await handle.close();
5920
6371
  }
@@ -6017,3 +6468,109 @@ async function assertDirectoryNotLink(path, fs) {
6017
6468
  function errorMessage(error) {
6018
6469
  return error instanceof Error ? error.message : String(error);
6019
6470
  }
6471
+
6472
+ // Opening is the only permission preparation boundary. Strict readers never
6473
+ // change modes, and verified handles ensure tightening cannot follow links.
6474
+ async function prepareSessionPermissions(sessionsDir, fs, selectedSessionId, includeSidecars = false) {
6475
+ let initial;
6476
+ try { initial = await fs.lstat(sessionsDir); }
6477
+ catch (cause) { if (cause?.code === 'ENOENT') return; throw cause; }
6478
+ const uid = process.getuid?.();
6479
+ const verify = (stat, directory) => {
6480
+ const required = directory ? 0o700 : 0o600;
6481
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) || (uid !== undefined && stat.uid !== uid) || (stat.mode & required) !== required) throw new Error('session permission preparation refuses unsafe ownership, links, type, or owner access');
6482
+ };
6483
+ const tighten = async (path, before, directory) => {
6484
+ verify(before, directory);
6485
+ const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (directory ? constants.O_DIRECTORY ?? 0 : 0));
6486
+ try {
6487
+ const opened = await handle.stat(); verify(opened, directory);
6488
+ if (!sameFileIdentity(before, opened)) throw new Error('session entry changed during permission preparation');
6489
+ const mode = directory ? 0o700 : 0o600;
6490
+ if ((opened.mode & 0o7777) !== mode) await handle.chmod(mode);
6491
+ const after = await handle.stat(); verify(after, directory);
6492
+ const current = await fs.lstat(path); verify(current, directory);
6493
+ if (!sameFileIdentity(after, current) || (after.mode & 0o7777) !== mode || (current.mode & 0o7777) !== mode) throw new Error('session permission tightening could not be verified');
6494
+ } finally { await handle.close(); }
6495
+ };
6496
+ await tighten(sessionsDir, initial, true);
6497
+ for (const name of await fs.readdir(sessionsDir)) {
6498
+ const eligible = /^[0-9a-f-]{36}\.(?:json|records\.jsonl|hints\.json)$/.test(name)
6499
+ || (includeSidecars && /^[0-9a-f-]{36}\.spex\.json$/.test(name));
6500
+ if (!eligible || (selectedSessionId !== undefined && !name.startsWith(`${selectedSessionId}.`))) continue;
6501
+ const path = join(sessionsDir, name);
6502
+ await tighten(path, await fs.lstat(path), false);
6503
+ }
6504
+ }
6505
+
6506
+ async function readSessionHistory({ sessionsDir, path, fs, afterSeq = 0 }) {
6507
+ if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new Error('history afterSeq must be a nonnegative safe integer');
6508
+ const snapshot = await readReplaySnapshot({ sessionsDir, path, fs, afterSeq: 0, forceFullRead: true });
6509
+ const bytes = snapshot.absent ? Buffer.alloc(0) : snapshot.bytes;
6510
+ return Object.freeze({ ...parseSessionHistory(bytes, afterSeq), missing: snapshot.absent === true });
6511
+ }
6512
+
6513
+
6514
+ function legacyJournalHistory(record, afterSeq, absentHistory) {
6515
+ const entries = [];
6516
+ const timestamp = Date.parse(record.updatedAt);
6517
+ let activeTurn;
6518
+ const emit = (event) => entries.push(Object.freeze({ v: 1, seq: entries.length + 1, record: Object.freeze(event) }));
6519
+ const finish = () => {
6520
+ if (activeTurn !== undefined) emit({ type: 'turn_finished', timestamp, turnId: activeTurn });
6521
+ activeTurn = undefined;
6522
+ };
6523
+ for (const entry of record.snapshot?.journal ?? []) {
6524
+ if (!Number.isSafeInteger(entry.turnId) || entry.turnId <= 0 || typeof entry.payload !== 'string') continue;
6525
+ if (entry.kind === 'boss') {
6526
+ finish(); activeTurn = entry.turnId;
6527
+ emit({ type: 'turn_started', timestamp, turnId: entry.turnId, turn: { id: entry.turnId, prompt: entry.payload } });
6528
+ } else if (entry.kind === 'reply' && entry.turnId === activeTurn) {
6529
+ emit({ type: 'captain_reply', timestamp, turnId: entry.turnId, text: entry.payload });
6530
+ }
6531
+ }
6532
+ finish();
6533
+ return Object.freeze({ ...absentHistory, synthetic: true, lastReadableSeq: entries.length, entries: Object.freeze(entries.filter(({ seq }) => seq > afterSeq)) });
6534
+ }
6535
+
6536
+ function parseSessionHistory(bytes, afterSeq = 0) {
6537
+ const entries = [], digests = [EMPTY_REPLAY_SHA256];
6538
+ const hash = createHash('sha256');
6539
+ let offset = 0, seq = 0, damage;
6540
+ while (offset < bytes.length) {
6541
+ const newline = bytes.indexOf(10, offset);
6542
+ if (newline < 0) break;
6543
+ let entry;
6544
+ try { entry = parseReplayEnvelope(bytes.subarray(offset, newline), seq + 1); }
6545
+ catch (cause) { damage = { seq: seq + 1, offset, reason: errorMessage(cause) }; break; }
6546
+ hash.update(bytes.subarray(offset, newline + 1));
6547
+ seq += 1; digests.push(hash.copy().digest('hex'));
6548
+ if (seq > afterSeq) entries.push(entry);
6549
+ offset = newline + 1;
6550
+ }
6551
+ return Object.freeze({ entries: Object.freeze(entries), lastReadableSeq: seq, incomplete: damage !== undefined, ...(damage ? { damage } : {}), pendingTail: damage === undefined && offset < bytes.length, digests: Object.freeze(digests), completeBytes: offset });
6552
+ }
6553
+
6554
+ async function writePrivateJson(path, value, fs) {
6555
+ return writePrivateBytes(path, Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'), fs);
6556
+ }
6557
+
6558
+ async function writePrivateBytes(path, bytes, fs) {
6559
+ const directory = dirname(path);
6560
+ await assertPrivateDirectory(directory, fs);
6561
+ try { await assertPrivateRegularPath(path, 0o600, fs, 'session data'); }
6562
+ catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
6563
+ const temporary = join(directory, `.${randomUUID()}.tmp`);
6564
+ let handle;
6565
+ try {
6566
+ handle = await fs.open(temporary, 'wx', 0o600);
6567
+ await handle.chmod(0o600);
6568
+ await handle.writeFile(bytes);
6569
+ await handle.sync(); await handle.close(); handle = undefined;
6570
+ await fs.rename(temporary, path);
6571
+ await syncDirectory(directory, fs);
6572
+ } finally {
6573
+ await handle?.close();
6574
+ try { await fs.unlink(temporary); } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
6575
+ }
6576
+ }