@sublang/playbook 6.0.0 → 7.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.
@@ -1,8 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
  import PQueue from 'p-queue';
5
- import { hiddenControlEnvelope, registerPlaybookAbortCleanup, } from '../../../src/xstate-runtime.js';
6
+ import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
6
7
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
7
8
  class VisibilityControlError extends Error {
8
9
  constructor(cause) {
@@ -414,6 +415,267 @@ function isValidRegistryEntry(value) {
414
415
  typeof e.validateOptions === 'function' &&
415
416
  typeof e.createRuntime === 'function');
416
417
  }
418
+ const SNAPSHOT_ACTIONS = new Set([
419
+ 'respond',
420
+ 'start',
421
+ 'switch',
422
+ 'dismiss',
423
+ 'deliver',
424
+ 'runtime',
425
+ ]);
426
+ const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
427
+ 'ok',
428
+ 'rejected',
429
+ 'failed',
430
+ ]);
431
+ const SNAPSHOT_JOURNAL_KINDS = new Set([
432
+ 'boss',
433
+ 'reply',
434
+ 'handoff',
435
+ 'action',
436
+ 'outcome',
437
+ ]);
438
+ function snapshotRecord(value, path) {
439
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
440
+ throw new TypeError(`${path} must be an object`);
441
+ }
442
+ return value;
443
+ }
444
+ function rejectSnapshotKeys(value, allowed, path) {
445
+ const allowedKeys = new Set(allowed);
446
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
447
+ if (unknown.length > 0) {
448
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
449
+ }
450
+ }
451
+ function snapshotString(value, path, allowEmpty = false) {
452
+ if (typeof value !== 'string' ||
453
+ (!allowEmpty && value.trim().length === 0)) {
454
+ throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
455
+ }
456
+ return value;
457
+ }
458
+ function snapshotInteger(value, path, minimum = 0) {
459
+ if (!Number.isSafeInteger(value) || value < minimum) {
460
+ throw new TypeError(`${path} must be an integer >= ${minimum}`);
461
+ }
462
+ return value;
463
+ }
464
+ function snapshotUuid(value, path) {
465
+ const id = snapshotString(value, path);
466
+ if (!UUID_PATTERN.test(id)) {
467
+ throw new TypeError(`${path} must be a UUID`);
468
+ }
469
+ return id;
470
+ }
471
+ /** Validate, detach, and freeze one untrusted shell snapshot. */
472
+ function assertPlaybookCaptainShellSnapshot(value) {
473
+ const detached = snapshotJsonValue(value, 'Captain shell snapshot');
474
+ const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
475
+ const mode = snapshot.mode;
476
+ const commonKeys = [
477
+ 'schemaVersion',
478
+ 'captain',
479
+ 'issuedSessionIds',
480
+ 'sequences',
481
+ 'journal',
482
+ 'lastAction',
483
+ 'lastSettlementStatus',
484
+ 'mode',
485
+ ];
486
+ if (mode === 'chat') {
487
+ rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
488
+ }
489
+ else if (mode === 'engaged.parked') {
490
+ rejectSnapshotKeys(snapshot, [
491
+ ...commonKeys,
492
+ 'frames',
493
+ 'rootPlayerResumeTokens',
494
+ 'pendingBossQuestions',
495
+ 'lastError',
496
+ ], 'Captain shell snapshot');
497
+ }
498
+ else {
499
+ throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
500
+ }
501
+ if (snapshot.schemaVersion !== 1) {
502
+ throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`);
503
+ }
504
+ const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
505
+ rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
506
+ const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
507
+ const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
508
+ const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
509
+ let normalizedConversation;
510
+ if (conversation.kind === 'pinned') {
511
+ rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
512
+ normalizedConversation = {
513
+ kind: 'pinned',
514
+ token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token', true),
515
+ };
516
+ }
517
+ else if (conversation.kind === 'unopened' ||
518
+ conversation.kind === 'needsSeeding') {
519
+ rejectSnapshotKeys(conversation, ['kind'], 'Captain shell snapshot.captain.conversation');
520
+ normalizedConversation = { kind: conversation.kind };
521
+ }
522
+ else {
523
+ throw new TypeError('Captain shell snapshot.captain.conversation.kind is not supported');
524
+ }
525
+ if (!Array.isArray(snapshot.issuedSessionIds)) {
526
+ throw new TypeError('Captain shell snapshot.issuedSessionIds must be an array');
527
+ }
528
+ const issued = snapshot.issuedSessionIds.map((id, index) => snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`));
529
+ if (new Set(issued).size !== issued.length) {
530
+ throw new TypeError('Captain shell snapshot.issuedSessionIds must not contain duplicates');
531
+ }
532
+ if (issued[0] !== captainSessionId) {
533
+ throw new TypeError('Captain shell snapshot Captain session id must be the first issued id');
534
+ }
535
+ const sequences = snapshotRecord(snapshot.sequences, 'Captain shell snapshot.sequences');
536
+ rejectSnapshotKeys(sequences, ['turn', 'journal'], 'Captain shell snapshot.sequences');
537
+ const turnSequence = snapshotInteger(sequences.turn, 'Captain shell snapshot.sequences.turn');
538
+ const journalSequence = snapshotInteger(sequences.journal, 'Captain shell snapshot.sequences.journal');
539
+ if (!Array.isArray(snapshot.journal)) {
540
+ throw new TypeError('Captain shell snapshot.journal must be an array');
541
+ }
542
+ const normalizedJournal = [];
543
+ let previousTurn = 0;
544
+ let bossRecords = 0;
545
+ for (const [index, value] of snapshot.journal.entries()) {
546
+ const record = snapshotRecord(value, `Captain shell snapshot.journal[${index}]`);
547
+ rejectSnapshotKeys(record, ['seq', 'turnId', 'kind', 'payload'], `Captain shell snapshot.journal[${index}]`);
548
+ const seq = snapshotInteger(record.seq, `Captain shell snapshot.journal[${index}].seq`, 1);
549
+ if (seq !== index + 1) {
550
+ throw new TypeError('Captain shell snapshot journal sequence must be contiguous from one');
551
+ }
552
+ const turnId = snapshotInteger(record.turnId, `Captain shell snapshot.journal[${index}].turnId`, 1);
553
+ if (turnId < previousTurn || turnId > turnSequence) {
554
+ throw new TypeError('Captain shell snapshot journal turn ids must be ordered and in range');
555
+ }
556
+ const kind = record.kind;
557
+ if (typeof kind !== 'string' ||
558
+ !SNAPSHOT_JOURNAL_KINDS.has(kind)) {
559
+ throw new TypeError(`Captain shell snapshot.journal[${index}].kind is not supported`);
560
+ }
561
+ if (turnId !== previousTurn) {
562
+ if (turnId !== previousTurn + 1 || kind !== 'boss') {
563
+ throw new TypeError('Captain shell snapshot journal must begin every turn with one boss record');
564
+ }
565
+ bossRecords++;
566
+ previousTurn = turnId;
567
+ }
568
+ else if (kind === 'boss') {
569
+ throw new TypeError('Captain shell snapshot journal must contain one boss record per turn');
570
+ }
571
+ normalizedJournal.push({
572
+ seq,
573
+ turnId,
574
+ kind: kind,
575
+ payload: record.payload,
576
+ });
577
+ }
578
+ if (journalSequence !== normalizedJournal.length ||
579
+ bossRecords !== turnSequence) {
580
+ throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
581
+ }
582
+ let lastAction;
583
+ if (snapshot.lastAction !== undefined) {
584
+ if (typeof snapshot.lastAction !== 'string' ||
585
+ !SNAPSHOT_ACTIONS.has(snapshot.lastAction)) {
586
+ throw new TypeError('Captain shell snapshot.lastAction is not supported');
587
+ }
588
+ lastAction = snapshot.lastAction;
589
+ }
590
+ let lastSettlementStatus;
591
+ if (snapshot.lastSettlementStatus !== undefined) {
592
+ if (typeof snapshot.lastSettlementStatus !== 'string' ||
593
+ !SNAPSHOT_SETTLEMENT_STATUSES.has(snapshot.lastSettlementStatus)) {
594
+ throw new TypeError('Captain shell snapshot.lastSettlementStatus is not supported');
595
+ }
596
+ lastSettlementStatus = snapshot.lastSettlementStatus;
597
+ }
598
+ const common = {
599
+ schemaVersion: 1,
600
+ captain: {
601
+ sessionId: captainSessionId,
602
+ runtime: captainRuntime,
603
+ conversation: normalizedConversation,
604
+ },
605
+ issuedSessionIds: issued,
606
+ sequences: { turn: turnSequence, journal: journalSequence },
607
+ journal: normalizedJournal,
608
+ ...(lastAction === undefined ? {} : { lastAction }),
609
+ ...(lastSettlementStatus === undefined
610
+ ? {}
611
+ : { lastSettlementStatus }),
612
+ };
613
+ if (mode === 'chat') {
614
+ return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
615
+ }
616
+ if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
617
+ throw new TypeError('Captain shell snapshot.frames must be a non-empty array');
618
+ }
619
+ const normalizedFrames = [];
620
+ for (const [index, value] of snapshot.frames.entries()) {
621
+ const frame = snapshotRecord(value, `Captain shell snapshot.frames[${index}]`);
622
+ rejectSnapshotKeys(frame, [
623
+ 'playbookId',
624
+ 'sessionId',
625
+ 'rootSessionId',
626
+ 'depth',
627
+ 'parentSessionId',
628
+ 'parentCallId',
629
+ 'runtime',
630
+ ], `Captain shell snapshot.frames[${index}]`);
631
+ const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
632
+ const sessionId = snapshotUuid(frame.sessionId, `Captain shell snapshot.frames[${index}].sessionId`);
633
+ const rootSessionId = snapshotUuid(frame.rootSessionId, `Captain shell snapshot.frames[${index}].rootSessionId`);
634
+ const depth = snapshotInteger(frame.depth, `Captain shell snapshot.frames[${index}].depth`);
635
+ const parentSessionId = frame.parentSessionId === undefined
636
+ ? undefined
637
+ : snapshotUuid(frame.parentSessionId, `Captain shell snapshot.frames[${index}].parentSessionId`);
638
+ const parentCallId = frame.parentCallId === undefined
639
+ ? undefined
640
+ : snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
641
+ const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
642
+ normalizedFrames.push({
643
+ playbookId,
644
+ sessionId,
645
+ rootSessionId,
646
+ depth,
647
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
648
+ ...(parentCallId === undefined ? {} : { parentCallId }),
649
+ runtime,
650
+ });
651
+ }
652
+ const rootTokens = snapshotRecord(snapshot.rootPlayerResumeTokens, 'Captain shell snapshot.rootPlayerResumeTokens');
653
+ const normalizedRootTokens = Object.fromEntries(Object.entries(rootTokens).map(([playerId, token]) => [
654
+ playerId,
655
+ snapshotString(token, `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`),
656
+ ]));
657
+ let normalizedLastError;
658
+ if (snapshot.lastError !== undefined) {
659
+ const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
660
+ rejectSnapshotKeys(error, ['name', 'message'], 'Captain shell snapshot.lastError');
661
+ normalizedLastError = {
662
+ name: snapshotString(error.name, 'Captain shell snapshot.lastError.name', true),
663
+ message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
664
+ };
665
+ }
666
+ return snapshotJsonValue({
667
+ ...common,
668
+ mode,
669
+ frames: normalizedFrames,
670
+ rootPlayerResumeTokens: normalizedRootTokens,
671
+ ...(snapshot.pendingBossQuestions === undefined
672
+ ? {}
673
+ : { pendingBossQuestions: snapshot.pendingBossQuestions }),
674
+ ...(normalizedLastError === undefined
675
+ ? {}
676
+ : { lastError: normalizedLastError }),
677
+ }, 'Captain shell snapshot');
678
+ }
417
679
  function readPlaybooksConfig(options) {
418
680
  if (typeof options !== 'object' || options === null)
419
681
  return undefined;
@@ -512,6 +774,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
512
774
  let byId = new Map();
513
775
  let enablementById = new Map();
514
776
  let session;
777
+ let sessionEmissionsOpen = false;
778
+ let closedGateAttempted = false;
779
+ let lifecycle = 'fresh';
780
+ let terminallyDisposed = false;
515
781
  let players = [];
516
782
  let activeContext;
517
783
  const frames = [];
@@ -524,6 +790,41 @@ export function createPlaybookCaptainShell(options, deps = {}) {
524
790
  const pendingChildParents = new Set();
525
791
  const captainQueue = new PQueue({ concurrency: 1 });
526
792
  let disposing = false;
793
+ const admitHostBoundary = () => {
794
+ if (sessionEmissionsOpen)
795
+ return;
796
+ closedGateAttempted = true;
797
+ throw new Error('Captain shell host boundaries are closed during restore');
798
+ };
799
+ const admitHostEmission = () => {
800
+ if (sessionEmissionsOpen)
801
+ return true;
802
+ closedGateAttempted = true;
803
+ return false;
804
+ };
805
+ const installSession = (initSession, emissionsOpen) => {
806
+ sessionEmissionsOpen = emissionsOpen;
807
+ closedGateAttempted = false;
808
+ session = {
809
+ signal: initSession.signal,
810
+ players: initSession.players,
811
+ emitStatus: async (message, data) => {
812
+ if (!admitHostEmission())
813
+ return;
814
+ await initSession.emitStatus(message, data);
815
+ },
816
+ emitTelemetry: async (event) => {
817
+ if (!admitHostEmission())
818
+ return;
819
+ await initSession.emitTelemetry(event);
820
+ },
821
+ setVisiblePlayers: async (playerIds) => {
822
+ if (!admitHostEmission())
823
+ return;
824
+ await initSession.setVisiblePlayers(playerIds);
825
+ },
826
+ };
827
+ };
527
828
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
528
829
  let captainRuntime;
529
830
  let captainSessionId;
@@ -760,6 +1061,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
760
1061
  let callNestedPlaybook;
761
1062
  const createPorts = (frame) => ({
762
1063
  callPlayer: async (playerId, prompt, signal, options) => {
1064
+ admitHostBoundary();
763
1065
  if (!activeContext) {
764
1066
  throw new Error('callPlayer invoked outside a Boss turn');
765
1067
  }
@@ -793,6 +1095,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
793
1095
  };
794
1096
  },
795
1097
  callCaptain: async (prompt, signal, options) => {
1098
+ admitHostBoundary();
796
1099
  if (!activeContext) {
797
1100
  throw new Error('callCaptain invoked outside a Boss turn');
798
1101
  }
@@ -810,6 +1113,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
810
1113
  };
811
1114
  },
812
1115
  callJudge: async (prompt, signal) => {
1116
+ admitHostBoundary();
813
1117
  if (!activeContext) {
814
1118
  throw new Error('callJudge invoked outside a Boss turn');
815
1119
  }
@@ -835,6 +1139,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
835
1139
  return result.finalText;
836
1140
  },
837
1141
  callPlaybook: (request, signal) => {
1142
+ admitHostBoundary();
838
1143
  const opening = callNestedPlaybook(frame, request, signal);
839
1144
  let exposed;
840
1145
  const registerOpeningCleanup = () => {
@@ -848,14 +1153,23 @@ export function createPlaybookCaptainShell(options, deps = {}) {
848
1153
  registerOpeningCleanup();
849
1154
  return exposed;
850
1155
  },
851
- emitStatus: async (message, data) => {
852
- await requireSession().emitStatus(message, data);
1156
+ emitStatus: (message, data) => {
1157
+ if (!admitHostEmission())
1158
+ return Promise.resolve();
1159
+ return trackHostCall(frame, (async () => {
1160
+ await requireSession().emitStatus(message, data);
1161
+ })());
853
1162
  },
854
- emitTelemetry: async (event) => {
855
- if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
856
- await mirrorSubRuntimeTelemetry(frame, event.payload);
857
- }
858
- await requireSession().emitTelemetry(event);
1163
+ emitTelemetry: (event) => {
1164
+ if (!admitHostEmission())
1165
+ return Promise.resolve();
1166
+ const emission = (async () => {
1167
+ if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1168
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
1169
+ }
1170
+ await requireSession().emitTelemetry(event);
1171
+ })();
1172
+ return trackHostCall(frame, emission);
859
1173
  },
860
1174
  });
861
1175
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
@@ -896,9 +1210,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
896
1210
  : undefined;
897
1211
  return typeof stack === 'string' ? { ...compact, stack } : compact;
898
1212
  };
899
- const makeFrame = (enablement, parent) => {
1213
+ const makePlayerBindings = (enablement, parent) => {
900
1214
  const entry = enablement.entry;
901
- const sessionId = allocateSessionId();
902
1215
  const playerBindings = new Map();
903
1216
  for (const role of entry.requiredRoleIds) {
904
1217
  let inherited;
@@ -915,6 +1228,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
915
1228
  player: configured,
916
1229
  });
917
1230
  }
1231
+ return playerBindings;
1232
+ };
1233
+ const makeFrame = (enablement, parent) => {
1234
+ const entry = enablement.entry;
1235
+ const sessionId = allocateSessionId();
1236
+ const playerBindings = makePlayerBindings(enablement, parent);
918
1237
  const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
919
1238
  const runtime = entry.createRuntime({
920
1239
  captainOptions: enablement.optionInput,
@@ -937,6 +1256,31 @@ export function createPlaybookCaptainShell(options, deps = {}) {
937
1256
  inFlightHostCalls: new Set(),
938
1257
  };
939
1258
  };
1259
+ const makeRestoredFrame = (enablement, snapshot, rootPlayerResumeTokens, parent) => {
1260
+ const entry = enablement.entry;
1261
+ const playerBindings = makePlayerBindings(enablement, parent);
1262
+ const runtime = entry.createRuntime({
1263
+ captainOptions: enablement.optionInput,
1264
+ players: [...playerBindings].map(([role, { player }]) => ({
1265
+ id: role,
1266
+ ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
1267
+ ...(player.model === undefined ? {} : { model: player.model }),
1268
+ })),
1269
+ });
1270
+ return {
1271
+ entry,
1272
+ enablement,
1273
+ runtime,
1274
+ sessionId: snapshot.sessionId,
1275
+ rootSessionId: snapshot.rootSessionId,
1276
+ depth: snapshot.depth,
1277
+ playerBindings,
1278
+ playerResumeTokens: rootPlayerResumeTokens,
1279
+ ...(parent ? { parent } : {}),
1280
+ state: snapshot.runtime.state,
1281
+ inFlightHostCalls: new Set(),
1282
+ };
1283
+ };
940
1284
  const playerSessionStore = (frame) => ({
941
1285
  select(playerId) {
942
1286
  const binding = bindingFor(frame, playerId);
@@ -970,21 +1314,22 @@ export function createPlaybookCaptainShell(options, deps = {}) {
970
1314
  }
971
1315
  },
972
1316
  });
1317
+ const frameSession = (frame) => ({
1318
+ sessionId: frame.sessionId,
1319
+ playbookId: frame.entry.id,
1320
+ rootSessionId: frame.rootSessionId,
1321
+ ...(frame.parent
1322
+ ? {
1323
+ parentSessionId: frame.parent.frame.sessionId,
1324
+ parentCallId: frame.parent.callId,
1325
+ }
1326
+ : {}),
1327
+ depth: frame.depth,
1328
+ playerSessions: playerSessionStore(frame),
1329
+ ports: createPorts(frame),
1330
+ });
973
1331
  const initFrame = async (frame) => {
974
- await frame.runtime.init({
975
- sessionId: frame.sessionId,
976
- playbookId: frame.entry.id,
977
- rootSessionId: frame.rootSessionId,
978
- ...(frame.parent
979
- ? {
980
- parentSessionId: frame.parent.frame.sessionId,
981
- parentCallId: frame.parent.callId,
982
- }
983
- : {}),
984
- depth: frame.depth,
985
- playerSessions: playerSessionStore(frame),
986
- ports: createPorts(frame),
987
- });
1332
+ await frame.runtime.init(frameSession(frame));
988
1333
  };
989
1334
  const clearLeafLedger = () => {
990
1335
  pendingBossQuestions = undefined;
@@ -1346,6 +1691,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1346
1691
  await resumeParent(frame, callResultFor(frame, result), context);
1347
1692
  }
1348
1693
  else {
1694
+ // CAPTAIN-20: the root is still alive here, so this is the one
1695
+ // authoritative boundary that can retain the Boss-facing meaning its
1696
+ // runtime publishes before disposal removes the frame. The opaque run
1697
+ // output remains runtime-to-runtime data and never becomes Captain
1698
+ // evidence (CAPPLAY-10).
1699
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame));
1349
1700
  await runEffect(() => disposeStack('final'));
1350
1701
  }
1351
1702
  return;
@@ -2025,9 +2376,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2025
2376
  // -------------------------------------------------------------------------
2026
2377
  const captainPorts = () => ({
2027
2378
  callPlayer: async () => {
2379
+ admitHostBoundary();
2028
2380
  throw new Error('the session Captain has no players');
2029
2381
  },
2030
2382
  callCaptain: async (prompt, signal) => {
2383
+ admitHostBoundary();
2031
2384
  if (!activeContext) {
2032
2385
  throw new Error('the session Captain called out of a Boss turn');
2033
2386
  }
@@ -2085,29 +2438,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2085
2438
  return { status: 'ok', finalText: 'ok' };
2086
2439
  },
2087
2440
  callJudge: async () => {
2441
+ admitHostBoundary();
2088
2442
  throw new Error('the session Captain makes no judge call');
2089
2443
  },
2090
2444
  callPlaybook: async () => {
2445
+ admitHostBoundary();
2091
2446
  throw new Error('the session Captain never calls a playbook');
2092
2447
  },
2093
2448
  // CAPTAIN-9: the session Captain's human status stream is suppressed while
2094
2449
  // its structured telemetry is forwarded.
2095
- emitStatus: async () => { },
2096
- emitTelemetry: async (event) => {
2097
- if (event.topic === 'playbook.trace') {
2098
- const payload = payloadRecord(event.payload);
2099
- if (payload?.type === 'captain.call.started') {
2100
- const identity = payloadRecord(payload.payload);
2101
- const stateId = identity?.stateId;
2102
- servingCall =
2103
- stateId === 'reporting'
2104
- ? 'closingReply'
2105
- : stateId === 'answeringCommand'
2106
- ? 'commandReply'
2107
- : 'decision';
2450
+ emitStatus: async () => {
2451
+ admitHostEmission();
2452
+ },
2453
+ emitTelemetry: (event) => {
2454
+ if (!admitHostEmission())
2455
+ return Promise.resolve();
2456
+ const emission = (async () => {
2457
+ if (event.topic === 'playbook.trace') {
2458
+ const payload = payloadRecord(event.payload);
2459
+ if (payload?.type === 'captain.call.started') {
2460
+ const identity = payloadRecord(payload.payload);
2461
+ const stateId = identity?.stateId;
2462
+ servingCall =
2463
+ stateId === 'reporting'
2464
+ ? 'closingReply'
2465
+ : stateId === 'answeringCommand'
2466
+ ? 'commandReply'
2467
+ : 'decision';
2468
+ }
2108
2469
  }
2109
- }
2110
- await requireSession().emitTelemetry(event);
2470
+ await requireSession().emitTelemetry(event);
2471
+ })();
2472
+ return trackTurnCall(emission);
2111
2473
  },
2112
2474
  });
2113
2475
  const resolveCommandTurn = (text) => {
@@ -2175,6 +2537,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2175
2537
  return undefined;
2176
2538
  }
2177
2539
  };
2540
+ const rootCompletionFact = (frame) => {
2541
+ const published = leafStateDescription(frame);
2542
+ const description = published === undefined ? '' : compactEvidence(published);
2543
+ return description === ''
2544
+ ? `${frameLabel(frame)} completed; its runtime published no result description.`
2545
+ : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
2546
+ };
2178
2547
  const leafStateSummary = () => {
2179
2548
  const leaf = leafFrame();
2180
2549
  if (!leaf)
@@ -2573,9 +2942,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2573
2942
  };
2574
2943
  throw outcome.error;
2575
2944
  }
2576
- if (!frames.includes(leaf)) {
2577
- facts.push(`${frameLabel(leaf)} finished and was disposed.`);
2578
- }
2579
2945
  const runFailed = drainRunFailureFacts(facts);
2580
2946
  const summary = leafStateSummary();
2581
2947
  turn.report = {
@@ -2730,8 +3096,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2730
3096
  };
2731
3097
  };
2732
3098
  const controller = {
2733
- submit: (selection, signal) => settleSelection(selection, signal),
2734
- resolveParsedTurn: () => shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution,
3099
+ submit: (selection, signal) => {
3100
+ admitHostBoundary();
3101
+ return settleSelection(selection, signal);
3102
+ },
3103
+ resolveParsedTurn: () => {
3104
+ admitHostBoundary();
3105
+ return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
3106
+ },
2735
3107
  };
2736
3108
  // -------------------------------------------------------------------------
2737
3109
  // Failure surface (CAPTAIN-34): a Boss-appropriate reply naming a concrete
@@ -2800,40 +3172,455 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2800
3172
  text: failureReplyText(),
2801
3173
  });
2802
3174
  };
2803
- return {
2804
- async init(initSession) {
2805
- session = initSession;
3175
+ const enabledCatalog = () => Object.freeze(entries.map((entry) => Object.freeze({
3176
+ id: entry.id,
3177
+ command: enablementById.get(entry.id).command,
3178
+ intent: entry.intent,
3179
+ })));
3180
+ const captainPlaybookSession = (id) => ({
3181
+ sessionId: id,
3182
+ playbookId: INTERNAL_CAPTAIN_ID,
3183
+ rootSessionId: id,
3184
+ depth: 0,
3185
+ ports: captainPorts(),
3186
+ });
3187
+ const tokenRecord = (tokens) => Object.fromEntries(tokens);
3188
+ const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
3189
+ const captain = snapshot.captain.runtime;
3190
+ if (captain.schemaVersion !== 2 ||
3191
+ captain.state.status !== 'active' ||
3192
+ !captain.state.quiescent ||
3193
+ !captain.state.tags.includes('playbook.parked') ||
3194
+ captain.suspendedCall !== undefined ||
3195
+ Object.keys(captain.playerResumeTokens).length > 0 ||
3196
+ captain.pendingBossQuestions.length > 0) {
3197
+ throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
3198
+ }
3199
+ if (captain.sequences.turn !== snapshot.sequences.turn) {
3200
+ throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
3201
+ }
3202
+ const emptyHistory = snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
3203
+ if ((snapshot.captain.conversation.kind === 'unopened') !== emptyHistory) {
3204
+ throw new TypeError('Captain shell snapshot unopened conversation must exactly match an empty session history');
3205
+ }
3206
+ if (snapshot.mode === 'chat')
3207
+ return;
3208
+ const activePlaybooks = new Set();
3209
+ const activeSessionIds = new Set([snapshot.captain.sessionId]);
3210
+ const issuedIds = new Set(snapshot.issuedSessionIds);
3211
+ const allowedHostPlayerIds = new Set();
3212
+ for (const enablement of enabled.values()) {
3213
+ for (const role of enablement.entry.requiredRoleIds) {
3214
+ allowedHostPlayerIds.add(enablement.hostPlayerId(role));
3215
+ }
3216
+ }
3217
+ for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
3218
+ if (!allowedHostPlayerIds.has(playerId)) {
3219
+ throw new TypeError(`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`);
3220
+ }
3221
+ }
3222
+ const bindingMaps = [];
3223
+ const rootSessionId = snapshot.frames[0].sessionId;
3224
+ for (const [index, frame] of snapshot.frames.entries()) {
3225
+ const enablement = enabled.get(frame.playbookId);
3226
+ if (!enablement) {
3227
+ throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
3228
+ }
3229
+ if (activePlaybooks.has(frame.playbookId)) {
3230
+ throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
3231
+ }
3232
+ activePlaybooks.add(frame.playbookId);
3233
+ if (activeSessionIds.has(frame.sessionId)) {
3234
+ throw new TypeError('Captain shell snapshot frame session ids must be unique');
3235
+ }
3236
+ activeSessionIds.add(frame.sessionId);
3237
+ if (!issuedIds.has(frame.sessionId)) {
3238
+ throw new TypeError('Captain shell snapshot frame session id was not historically issued');
3239
+ }
3240
+ if (frame.depth !== index ||
3241
+ frame.rootSessionId !== rootSessionId ||
3242
+ frame.runtime.state.status !== 'active' ||
3243
+ !frame.runtime.state.quiescent) {
3244
+ throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
3245
+ }
3246
+ if (index === 0) {
3247
+ if (frame.sessionId !== frame.rootSessionId ||
3248
+ frame.parentSessionId !== undefined ||
3249
+ frame.parentCallId !== undefined) {
3250
+ throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
3251
+ }
3252
+ }
3253
+ else {
3254
+ const parent = snapshot.frames[index - 1];
3255
+ if (frame.parentSessionId !== parent.sessionId ||
3256
+ frame.parentCallId === undefined) {
3257
+ throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
3258
+ }
3259
+ const pending = parent.runtime.suspendedCall;
3260
+ if (!pending ||
3261
+ pending.callId !== frame.parentCallId ||
3262
+ pending.playbookId !== frame.playbookId ||
3263
+ pending.childSessionId !== frame.sessionId) {
3264
+ throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
3265
+ }
3266
+ }
3267
+ const roleBindings = new Map();
3268
+ for (const role of enablement.entry.requiredRoleIds) {
3269
+ let inherited;
3270
+ for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
3271
+ inherited = bindingMaps[ancestor]?.get(role);
3272
+ if (inherited !== undefined)
3273
+ break;
3274
+ }
3275
+ roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
3276
+ }
3277
+ bindingMaps.push(roleBindings);
3278
+ const projectedTokens = Object.fromEntries([...roleBindings].flatMap(([role, hostPlayerId]) => {
3279
+ const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
3280
+ return token === undefined ? [] : [[role, token]];
3281
+ }));
3282
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
3283
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`);
3284
+ }
3285
+ }
3286
+ const leafRuntime = snapshot.frames.at(-1).runtime;
3287
+ if (leafRuntime.suspendedCall !== undefined ||
3288
+ !leafRuntime.state.tags.includes('playbook.parked')) {
3289
+ throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
3290
+ }
3291
+ };
3292
+ const safeCapturePoint = () => {
3293
+ if (lifecycle !== 'ready' ||
3294
+ terminallyDisposed ||
3295
+ !sessionEmissionsOpen ||
3296
+ !session ||
3297
+ session.signal.aborted ||
3298
+ !captainRuntime ||
3299
+ disposing ||
3300
+ shuttingDown ||
3301
+ activeContext !== undefined ||
3302
+ activeTurn !== undefined ||
3303
+ activeTurnHostCalls !== undefined ||
3304
+ activeTurnSummary !== undefined ||
3305
+ runFailureFacts !== undefined ||
3306
+ servingCall !== undefined ||
3307
+ decisionCall !== undefined ||
3308
+ captainQueue.pending !== 0 ||
3309
+ captainQueue.size !== 0 ||
3310
+ (mode !== 'chat' && mode !== 'engaged.parked')) {
3311
+ return false;
3312
+ }
3313
+ if ((mode === 'chat' &&
3314
+ (frames.length !== 0 ||
3315
+ pendingChildParents.size !== 0 ||
3316
+ pendingBossQuestions !== undefined ||
3317
+ lastError !== undefined)) ||
3318
+ (mode === 'engaged.parked' && frames.length === 0)) {
3319
+ return false;
3320
+ }
3321
+ const expectedParents = new Set(frames.slice(0, -1));
3322
+ if (pendingChildParents.size !== expectedParents.size ||
3323
+ [...pendingChildParents].some((frame) => !expectedParents.has(frame))) {
3324
+ return false;
3325
+ }
3326
+ return frames.every((frame, index) => {
3327
+ const liveInvocation = frame.invocationSignal !== undefined || frame.abortListener !== undefined;
3328
+ return (frame.state !== undefined &&
3329
+ frame.state.status === 'active' &&
3330
+ frame.state.quiescent &&
3331
+ !frame.disposing &&
3332
+ frame.disposePromise === undefined &&
3333
+ frame.removal === undefined &&
3334
+ frame.inFlightHostCalls.size === 0 &&
3335
+ (index === 0
3336
+ ? !liveInvocation
3337
+ : !liveInvocation ||
3338
+ (frame.invocationSignal !== undefined &&
3339
+ !frame.invocationSignal.aborted &&
3340
+ frame.abortListener !== undefined)));
3341
+ });
3342
+ };
3343
+ const exportShellSnapshot = () => {
3344
+ if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
3345
+ return undefined;
3346
+ }
3347
+ try {
3348
+ if (typeof captainRuntime.exportSnapshot !== 'function' ||
3349
+ typeof captainRuntime.restore !== 'function') {
3350
+ return undefined;
3351
+ }
3352
+ const captainSnapshot = captainRuntime.exportSnapshot();
3353
+ if (captainSnapshot === undefined)
3354
+ return undefined;
3355
+ const frameSnapshots = [];
3356
+ for (const frame of frames) {
3357
+ if (typeof frame.runtime.exportSnapshot !== 'function' ||
3358
+ typeof frame.runtime.restore !== 'function') {
3359
+ return undefined;
3360
+ }
3361
+ const runtime = frame.runtime.exportSnapshot();
3362
+ if (runtime === undefined ||
3363
+ !isDeepStrictEqual(frame.state, runtime.state)) {
3364
+ return undefined;
3365
+ }
3366
+ frameSnapshots.push({
3367
+ playbookId: frame.entry.id,
3368
+ sessionId: frame.sessionId,
3369
+ rootSessionId: frame.rootSessionId,
3370
+ depth: frame.depth,
3371
+ ...(frame.parent
3372
+ ? {
3373
+ parentSessionId: frame.parent.frame.sessionId,
3374
+ parentCallId: frame.parent.callId,
3375
+ }
3376
+ : {}),
3377
+ runtime,
3378
+ });
3379
+ }
3380
+ const common = {
3381
+ schemaVersion: 1,
3382
+ captain: {
3383
+ sessionId: captainSessionId,
3384
+ runtime: captainSnapshot,
3385
+ conversation,
3386
+ },
3387
+ issuedSessionIds: [...issuedSessionIds],
3388
+ sequences: { turn: turnSequence, journal: journalSeq },
3389
+ journal,
3390
+ ...(lastAction === undefined ? {} : { lastAction }),
3391
+ ...(lastSettlementStatus === undefined
3392
+ ? {}
3393
+ : { lastSettlementStatus }),
3394
+ };
3395
+ const candidate = mode === 'chat'
3396
+ ? { ...common, mode }
3397
+ : {
3398
+ ...common,
3399
+ mode: 'engaged.parked',
3400
+ frames: frameSnapshots,
3401
+ rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
3402
+ ...(pendingBossQuestions === undefined
3403
+ ? {}
3404
+ : { pendingBossQuestions: pendingBossQuestions }),
3405
+ ...(lastError === undefined ? {} : { lastError }),
3406
+ };
3407
+ const normalized = assertPlaybookCaptainShellSnapshot(candidate);
3408
+ assertSnapshotMatchesEnablements(normalized, enablementById);
3409
+ return normalized;
3410
+ }
3411
+ catch {
3412
+ return undefined;
3413
+ }
3414
+ };
3415
+ const verifyRestoredRuntime = (runtime, expected, playbookId, allowSuspendedCall) => {
3416
+ const actual = runtime.exportSnapshot?.();
3417
+ if (actual === undefined) {
3418
+ throw new Error(`restored ${playbookId} runtime did not reach a safe snapshot boundary`);
3419
+ }
3420
+ const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
3421
+ for (const key of [
3422
+ 'state',
3423
+ 'playerResumeTokens',
3424
+ 'sequences',
3425
+ 'pendingBossQuestions',
3426
+ 'suspendedCall',
3427
+ ]) {
3428
+ if (!isDeepStrictEqual(normalized[key], expected[key])) {
3429
+ throw new Error(`restored ${playbookId} runtime changed snapshot field ${key}`);
3430
+ }
3431
+ }
3432
+ };
3433
+ const resetFailedRestore = async () => {
3434
+ const cleanupFailures = [];
3435
+ for (const frame of [...frames].reverse()) {
3436
+ frame.disposing = true;
3437
+ try {
3438
+ await frame.runtime.dispose();
3439
+ }
3440
+ catch (error) {
3441
+ cleanupFailures.push(error);
3442
+ }
3443
+ }
3444
+ if (captainRuntime) {
3445
+ shuttingDown = true;
3446
+ try {
3447
+ await captainRuntime.dispose();
3448
+ }
3449
+ catch (error) {
3450
+ cleanupFailures.push(error);
3451
+ }
3452
+ }
3453
+ frames.splice(0);
3454
+ pendingChildParents.clear();
3455
+ issuedSessionIds.clear();
3456
+ journal.splice(0);
3457
+ entries = [];
3458
+ byCommand = new Map();
3459
+ byId = new Map();
3460
+ enablementById = new Map();
3461
+ players = [];
3462
+ session = undefined;
3463
+ sessionEmissionsOpen = false;
3464
+ closedGateAttempted = false;
3465
+ captainRuntime = undefined;
3466
+ captainSessionId = undefined;
3467
+ conversation = { kind: 'unopened' };
3468
+ mode = 'chat';
3469
+ pendingBossQuestions = undefined;
3470
+ lastError = undefined;
3471
+ journalSeq = 0;
3472
+ turnSequence = 0;
3473
+ lastAction = undefined;
3474
+ lastSettlementStatus = undefined;
3475
+ shuttingDown = false;
3476
+ if (cleanupFailures.length > 0) {
3477
+ terminallyDisposed = true;
3478
+ lifecycle = 'closed';
3479
+ }
3480
+ else {
3481
+ lifecycle = 'fresh';
3482
+ }
3483
+ return cleanupFailures;
3484
+ };
3485
+ const restoreShellSnapshot = async (initSession, untrusted) => {
3486
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
3487
+ throw new Error('Captain shell restore requires a fresh shell');
3488
+ }
3489
+ if (initSession.signal.aborted) {
3490
+ throw new Error('cannot restore an aborted Captain session');
3491
+ }
3492
+ lifecycle = 'restoring';
3493
+ try {
3494
+ const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
3495
+ const built = await buildEnablements(options, initSession.players, loadModule);
3496
+ for (const enablement of built.enablementById.values()) {
3497
+ enablement.entry.validateOptions(enablement.optionInput);
3498
+ }
3499
+ assertSnapshotMatchesEnablements(snapshot, built.enablementById);
3500
+ installSession(initSession, false);
2806
3501
  players = initSession.players;
2807
- const built = await buildEnablements(options, players, loadModule);
2808
3502
  entries = built.entries;
2809
3503
  byCommand = built.byCommand;
2810
3504
  byId = built.byId;
2811
3505
  enablementById = built.enablementById;
2812
- for (const enablement of enablementById.values()) {
2813
- enablement.entry.validateOptions(enablement.optionInput);
2814
- }
2815
- await setMode('chat', 'init');
2816
- // CAPTAIN-16: the session Captain exists from `init`, outside the
2817
- // engagement stack, with its own playbook session id.
2818
- const catalog = Object.freeze(entries.map((entry) => Object.freeze({
2819
- id: entry.id,
2820
- command: enablementById.get(entry.id).command,
2821
- intent: entry.intent,
2822
- })));
2823
- captainSessionId = allocateSessionId();
2824
3506
  captainRuntime = createCaptainRuntime({
2825
- enabledPlaybooks: catalog,
3507
+ enabledPlaybooks: enabledCatalog(),
2826
3508
  controller,
2827
3509
  });
2828
- await captainRuntime.init({
2829
- sessionId: captainSessionId,
2830
- playbookId: INTERNAL_CAPTAIN_ID,
2831
- rootSessionId: captainSessionId,
2832
- depth: 0,
2833
- ports: captainPorts(),
2834
- });
3510
+ if (typeof captainRuntime.restore !== 'function') {
3511
+ throw new Error('session Captain runtime does not support restore');
3512
+ }
3513
+ if (snapshot.mode === 'engaged.parked') {
3514
+ const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
3515
+ for (const [index, frameSnapshot] of snapshot.frames.entries()) {
3516
+ const parentFrame = frames.at(-1);
3517
+ const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, rootTokens, index === 0
3518
+ ? undefined
3519
+ : {
3520
+ frame: parentFrame,
3521
+ callId: frameSnapshot.parentCallId,
3522
+ });
3523
+ if (typeof frame.runtime.restore !== 'function') {
3524
+ throw new Error(`playbook ${frame.entry.id} runtime does not support restore`);
3525
+ }
3526
+ frames.push(frame);
3527
+ if (parentFrame)
3528
+ pendingChildParents.add(parentFrame);
3529
+ }
3530
+ }
3531
+ await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
3532
+ if (snapshot.mode === 'engaged.parked') {
3533
+ for (const [index, frame] of frames.entries()) {
3534
+ await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
3535
+ }
3536
+ }
3537
+ if (closedGateAttempted) {
3538
+ throw new Error('a runtime attempted a host emission during restore');
3539
+ }
3540
+ verifyRestoredRuntime(captainRuntime, snapshot.captain.runtime, INTERNAL_CAPTAIN_ID, false);
3541
+ if (snapshot.mode === 'engaged.parked') {
3542
+ for (const [index, frame] of frames.entries()) {
3543
+ verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
3544
+ }
3545
+ if (!isDeepStrictEqual(tokenRecord(rootFrame().playerResumeTokens), snapshot.rootPlayerResumeTokens)) {
3546
+ throw new Error('restored root-owned player continuation changed during restore');
3547
+ }
3548
+ }
3549
+ if (closedGateAttempted) {
3550
+ throw new Error('a runtime attempted a host emission during restore');
3551
+ }
3552
+ if (requireSession().signal.aborted) {
3553
+ throw new Error('Captain session aborted during restore');
3554
+ }
3555
+ for (const id of snapshot.issuedSessionIds)
3556
+ issuedSessionIds.add(id);
3557
+ journal.push(...snapshot.journal);
3558
+ journalSeq = snapshot.sequences.journal;
3559
+ turnSequence = snapshot.sequences.turn;
3560
+ conversation = snapshot.captain.conversation;
3561
+ captainSessionId = snapshot.captain.sessionId;
3562
+ lastAction = snapshot.lastAction;
3563
+ lastSettlementStatus = snapshot.lastSettlementStatus;
3564
+ mode = snapshot.mode;
3565
+ if (snapshot.mode === 'engaged.parked') {
3566
+ pendingBossQuestions = snapshot.pendingBossQuestions;
3567
+ lastError = snapshot.lastError;
3568
+ }
3569
+ lifecycle = 'ready';
3570
+ // The final commit is deliberately one non-throwing assignment.
3571
+ sessionEmissionsOpen = true;
3572
+ }
3573
+ catch (error) {
3574
+ const cleanupFailures = await resetFailedRestore();
3575
+ if (cleanupFailures.length > 0) {
3576
+ throw new AggregateError([error, ...cleanupFailures], 'Captain shell restore and cleanup failed');
3577
+ }
3578
+ throw error;
3579
+ }
3580
+ };
3581
+ return {
3582
+ async init(initSession) {
3583
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
3584
+ throw new Error('Captain shell requires a fresh instance for init');
3585
+ }
3586
+ if (initSession.signal.aborted) {
3587
+ throw new Error('cannot initialize an aborted Captain session');
3588
+ }
3589
+ lifecycle = 'initializing';
3590
+ try {
3591
+ installSession(initSession, true);
3592
+ players = initSession.players;
3593
+ const built = await buildEnablements(options, players, loadModule);
3594
+ entries = built.entries;
3595
+ byCommand = built.byCommand;
3596
+ byId = built.byId;
3597
+ enablementById = built.enablementById;
3598
+ for (const enablement of enablementById.values()) {
3599
+ enablement.entry.validateOptions(enablement.optionInput);
3600
+ }
3601
+ await setMode('chat', 'init');
3602
+ // CAPTAIN-16: the session Captain exists from `init`, outside the
3603
+ // engagement stack, with its own playbook session id.
3604
+ captainSessionId = allocateSessionId();
3605
+ captainRuntime = createCaptainRuntime({
3606
+ enabledPlaybooks: enabledCatalog(),
3607
+ controller,
3608
+ });
3609
+ await captainRuntime.init(captainPlaybookSession(captainSessionId));
3610
+ lifecycle = 'ready';
3611
+ }
3612
+ catch (error) {
3613
+ terminallyDisposed = true;
3614
+ lifecycle = 'closed';
3615
+ throw error;
3616
+ }
2835
3617
  },
3618
+ exportSnapshot: exportShellSnapshot,
3619
+ restore: restoreShellSnapshot,
2836
3620
  async handleBossTurn(turn, context) {
3621
+ if (lifecycle !== 'ready' || terminallyDisposed) {
3622
+ throw new Error('init must be called first, or restore must complete before handling a Boss turn');
3623
+ }
2837
3624
  requireSession();
2838
3625
  if (!captainRuntime) {
2839
3626
  throw new Error('init must be called first');
@@ -2926,10 +3713,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2926
3713
  }
2927
3714
  },
2928
3715
  async prepareDispose() {
3716
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
3717
+ throw new Error('cannot dispose while Captain shell setup is in progress');
3718
+ }
2929
3719
  activeContext = undefined;
2930
3720
  await teardown();
2931
3721
  },
2932
3722
  async dispose() {
3723
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
3724
+ throw new Error('cannot dispose while Captain shell setup is in progress');
3725
+ }
2933
3726
  activeContext = undefined;
2934
3727
  await teardown();
2935
3728
  },
@@ -2937,6 +3730,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2937
3730
  // CAPTAIN-16: dispose every active frame from leaf to root, then the
2938
3731
  // session Captain last.
2939
3732
  async function teardown() {
3733
+ terminallyDisposed = true;
3734
+ lifecycle = 'disposing';
2940
3735
  let failure;
2941
3736
  try {
2942
3737
  await disposeStack('dispose');
@@ -2955,6 +3750,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2955
3750
  failure ??= error;
2956
3751
  }
2957
3752
  }
3753
+ lifecycle = 'closed';
2958
3754
  if (failure !== undefined)
2959
3755
  throw failure;
2960
3756
  }