@sublang/playbook 12.2.2 → 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.
Files changed (38) hide show
  1. package/docs/cli.md +56 -52
  2. package/docs/configuration.md +2 -2
  3. package/docs/embedding.md +68 -32
  4. package/package.json +11 -3
  5. package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
  6. package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
  7. package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
  8. package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
  9. package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
  10. package/reference/sdlc/code.playbook/bin/run.js +62 -18
  11. package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
  12. package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
  13. package/reference/sdlc/code.playbook/code.fsm.js +43 -5
  14. package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
  15. package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
  17. package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
  18. package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
  19. package/reference/sdlc/code.playbook/session-host.js +18 -0
  20. package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
  21. package/reference/sdlc/code.playbook/session-store.js +22 -6
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
  24. package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
  25. package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
  26. package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
  27. package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
  28. package/reference/sdlc/review.playbook/review.fsm.js +11 -1
  29. package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
  30. package/slc/gears2fsm.md +21 -3
  31. package/slc/link.md +21 -2
  32. package/src/runtime.d.ts +7 -0
  33. package/src/runtime.ts +12 -0
  34. package/src/xstate-playbook-runtime.d.ts +13 -0
  35. package/src/xstate-playbook-runtime.js +75 -13
  36. package/src/xstate-playbook-runtime.ts +87 -21
  37. package/src/xstate-runtime.js +35 -4
  38. package/src/xstate-runtime.ts +49 -4
@@ -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,
@@ -57,8 +63,22 @@ export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
57
63
  export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
58
64
 
59
65
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
60
- const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
66
+ const ROLE_ID_WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
61
67
  const RESERVED_ID = 'captain';
68
+
69
+ // PBCLI-4: a local role id is its source role name lowercased by Unicode case
70
+ // mapping — nonempty, free of whitespace and control characters, and equal to
71
+ // its own lowercase form. The rule restricts neither script nor alphabet, so
72
+ // `coder`, `编码者`, and `作者` are canonical while `Coder` is not. Callers
73
+ // enforce the reserved `captain` name separately.
74
+ export function isCanonicalLocalRoleId(value) {
75
+ return (
76
+ typeof value === 'string' &&
77
+ value.length > 0 &&
78
+ value === value.toLowerCase() &&
79
+ !ROLE_ID_WHITESPACE_OR_CONTROL.test(value)
80
+ );
81
+ }
62
82
  const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
63
83
  const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
64
84
 
@@ -205,8 +225,7 @@ export function defaultCaptainSessionsDir(
205
225
  env = process.env,
206
226
  home = env.HOME ?? homedir(),
207
227
  ) {
208
- const stateHome = env.XDG_STATE_HOME || join(home, '.local', 'state');
209
- return join(stateHome, 'playbook', 'sessions');
228
+ return join(typeof env.SPEX_HOME === 'string' && env.SPEX_HOME.trim() !== '' ? env.SPEX_HOME : join(home, '.spex'), 'sessions');
210
229
  }
211
230
 
212
231
  // PBCLI-78: front-end bootstrap checks the same private filesystem boundary
@@ -221,6 +240,7 @@ export async function assertCaptainSessionsDirectoryUsable(
221
240
  }
222
241
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
223
242
  try {
243
+ await prepareSessionPermissions(sessionsDir, fs);
224
244
  await assertPrivateDirectory(sessionsDir, fs);
225
245
  await fs.access(
226
246
  sessionsDir,
@@ -616,6 +636,7 @@ export function createCaptainSessionStore(options = {}) {
616
636
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
617
637
  const replayReadCursors = new Map();
618
638
  const replayReadQueues = new Map();
639
+ const portableWriters = new Map();
619
640
 
620
641
  if (!isAbsolute(sessionsDir)) {
621
642
  throw new Error('Captain session store path must be absolute');
@@ -651,7 +672,7 @@ export function createCaptainSessionStore(options = {}) {
651
672
  return join(sessionsDir, `.${sessionId}.lock.retired.${ownerToken}`);
652
673
  };
653
674
 
654
- const readRecord = async (sessionId, { missing = 'error' } = {}) => {
675
+ const readRecord = async (sessionId, { missing = 'error', requirePortable = false } = {}) => {
655
676
  const path = recordPathFor(sessionId);
656
677
  let text;
657
678
  try {
@@ -676,7 +697,16 @@ export function createCaptainSessionStore(options = {}) {
676
697
  }
677
698
  let record;
678
699
  try {
679
- 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
+ }
680
710
  } catch (cause) {
681
711
  const context =
682
712
  `Captain session ${JSON.stringify(sessionId)} at ` +
@@ -720,6 +750,70 @@ export function createCaptainSessionStore(options = {}) {
720
750
  return record;
721
751
  };
722
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
+
723
817
  const read = (sessionId) => readRecord(sessionId);
724
818
 
725
819
  const readStream = async (sessionId, options) => {
@@ -809,15 +903,16 @@ export function createCaptainSessionStore(options = {}) {
809
903
  // Canonically named records are store-owned. Corruption must not make
810
904
  // --continue silently select an older logical session.
811
905
  try {
812
- candidates.push(await readRecord(sessionId));
906
+ candidates.push(await readRecord(sessionId, { requirePortable: true }));
813
907
  } catch (error) {
814
908
  if (error instanceof CaptainSessionRecordNonresumableError) {
815
- await onLegacyOrderingBoundary?.(error.orderingBoundary);
909
+ if (error.orderingBoundary) await onLegacyOrderingBoundary?.(error.orderingBoundary);
816
910
  await onLegacyRecord?.(
817
911
  Object.freeze({
818
912
  sessionId,
819
913
  path: recordPathFor(sessionId),
820
914
  schemaVersion: error.schemaVersion,
915
+ ...(error.schemaVersion >= 6 ? { reason: error.cause?.message ?? error.message } : {}),
821
916
  }),
822
917
  );
823
918
  continue;
@@ -851,37 +946,24 @@ export function createCaptainSessionStore(options = {}) {
851
946
  );
852
947
  };
853
948
 
854
- const readSummary = async (sessionId) =>
855
- 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
+ };
856
954
 
857
955
  const listSummaries = async () => {
858
- const skipped = [];
859
- const records = await listRecords({
860
- onLegacyRecord: ({ sessionId, schemaVersion }) => {
861
- skipped.push(
862
- Object.freeze({
863
- sessionId,
864
- reason:
865
- `Captain session schema ${schemaVersion} is below current ` +
866
- `schema ${CAPTAIN_SESSION_RECORD_SCHEMA_VERSION}`,
867
- }),
868
- );
869
- },
870
- onInvalidRecord: ({ sessionId, reason }) => {
871
- skipped.push(Object.freeze({ sessionId, reason }));
872
- },
873
- skipInvalidRecords: true,
874
- });
875
- return Object.freeze({
876
- sessions: Object.freeze(
877
- sortCaptainSessionRecords(records).map(projectPlaybookSessionSummary),
878
- ),
879
- skipped: Object.freeze(
880
- skipped.sort((left, right) =>
881
- left.sessionId.localeCompare(right.sessionId),
882
- ),
883
- ),
884
- });
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) });
885
967
  };
886
968
 
887
969
  const scanAdoptionPredecessor = async (
@@ -916,7 +998,7 @@ export function createCaptainSessionStore(options = {}) {
916
998
  ...legacyOrderingBoundaries.filter(
917
999
  (candidate) =>
918
1000
  candidate.sessionId !== target.sessionId &&
919
- candidate.state === 'settled' &&
1001
+ candidate.state !== 'uncertain' &&
920
1002
  candidate.cwd === target.cwd,
921
1003
  ),
922
1004
  ]);
@@ -940,9 +1022,12 @@ export function createCaptainSessionStore(options = {}) {
940
1022
 
941
1023
  const writeRecord = async (
942
1024
  recordValue,
943
- { noReplace, onPublished },
1025
+ { noReplace, onPublished, portable = false },
944
1026
  ) => {
945
- 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');
946
1031
  const destination = recordPathFor(record.sessionId);
947
1032
  await ensurePrivateDirectory(sessionsDir, fs);
948
1033
 
@@ -1006,6 +1091,7 @@ export function createCaptainSessionStore(options = {}) {
1006
1091
  onPublished?.();
1007
1092
  }
1008
1093
  await syncDirectory(sessionsDir, fs);
1094
+ portableWriters.get(record.sessionId)?.published(record);
1009
1095
  return record;
1010
1096
  } catch (cause) {
1011
1097
  try {
@@ -1025,10 +1111,11 @@ export function createCaptainSessionStore(options = {}) {
1025
1111
  };
1026
1112
 
1027
1113
  const deleteRecord = async (sessionId) => {
1028
- const path = recordPathFor(sessionId);
1029
- await assertPrivateRegularPath(path, 0o600, fs, 'record');
1030
- await fs.unlink(path);
1031
- 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
+ }
1032
1119
  };
1033
1120
 
1034
1121
  const readLeaseDirectory = async (
@@ -1083,6 +1170,26 @@ export function createCaptainSessionStore(options = {}) {
1083
1170
  const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
1084
1171
  readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
1085
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
+
1086
1193
  const activeLeaseErrorFor = async (sessionId, owner) => {
1087
1194
  if (owner.hostname !== localHostname) {
1088
1195
  return foreignCaptainSessionLeaseActiveError(
@@ -1286,8 +1393,9 @@ export function createCaptainSessionStore(options = {}) {
1286
1393
  return publishedOwner;
1287
1394
  };
1288
1395
 
1289
- const acquire = async (sessionId) => {
1396
+ const acquire = async (sessionId, management = false) => {
1290
1397
  assertSessionId(sessionId);
1398
+ await prepareSessionPermissions(sessionsDir, fs, sessionId);
1291
1399
  let stage;
1292
1400
  let stagePublished = false;
1293
1401
  try {
@@ -1316,6 +1424,21 @@ export function createCaptainSessionStore(options = {}) {
1316
1424
  await publishLeaseStage(sessionId, stage, () => {
1317
1425
  stagePublished = true;
1318
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
+ }
1319
1442
  return await createLease({
1320
1443
  sessionId,
1321
1444
  owner: stage.owner,
@@ -1324,6 +1447,10 @@ export function createCaptainSessionStore(options = {}) {
1324
1447
  replayFs: fs,
1325
1448
  readReplayStream: (options) => readStream(sessionId, options),
1326
1449
  readRecord,
1450
+ readManifest,
1451
+ readHistory,
1452
+ validateSession: validate,
1453
+ portableWriters,
1327
1454
  writeRecord,
1328
1455
  syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
1329
1456
  deleteRecord,
@@ -1361,14 +1488,219 @@ export function createCaptainSessionStore(options = {}) {
1361
1488
  }
1362
1489
  };
1363
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
+
1364
1687
  return Object.freeze({
1365
1688
  sessionsDir,
1689
+ prepare,
1690
+ migrate,
1691
+ migrateLegacyDefault,
1692
+ readManifest,
1693
+ readHistory,
1694
+ readLeaseState,
1695
+ validate,
1696
+ delete: remove,
1366
1697
  listSummaries,
1367
1698
  readSummary,
1368
1699
  read,
1369
1700
  readStream,
1370
1701
  latest,
1371
1702
  acquire,
1703
+ acquireManagement: (sessionId) => acquire(sessionId, true),
1372
1704
  });
1373
1705
  }
1374
1706
 
@@ -2063,7 +2395,7 @@ function canonicalReplayJson(value) {
2063
2395
  return JSON.stringify(value);
2064
2396
  }
2065
2397
 
2066
- function sanitizeReplayValue(value, path, ancestors) {
2398
+ function sanitizeReplayValue(value, path, ancestors, scope = 'record') {
2067
2399
  if (
2068
2400
  value === null ||
2069
2401
  typeof value === 'string' ||
@@ -2118,6 +2450,7 @@ function sanitizeReplayValue(value, path, ancestors) {
2118
2450
  descriptor.value,
2119
2451
  `${path}[${index}]`,
2120
2452
  nextAncestors,
2453
+ scope,
2121
2454
  ),
2122
2455
  );
2123
2456
  }
@@ -2151,10 +2484,19 @@ function sanitizeReplayValue(value, path, ancestors) {
2151
2484
  throw new TypeError(`${path} must not contain symbol-keyed properties`);
2152
2485
  }
2153
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';
2154
2495
  const copy = {};
2155
2496
  for (const key of keys) {
2156
2497
  if (typeof key !== 'string') continue;
2157
2498
  if (key === 'resumeToken') continue;
2499
+ if (provider && (['sessionid', 'nativesessionid', 'threadid', 'conversationid'].includes(key.replaceAll('_', '').toLowerCase()) || (scope === 'identity' && key === 'id'))) continue;
2158
2500
  const descriptor = descriptors[key];
2159
2501
  if (
2160
2502
  descriptor === undefined ||
@@ -2172,6 +2514,11 @@ function sanitizeReplayValue(value, path, ancestors) {
2172
2514
  descriptor.value,
2173
2515
  `${path}.${key}`,
2174
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',
2175
2522
  ),
2176
2523
  enumerable: true,
2177
2524
  configurable: true,
@@ -2189,6 +2536,10 @@ async function createLease({
2189
2536
  replayFs,
2190
2537
  readReplayStream,
2191
2538
  readRecord,
2539
+ readManifest,
2540
+ readHistory,
2541
+ validateSession,
2542
+ portableWriters,
2192
2543
  writeRecord,
2193
2544
  syncRecordDirectory,
2194
2545
  deleteRecord,
@@ -2239,9 +2590,102 @@ async function createLease({
2239
2590
  readStream: readReplayStream,
2240
2591
  });
2241
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
+
2242
2685
  const finishSettlement = async (record) => {
2243
2686
  await replayWriter.checkpoint();
2244
- return record;
2687
+ try { await writeHints(acknowledgedHints); } catch { /* Hints are optional; missing hints start fresh. */ }
2688
+ return validateCaptainSessionRecord(projectRecovery(record));
2245
2689
  };
2246
2690
 
2247
2691
  const read = () =>
@@ -2353,6 +2797,7 @@ async function createLease({
2353
2797
  const initializeSettledWithPredecessor = (options = {}) =>
2354
2798
  runExclusive(async () => {
2355
2799
  const target = freshSettledRecord(options);
2800
+ if (options.context !== undefined) await recordContext(options.context);
2356
2801
  await assertOwnerUnchecked();
2357
2802
  if (
2358
2803
  (await readRecord(sessionId, { missing: 'undefined' })) !== undefined
@@ -2588,6 +3033,7 @@ async function createLease({
2588
3033
  'Captain session attempted execution projection',
2589
3034
  );
2590
3035
  await assertOwnerUnchecked();
3036
+ await assertContinuable();
2591
3037
  const prior = await readRecord(sessionId, { missing: 'undefined' });
2592
3038
  if (prior === undefined) {
2593
3039
  throw new Error('Captain session does not exist for continuation');
@@ -2635,6 +3081,7 @@ async function createLease({
2635
3081
  throw new Error('Captain session retry requires a fresh attempt id');
2636
3082
  }
2637
3083
  await assertOwnerUnchecked();
3084
+ await assertContinuable();
2638
3085
  const prior = await requireUncertainRecord(
2639
3086
  await readRecord(sessionId, { missing: 'undefined' }),
2640
3087
  expectedAttemptId,
@@ -2923,7 +3370,7 @@ async function createLease({
2923
3370
  } = {}) =>
2924
3371
  runExclusive(async () => {
2925
3372
  assertUuid(attemptId, 'Captain session attempt id');
2926
- const updates = validateRetainedGenerationUpdates(retentionUpdates);
3373
+ const rawUpdates = validateRetainedGenerationUpdates(retentionUpdates);
2927
3374
  const settledUnresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
2928
3375
  unresolvedEffects,
2929
3376
  );
@@ -2946,7 +3393,19 @@ async function createLease({
2946
3393
  'Captain session abandonment settlement attempt differs from its durable marker',
2947
3394
  );
2948
3395
  }
2949
- 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);
2950
3409
  if (settledAbandonment !== undefined) {
2951
3410
  requireAbandonmentSettlement(
2952
3411
  settledAbandonment,
@@ -3172,8 +3631,7 @@ async function createLease({
3172
3631
  await assertOwnerUnchecked();
3173
3632
  return undefined;
3174
3633
  }
3175
- // writeRecord's stable key order reconstructs the exact prior settled
3176
- // bytes from the baseline carried by the uncertain record.
3634
+ // Restore the prior recovery baseline while retaining attempt history.
3177
3635
  const record = validateCaptainSessionRecord({
3178
3636
  schemaVersion: prior.schemaVersion,
3179
3637
  kind: CAPTAIN_SESSION_RECORD_KIND,
@@ -3200,9 +3658,14 @@ async function createLease({
3200
3658
  replayWriter.closeAppendAdmission();
3201
3659
  return runExclusive(async () => {
3202
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
+ }
3203
3665
  const current = await assertOwnerUnchecked();
3204
3666
  await retireObservedLease(sessionId, current);
3205
3667
  released = true;
3668
+ portableWriters.delete(sessionId);
3206
3669
  return replayWriter.status();
3207
3670
  });
3208
3671
  };
@@ -3210,7 +3673,13 @@ async function createLease({
3210
3673
  return Object.freeze({
3211
3674
  sessionId,
3212
3675
  ownerToken: owner.ownerToken,
3213
- append: replayWriter.append,
3676
+ append,
3677
+ recordContext,
3678
+ consumeHints,
3679
+ acknowledgeHint,
3680
+ clearHint,
3681
+ assertContinuable,
3682
+ readManifest: () => readManifest(sessionId),
3214
3683
  readStream: replayWriter.read,
3215
3684
  streamStatus: replayWriter.status,
3216
3685
  read,
@@ -3387,6 +3856,7 @@ export function captainSessionSelectedMembers(value) {
3387
3856
  }
3388
3857
 
3389
3858
  export function validateCaptainSessionRecord(value) {
3859
+ if (value?.schemaVersion === 7) return recoveryFromManifest(value);
3390
3860
  const record = requireRecord(
3391
3861
  snapshotJsonValue(value, 'Captain session record'),
3392
3862
  'Captain session record',
@@ -3500,12 +3970,7 @@ function validateCanonicalCaptainSessionRecord(
3500
3970
  'settled Captain session updatedAt must follow its creation marker',
3501
3971
  );
3502
3972
  }
3503
- if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
3504
- throw new Error('Captain session record cwd must be an absolute path');
3505
- }
3506
- if (resolve(record.cwd) !== record.cwd) {
3507
- throw new Error('Captain session record cwd must be normalized');
3508
- }
3973
+ if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
3509
3974
  const structural = validateCaptainSessionStructuralProjectionWithSchemas(
3510
3975
  record.structuralProjection,
3511
3976
  'Captain session record structuralProjection',
@@ -4699,12 +5164,7 @@ function assertReleasedSchema2CaptainSessionRecord(record) {
4699
5164
  'settled Captain session updatedAt must follow its creation marker',
4700
5165
  );
4701
5166
  }
4702
- if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
4703
- throw new Error('Captain session record cwd must be an absolute path');
4704
- }
4705
- if (resolve(record.cwd) !== record.cwd) {
4706
- throw new Error('Captain session record cwd must be normalized');
4707
- }
5167
+ if (!isRecordedAbsolutePath(record.cwd)) throw new Error('Captain session record cwd must be a normalized absolute path');
4708
5168
  requireRecord(record.config, 'Captain session record config');
4709
5169
  requireRecord(record.snapshot, 'Captain session record snapshot');
4710
5170
 
@@ -5080,10 +5540,7 @@ function validateRoleIds(value, path) {
5080
5540
  if (
5081
5541
  !Array.isArray(value) ||
5082
5542
  value.some(
5083
- (roleId) =>
5084
- typeof roleId !== 'string' ||
5085
- !ROLE_ID_PATTERN.test(roleId) ||
5086
- roleId === RESERVED_ID,
5543
+ (roleId) => !isCanonicalLocalRoleId(roleId) || roleId === RESERVED_ID,
5087
5544
  ) ||
5088
5545
  new Set(value).size !== value.length
5089
5546
  ) {
@@ -5889,8 +6346,8 @@ function nextTimestamp(value, previous) {
5889
6346
  return new Date(Date.parse(previous) + 1).toISOString();
5890
6347
  }
5891
6348
 
5892
- async function readPrivateRegularFile(path, mode, fs, label) {
5893
- 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);
5894
6351
  const handle = await fs.open(
5895
6352
  path,
5896
6353
  constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
@@ -5903,7 +6360,12 @@ async function readPrivateRegularFile(path, mode, fs, label) {
5903
6360
  if ((stat.mode & 0o7777) !== mode) {
5904
6361
  throw new Error(`${label} permissions must be ${octal(mode)}`);
5905
6362
  }
5906
- 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);
5907
6369
  } finally {
5908
6370
  await handle.close();
5909
6371
  }
@@ -6006,3 +6468,109 @@ async function assertDirectoryNotLink(path, fs) {
6006
6468
  function errorMessage(error) {
6007
6469
  return error instanceof Error ? error.message : String(error);
6008
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
+ }