@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.
@@ -2,6 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { isDeepStrictEqual } from 'node:util';
5
6
  import PQueue from 'p-queue';
6
7
 
7
8
  import type {
@@ -20,12 +21,15 @@ import type {
20
21
  PlaybookPorts,
21
22
  PlaybookRunResult,
22
23
  PlaybookRuntime,
24
+ PlaybookRuntimeSnapshot,
23
25
  PlayerSessionStore,
24
26
  PlaybookState,
25
27
  } from '@sublang/playbook/runtime';
26
28
  import {
29
+ assertPlaybookRuntimeSnapshot,
27
30
  hiddenControlEnvelope,
28
31
  registerPlaybookAbortCleanup,
32
+ snapshotJsonValue,
29
33
  } from '../../../src/xstate-runtime.js';
30
34
  import createDefaultCaptainRuntime, {
31
35
  type CaptainControllerPort,
@@ -63,6 +67,86 @@ export interface PlaybookCaptainRegistryEntry {
63
67
  createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
64
68
  }
65
69
 
70
+ type PlaybookCaptainConversationSnapshot =
71
+ | { readonly kind: 'unopened' }
72
+ | { readonly kind: 'pinned'; readonly token: string }
73
+ | { readonly kind: 'needsSeeding' };
74
+
75
+ interface PlaybookCaptainJournalRecord {
76
+ readonly seq: number;
77
+ readonly turnId: number;
78
+ readonly kind: 'boss' | 'reply' | 'handoff' | 'action' | 'outcome';
79
+ readonly payload: JsonValue;
80
+ }
81
+
82
+ interface PlaybookCaptainFrameSnapshot {
83
+ readonly playbookId: string;
84
+ readonly sessionId: string;
85
+ readonly rootSessionId: string;
86
+ readonly depth: number;
87
+ readonly parentSessionId?: string;
88
+ readonly parentCallId?: string;
89
+ readonly runtime: PlaybookRuntimeSnapshot;
90
+ }
91
+
92
+ interface PlaybookCaptainShellSnapshotFields {
93
+ readonly schemaVersion: 1;
94
+ readonly captain: {
95
+ readonly sessionId: string;
96
+ readonly runtime: PlaybookRuntimeSnapshot;
97
+ readonly conversation: PlaybookCaptainConversationSnapshot;
98
+ };
99
+ /** Every Captain and engagement UUID issued during this logical session. */
100
+ readonly issuedSessionIds: readonly string[];
101
+ readonly sequences: {
102
+ readonly turn: number;
103
+ readonly journal: number;
104
+ };
105
+ readonly journal: readonly PlaybookCaptainJournalRecord[];
106
+ readonly lastAction?:
107
+ | 'respond'
108
+ | 'start'
109
+ | 'switch'
110
+ | 'dismiss'
111
+ | 'deliver'
112
+ | 'runtime';
113
+ readonly lastSettlementStatus?: 'ok' | 'rejected' | 'failed';
114
+ }
115
+
116
+ /**
117
+ * Complete JSON-safe durable state for one Captain shell between Boss turns.
118
+ * The discriminated mode keeps chat snapshots free of stale engagement data.
119
+ */
120
+ export type PlaybookCaptainShellSnapshot =
121
+ PlaybookCaptainShellSnapshotFields &
122
+ (
123
+ | {
124
+ readonly mode: 'chat';
125
+ readonly frames?: never;
126
+ readonly rootPlayerResumeTokens?: never;
127
+ readonly pendingBossQuestions?: never;
128
+ readonly lastError?: never;
129
+ }
130
+ | {
131
+ readonly mode: 'engaged.parked';
132
+ /** Root-to-leaf engagement order. */
133
+ readonly frames: readonly PlaybookCaptainFrameSnapshot[];
134
+ /** Root-owned continuation, keyed by effective host-player id. */
135
+ readonly rootPlayerResumeTokens: Readonly<Record<string, string>>;
136
+ readonly pendingBossQuestions?: JsonValue;
137
+ readonly lastError?: { readonly name: string; readonly message: string };
138
+ }
139
+ );
140
+
141
+ /** tmux and headless front ends share this one durable Captain shell API. */
142
+ export interface PlaybookCaptainShell extends Captain {
143
+ exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
144
+ restore(
145
+ session: CaptainSession,
146
+ snapshot: PlaybookCaptainShellSnapshot,
147
+ ): Promise<void>;
148
+ }
149
+
66
150
  // Per-enabled-playbook binding the shell resolves at init from
67
151
  // `captain.options.playbooks`: each playbook binds its local roles to
68
152
  // `<id>-<role>` host players and carries the generated visible set.
@@ -765,6 +849,467 @@ function isValidRegistryEntry(
765
849
  );
766
850
  }
767
851
 
852
+ const SNAPSHOT_ACTIONS = new Set([
853
+ 'respond',
854
+ 'start',
855
+ 'switch',
856
+ 'dismiss',
857
+ 'deliver',
858
+ 'runtime',
859
+ ] as const);
860
+ const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
861
+ 'ok',
862
+ 'rejected',
863
+ 'failed',
864
+ ] as const);
865
+ const SNAPSHOT_JOURNAL_KINDS = new Set([
866
+ 'boss',
867
+ 'reply',
868
+ 'handoff',
869
+ 'action',
870
+ 'outcome',
871
+ ] as const);
872
+
873
+ function snapshotRecord(
874
+ value: JsonValue | undefined,
875
+ path: string,
876
+ ): Record<string, JsonValue> {
877
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
878
+ throw new TypeError(`${path} must be an object`);
879
+ }
880
+ return value as Record<string, JsonValue>;
881
+ }
882
+
883
+ function rejectSnapshotKeys(
884
+ value: Readonly<Record<string, JsonValue>>,
885
+ allowed: readonly string[],
886
+ path: string,
887
+ ): void {
888
+ const allowedKeys = new Set(allowed);
889
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
890
+ if (unknown.length > 0) {
891
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
892
+ }
893
+ }
894
+
895
+ function snapshotString(
896
+ value: JsonValue | undefined,
897
+ path: string,
898
+ allowEmpty = false,
899
+ ): string {
900
+ if (
901
+ typeof value !== 'string' ||
902
+ (!allowEmpty && value.trim().length === 0)
903
+ ) {
904
+ throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
905
+ }
906
+ return value;
907
+ }
908
+
909
+ function snapshotInteger(
910
+ value: JsonValue | undefined,
911
+ path: string,
912
+ minimum = 0,
913
+ ): number {
914
+ if (!Number.isSafeInteger(value) || (value as number) < minimum) {
915
+ throw new TypeError(`${path} must be an integer >= ${minimum}`);
916
+ }
917
+ return value as number;
918
+ }
919
+
920
+ function snapshotUuid(value: JsonValue | undefined, path: string): string {
921
+ const id = snapshotString(value, path);
922
+ if (!UUID_PATTERN.test(id)) {
923
+ throw new TypeError(`${path} must be a UUID`);
924
+ }
925
+ return id;
926
+ }
927
+
928
+ /** Validate, detach, and freeze one untrusted shell snapshot. */
929
+ function assertPlaybookCaptainShellSnapshot(
930
+ value: unknown,
931
+ ): PlaybookCaptainShellSnapshot {
932
+ const detached = snapshotJsonValue(value, 'Captain shell snapshot');
933
+ const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
934
+ const mode = snapshot.mode;
935
+ const commonKeys = [
936
+ 'schemaVersion',
937
+ 'captain',
938
+ 'issuedSessionIds',
939
+ 'sequences',
940
+ 'journal',
941
+ 'lastAction',
942
+ 'lastSettlementStatus',
943
+ 'mode',
944
+ ];
945
+ if (mode === 'chat') {
946
+ rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
947
+ } else if (mode === 'engaged.parked') {
948
+ rejectSnapshotKeys(
949
+ snapshot,
950
+ [
951
+ ...commonKeys,
952
+ 'frames',
953
+ 'rootPlayerResumeTokens',
954
+ 'pendingBossQuestions',
955
+ 'lastError',
956
+ ],
957
+ 'Captain shell snapshot',
958
+ );
959
+ } else {
960
+ throw new TypeError(
961
+ 'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
962
+ );
963
+ }
964
+ if (snapshot.schemaVersion !== 1) {
965
+ throw new TypeError(
966
+ `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`,
967
+ );
968
+ }
969
+
970
+ const captain = snapshotRecord(
971
+ snapshot.captain,
972
+ 'Captain shell snapshot.captain',
973
+ );
974
+ rejectSnapshotKeys(
975
+ captain,
976
+ ['sessionId', 'runtime', 'conversation'],
977
+ 'Captain shell snapshot.captain',
978
+ );
979
+ const captainSessionId = snapshotUuid(
980
+ captain.sessionId,
981
+ 'Captain shell snapshot.captain.sessionId',
982
+ );
983
+ const captainRuntime = assertPlaybookRuntimeSnapshot(
984
+ captain.runtime,
985
+ INTERNAL_CAPTAIN_ID,
986
+ );
987
+ const conversation = snapshotRecord(
988
+ captain.conversation,
989
+ 'Captain shell snapshot.captain.conversation',
990
+ );
991
+ let normalizedConversation: PlaybookCaptainConversationSnapshot;
992
+ if (conversation.kind === 'pinned') {
993
+ rejectSnapshotKeys(
994
+ conversation,
995
+ ['kind', 'token'],
996
+ 'Captain shell snapshot.captain.conversation',
997
+ );
998
+ normalizedConversation = {
999
+ kind: 'pinned',
1000
+ token: snapshotString(
1001
+ conversation.token,
1002
+ 'Captain shell snapshot.captain.conversation.token',
1003
+ true,
1004
+ ),
1005
+ };
1006
+ } else if (
1007
+ conversation.kind === 'unopened' ||
1008
+ conversation.kind === 'needsSeeding'
1009
+ ) {
1010
+ rejectSnapshotKeys(
1011
+ conversation,
1012
+ ['kind'],
1013
+ 'Captain shell snapshot.captain.conversation',
1014
+ );
1015
+ normalizedConversation = { kind: conversation.kind };
1016
+ } else {
1017
+ throw new TypeError(
1018
+ 'Captain shell snapshot.captain.conversation.kind is not supported',
1019
+ );
1020
+ }
1021
+
1022
+ if (!Array.isArray(snapshot.issuedSessionIds)) {
1023
+ throw new TypeError(
1024
+ 'Captain shell snapshot.issuedSessionIds must be an array',
1025
+ );
1026
+ }
1027
+ const issued = snapshot.issuedSessionIds.map((id, index) =>
1028
+ snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`),
1029
+ );
1030
+ if (new Set(issued).size !== issued.length) {
1031
+ throw new TypeError(
1032
+ 'Captain shell snapshot.issuedSessionIds must not contain duplicates',
1033
+ );
1034
+ }
1035
+ if (issued[0] !== captainSessionId) {
1036
+ throw new TypeError(
1037
+ 'Captain shell snapshot Captain session id must be the first issued id',
1038
+ );
1039
+ }
1040
+
1041
+ const sequences = snapshotRecord(
1042
+ snapshot.sequences,
1043
+ 'Captain shell snapshot.sequences',
1044
+ );
1045
+ rejectSnapshotKeys(
1046
+ sequences,
1047
+ ['turn', 'journal'],
1048
+ 'Captain shell snapshot.sequences',
1049
+ );
1050
+ const turnSequence = snapshotInteger(
1051
+ sequences.turn,
1052
+ 'Captain shell snapshot.sequences.turn',
1053
+ );
1054
+ const journalSequence = snapshotInteger(
1055
+ sequences.journal,
1056
+ 'Captain shell snapshot.sequences.journal',
1057
+ );
1058
+
1059
+ if (!Array.isArray(snapshot.journal)) {
1060
+ throw new TypeError('Captain shell snapshot.journal must be an array');
1061
+ }
1062
+ const normalizedJournal: PlaybookCaptainJournalRecord[] = [];
1063
+ let previousTurn = 0;
1064
+ let bossRecords = 0;
1065
+ for (const [index, value] of snapshot.journal.entries()) {
1066
+ const record = snapshotRecord(
1067
+ value,
1068
+ `Captain shell snapshot.journal[${index}]`,
1069
+ );
1070
+ rejectSnapshotKeys(
1071
+ record,
1072
+ ['seq', 'turnId', 'kind', 'payload'],
1073
+ `Captain shell snapshot.journal[${index}]`,
1074
+ );
1075
+ const seq = snapshotInteger(
1076
+ record.seq,
1077
+ `Captain shell snapshot.journal[${index}].seq`,
1078
+ 1,
1079
+ );
1080
+ if (seq !== index + 1) {
1081
+ throw new TypeError(
1082
+ 'Captain shell snapshot journal sequence must be contiguous from one',
1083
+ );
1084
+ }
1085
+ const turnId = snapshotInteger(
1086
+ record.turnId,
1087
+ `Captain shell snapshot.journal[${index}].turnId`,
1088
+ 1,
1089
+ );
1090
+ if (turnId < previousTurn || turnId > turnSequence) {
1091
+ throw new TypeError(
1092
+ 'Captain shell snapshot journal turn ids must be ordered and in range',
1093
+ );
1094
+ }
1095
+ const kind = record.kind;
1096
+ if (
1097
+ typeof kind !== 'string' ||
1098
+ !SNAPSHOT_JOURNAL_KINDS.has(
1099
+ kind as PlaybookCaptainJournalRecord['kind'],
1100
+ )
1101
+ ) {
1102
+ throw new TypeError(
1103
+ `Captain shell snapshot.journal[${index}].kind is not supported`,
1104
+ );
1105
+ }
1106
+ if (turnId !== previousTurn) {
1107
+ if (turnId !== previousTurn + 1 || kind !== 'boss') {
1108
+ throw new TypeError(
1109
+ 'Captain shell snapshot journal must begin every turn with one boss record',
1110
+ );
1111
+ }
1112
+ bossRecords++;
1113
+ previousTurn = turnId;
1114
+ } else if (kind === 'boss') {
1115
+ throw new TypeError(
1116
+ 'Captain shell snapshot journal must contain one boss record per turn',
1117
+ );
1118
+ }
1119
+ normalizedJournal.push({
1120
+ seq,
1121
+ turnId,
1122
+ kind: kind as PlaybookCaptainJournalRecord['kind'],
1123
+ payload: record.payload as JsonValue,
1124
+ });
1125
+ }
1126
+ if (
1127
+ journalSequence !== normalizedJournal.length ||
1128
+ bossRecords !== turnSequence
1129
+ ) {
1130
+ throw new TypeError(
1131
+ 'Captain shell snapshot sequences do not match the complete journal',
1132
+ );
1133
+ }
1134
+
1135
+ let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
1136
+ if (snapshot.lastAction !== undefined) {
1137
+ if (
1138
+ typeof snapshot.lastAction !== 'string' ||
1139
+ !SNAPSHOT_ACTIONS.has(
1140
+ snapshot.lastAction as NonNullable<typeof lastAction>,
1141
+ )
1142
+ ) {
1143
+ throw new TypeError('Captain shell snapshot.lastAction is not supported');
1144
+ }
1145
+ lastAction = snapshot.lastAction as NonNullable<typeof lastAction>;
1146
+ }
1147
+ let lastSettlementStatus: PlaybookCaptainShellSnapshotFields['lastSettlementStatus'];
1148
+ if (snapshot.lastSettlementStatus !== undefined) {
1149
+ if (
1150
+ typeof snapshot.lastSettlementStatus !== 'string' ||
1151
+ !SNAPSHOT_SETTLEMENT_STATUSES.has(
1152
+ snapshot.lastSettlementStatus as NonNullable<
1153
+ typeof lastSettlementStatus
1154
+ >,
1155
+ )
1156
+ ) {
1157
+ throw new TypeError(
1158
+ 'Captain shell snapshot.lastSettlementStatus is not supported',
1159
+ );
1160
+ }
1161
+ lastSettlementStatus = snapshot.lastSettlementStatus as NonNullable<
1162
+ typeof lastSettlementStatus
1163
+ >;
1164
+ }
1165
+ const common: PlaybookCaptainShellSnapshotFields = {
1166
+ schemaVersion: 1,
1167
+ captain: {
1168
+ sessionId: captainSessionId,
1169
+ runtime: captainRuntime,
1170
+ conversation: normalizedConversation,
1171
+ },
1172
+ issuedSessionIds: issued,
1173
+ sequences: { turn: turnSequence, journal: journalSequence },
1174
+ journal: normalizedJournal,
1175
+ ...(lastAction === undefined ? {} : { lastAction }),
1176
+ ...(lastSettlementStatus === undefined
1177
+ ? {}
1178
+ : { lastSettlementStatus }),
1179
+ };
1180
+ if (mode === 'chat') {
1181
+ return snapshotJsonValue(
1182
+ { ...common, mode },
1183
+ 'Captain shell snapshot',
1184
+ ) as unknown as PlaybookCaptainShellSnapshot;
1185
+ }
1186
+
1187
+ if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
1188
+ throw new TypeError(
1189
+ 'Captain shell snapshot.frames must be a non-empty array',
1190
+ );
1191
+ }
1192
+ const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
1193
+ for (const [index, value] of snapshot.frames.entries()) {
1194
+ const frame = snapshotRecord(
1195
+ value,
1196
+ `Captain shell snapshot.frames[${index}]`,
1197
+ );
1198
+ rejectSnapshotKeys(
1199
+ frame,
1200
+ [
1201
+ 'playbookId',
1202
+ 'sessionId',
1203
+ 'rootSessionId',
1204
+ 'depth',
1205
+ 'parentSessionId',
1206
+ 'parentCallId',
1207
+ 'runtime',
1208
+ ],
1209
+ `Captain shell snapshot.frames[${index}]`,
1210
+ );
1211
+ const playbookId = snapshotString(
1212
+ frame.playbookId,
1213
+ `Captain shell snapshot.frames[${index}].playbookId`,
1214
+ );
1215
+ const sessionId = snapshotUuid(
1216
+ frame.sessionId,
1217
+ `Captain shell snapshot.frames[${index}].sessionId`,
1218
+ );
1219
+ const rootSessionId = snapshotUuid(
1220
+ frame.rootSessionId,
1221
+ `Captain shell snapshot.frames[${index}].rootSessionId`,
1222
+ );
1223
+ const depth = snapshotInteger(
1224
+ frame.depth,
1225
+ `Captain shell snapshot.frames[${index}].depth`,
1226
+ );
1227
+ const parentSessionId =
1228
+ frame.parentSessionId === undefined
1229
+ ? undefined
1230
+ : snapshotUuid(
1231
+ frame.parentSessionId,
1232
+ `Captain shell snapshot.frames[${index}].parentSessionId`,
1233
+ );
1234
+ const parentCallId =
1235
+ frame.parentCallId === undefined
1236
+ ? undefined
1237
+ : snapshotString(
1238
+ frame.parentCallId,
1239
+ `Captain shell snapshot.frames[${index}].parentCallId`,
1240
+ );
1241
+ const runtime = assertPlaybookRuntimeSnapshot(
1242
+ frame.runtime,
1243
+ playbookId,
1244
+ { allowSuspendedCall: true },
1245
+ );
1246
+ normalizedFrames.push({
1247
+ playbookId,
1248
+ sessionId,
1249
+ rootSessionId,
1250
+ depth,
1251
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
1252
+ ...(parentCallId === undefined ? {} : { parentCallId }),
1253
+ runtime,
1254
+ });
1255
+ }
1256
+
1257
+ const rootTokens = snapshotRecord(
1258
+ snapshot.rootPlayerResumeTokens,
1259
+ 'Captain shell snapshot.rootPlayerResumeTokens',
1260
+ );
1261
+ const normalizedRootTokens = Object.fromEntries(
1262
+ Object.entries(rootTokens).map(([playerId, token]) => [
1263
+ playerId,
1264
+ snapshotString(
1265
+ token,
1266
+ `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`,
1267
+ ),
1268
+ ]),
1269
+ );
1270
+ let normalizedLastError:
1271
+ | { readonly name: string; readonly message: string }
1272
+ | undefined;
1273
+ if (snapshot.lastError !== undefined) {
1274
+ const error = snapshotRecord(
1275
+ snapshot.lastError,
1276
+ 'Captain shell snapshot.lastError',
1277
+ );
1278
+ rejectSnapshotKeys(
1279
+ error,
1280
+ ['name', 'message'],
1281
+ 'Captain shell snapshot.lastError',
1282
+ );
1283
+ normalizedLastError = {
1284
+ name: snapshotString(
1285
+ error.name,
1286
+ 'Captain shell snapshot.lastError.name',
1287
+ true,
1288
+ ),
1289
+ message: snapshotString(
1290
+ error.message,
1291
+ 'Captain shell snapshot.lastError.message',
1292
+ true,
1293
+ ),
1294
+ };
1295
+ }
1296
+ return snapshotJsonValue(
1297
+ {
1298
+ ...common,
1299
+ mode,
1300
+ frames: normalizedFrames,
1301
+ rootPlayerResumeTokens: normalizedRootTokens,
1302
+ ...(snapshot.pendingBossQuestions === undefined
1303
+ ? {}
1304
+ : { pendingBossQuestions: snapshot.pendingBossQuestions }),
1305
+ ...(normalizedLastError === undefined
1306
+ ? {}
1307
+ : { lastError: normalizedLastError }),
1308
+ },
1309
+ 'Captain shell snapshot',
1310
+ ) as unknown as PlaybookCaptainShellSnapshot;
1311
+ }
1312
+
768
1313
  function readPlaybooksConfig(
769
1314
  options: unknown,
770
1315
  ): Record<string, unknown> | undefined {
@@ -889,7 +1434,7 @@ async function buildEnablements(
889
1434
  export function createPlaybookCaptainShell(
890
1435
  options: unknown,
891
1436
  deps: PlaybookCaptainDeps = {},
892
- ): Captain {
1437
+ ): PlaybookCaptainShell {
893
1438
  const loadModule =
894
1439
  deps.loadModule ?? ((specifier: string) => import(specifier));
895
1440
  const createSessionId = deps.createSessionId ?? randomUUID;
@@ -905,6 +1450,16 @@ export function createPlaybookCaptainShell(
905
1450
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
906
1451
  let enablementById = new Map<string, Enablement>();
907
1452
  let session: CaptainSession | undefined;
1453
+ let sessionEmissionsOpen = false;
1454
+ let closedGateAttempted = false;
1455
+ let lifecycle:
1456
+ | 'fresh'
1457
+ | 'initializing'
1458
+ | 'restoring'
1459
+ | 'ready'
1460
+ | 'disposing'
1461
+ | 'closed' = 'fresh';
1462
+ let terminallyDisposed = false;
908
1463
  let players: readonly RegistryPlayer[] = [];
909
1464
  let activeContext: CaptainContext | undefined;
910
1465
  const frames: EngagementFrame[] = [];
@@ -918,6 +1473,42 @@ export function createPlaybookCaptainShell(
918
1473
  const captainQueue = new PQueue({ concurrency: 1 });
919
1474
  let disposing = false;
920
1475
 
1476
+ const admitHostBoundary = (): void => {
1477
+ if (sessionEmissionsOpen) return;
1478
+ closedGateAttempted = true;
1479
+ throw new Error('Captain shell host boundaries are closed during restore');
1480
+ };
1481
+
1482
+ const admitHostEmission = (): boolean => {
1483
+ if (sessionEmissionsOpen) return true;
1484
+ closedGateAttempted = true;
1485
+ return false;
1486
+ };
1487
+
1488
+ const installSession = (
1489
+ initSession: CaptainSession,
1490
+ emissionsOpen: boolean,
1491
+ ): void => {
1492
+ sessionEmissionsOpen = emissionsOpen;
1493
+ closedGateAttempted = false;
1494
+ session = {
1495
+ signal: initSession.signal,
1496
+ players: initSession.players,
1497
+ emitStatus: async (message, data) => {
1498
+ if (!admitHostEmission()) return;
1499
+ await initSession.emitStatus(message, data);
1500
+ },
1501
+ emitTelemetry: async (event) => {
1502
+ if (!admitHostEmission()) return;
1503
+ await initSession.emitTelemetry(event);
1504
+ },
1505
+ setVisiblePlayers: async (playerIds) => {
1506
+ if (!admitHostEmission()) return;
1507
+ await initSession.setVisiblePlayers(playerIds);
1508
+ },
1509
+ };
1510
+ };
1511
+
921
1512
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
922
1513
  let captainRuntime: PlaybookRuntime | undefined;
923
1514
  let captainSessionId: string | undefined;
@@ -1236,6 +1827,7 @@ export function createPlaybookCaptainShell(
1236
1827
 
1237
1828
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
1238
1829
  callPlayer: async (playerId, prompt, signal, options) => {
1830
+ admitHostBoundary();
1239
1831
  if (!activeContext) {
1240
1832
  throw new Error('callPlayer invoked outside a Boss turn');
1241
1833
  }
@@ -1272,6 +1864,7 @@ export function createPlaybookCaptainShell(
1272
1864
  };
1273
1865
  },
1274
1866
  callCaptain: async (prompt, signal, options) => {
1867
+ admitHostBoundary();
1275
1868
  if (!activeContext) {
1276
1869
  throw new Error('callCaptain invoked outside a Boss turn');
1277
1870
  }
@@ -1295,6 +1888,7 @@ export function createPlaybookCaptainShell(
1295
1888
  };
1296
1889
  },
1297
1890
  callJudge: async (prompt, signal) => {
1891
+ admitHostBoundary();
1298
1892
  if (!activeContext) {
1299
1893
  throw new Error('callJudge invoked outside a Boss turn');
1300
1894
  }
@@ -1330,6 +1924,7 @@ export function createPlaybookCaptainShell(
1330
1924
  return result.finalText;
1331
1925
  },
1332
1926
  callPlaybook: (request, signal) => {
1927
+ admitHostBoundary();
1333
1928
  const opening = callNestedPlaybook(frame, request, signal);
1334
1929
  let exposed!: Promise<PlaybookCallStart>;
1335
1930
  const registerOpeningCleanup = (): void => {
@@ -1342,17 +1937,27 @@ export function createPlaybookCaptainShell(
1342
1937
  if (signal.aborted) registerOpeningCleanup();
1343
1938
  return exposed;
1344
1939
  },
1345
- emitStatus: async (message, data) => {
1346
- await requireSession().emitStatus(
1347
- message,
1348
- data as Record<string, unknown> | undefined,
1940
+ emitStatus: (message, data) => {
1941
+ if (!admitHostEmission()) return Promise.resolve();
1942
+ return trackHostCall(
1943
+ frame,
1944
+ (async (): Promise<void> => {
1945
+ await requireSession().emitStatus(
1946
+ message,
1947
+ data as Record<string, unknown> | undefined,
1948
+ );
1949
+ })(),
1349
1950
  );
1350
1951
  },
1351
- emitTelemetry: async (event) => {
1352
- if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1353
- await mirrorSubRuntimeTelemetry(frame, event.payload);
1354
- }
1355
- await requireSession().emitTelemetry(event);
1952
+ emitTelemetry: (event) => {
1953
+ if (!admitHostEmission()) return Promise.resolve();
1954
+ const emission = (async (): Promise<void> => {
1955
+ if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1956
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
1957
+ }
1958
+ await requireSession().emitTelemetry(event);
1959
+ })();
1960
+ return trackHostCall(frame, emission);
1356
1961
  },
1357
1962
  });
1358
1963
 
@@ -1402,12 +2007,11 @@ export function createPlaybookCaptainShell(
1402
2007
  return typeof stack === 'string' ? { ...compact, stack } : compact;
1403
2008
  };
1404
2009
 
1405
- const makeFrame = (
2010
+ const makePlayerBindings = (
1406
2011
  enablement: Enablement,
1407
2012
  parent?: { frame: EngagementFrame; callId: string },
1408
- ): EngagementFrame => {
2013
+ ): ReadonlyMap<string, EffectivePlayerBinding> => {
1409
2014
  const entry = enablement.entry;
1410
- const sessionId = allocateSessionId();
1411
2015
  const playerBindings = new Map<string, EffectivePlayerBinding>();
1412
2016
  for (const role of entry.requiredRoleIds) {
1413
2017
  let inherited: EffectivePlayerBinding | undefined;
@@ -1430,6 +2034,16 @@ export function createPlaybookCaptainShell(
1430
2034
  player: configured,
1431
2035
  });
1432
2036
  }
2037
+ return playerBindings;
2038
+ };
2039
+
2040
+ const makeFrame = (
2041
+ enablement: Enablement,
2042
+ parent?: { frame: EngagementFrame; callId: string },
2043
+ ): EngagementFrame => {
2044
+ const entry = enablement.entry;
2045
+ const sessionId = allocateSessionId();
2046
+ const playerBindings = makePlayerBindings(enablement, parent);
1433
2047
  const playerResumeTokens =
1434
2048
  parent?.frame.playerResumeTokens ?? new Map<string, string>();
1435
2049
  const runtime = entry.createRuntime({
@@ -1454,6 +2068,37 @@ export function createPlaybookCaptainShell(
1454
2068
  };
1455
2069
  };
1456
2070
 
2071
+ const makeRestoredFrame = (
2072
+ enablement: Enablement,
2073
+ snapshot: PlaybookCaptainFrameSnapshot,
2074
+ rootPlayerResumeTokens: Map<string, string>,
2075
+ parent?: { frame: EngagementFrame; callId: string },
2076
+ ): EngagementFrame => {
2077
+ const entry = enablement.entry;
2078
+ const playerBindings = makePlayerBindings(enablement, parent);
2079
+ const runtime = entry.createRuntime({
2080
+ captainOptions: enablement.optionInput,
2081
+ players: [...playerBindings].map(([role, { player }]) => ({
2082
+ id: role,
2083
+ ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
2084
+ ...(player.model === undefined ? {} : { model: player.model }),
2085
+ })),
2086
+ });
2087
+ return {
2088
+ entry,
2089
+ enablement,
2090
+ runtime,
2091
+ sessionId: snapshot.sessionId,
2092
+ rootSessionId: snapshot.rootSessionId,
2093
+ depth: snapshot.depth,
2094
+ playerBindings,
2095
+ playerResumeTokens: rootPlayerResumeTokens,
2096
+ ...(parent ? { parent } : {}),
2097
+ state: snapshot.runtime.state,
2098
+ inFlightHostCalls: new Set(),
2099
+ };
2100
+ };
2101
+
1457
2102
  const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
1458
2103
  select(playerId) {
1459
2104
  const binding = bindingFor(frame, playerId);
@@ -1486,8 +2131,7 @@ export function createPlaybookCaptainShell(
1486
2131
  },
1487
2132
  });
1488
2133
 
1489
- const initFrame = async (frame: EngagementFrame): Promise<void> => {
1490
- await frame.runtime.init({
2134
+ const frameSession = (frame: EngagementFrame) => ({
1491
2135
  sessionId: frame.sessionId,
1492
2136
  playbookId: frame.entry.id,
1493
2137
  rootSessionId: frame.rootSessionId,
@@ -1501,6 +2145,9 @@ export function createPlaybookCaptainShell(
1501
2145
  playerSessions: playerSessionStore(frame),
1502
2146
  ports: createPorts(frame),
1503
2147
  });
2148
+
2149
+ const initFrame = async (frame: EngagementFrame): Promise<void> => {
2150
+ await frame.runtime.init(frameSession(frame));
1504
2151
  };
1505
2152
 
1506
2153
  const clearLeafLedger = (): void => {
@@ -1920,6 +2567,12 @@ export function createPlaybookCaptainShell(
1920
2567
  if (frame.parent) {
1921
2568
  await resumeParent(frame, callResultFor(frame, result), context);
1922
2569
  } else {
2570
+ // CAPTAIN-20: the root is still alive here, so this is the one
2571
+ // authoritative boundary that can retain the Boss-facing meaning its
2572
+ // runtime publishes before disposal removes the frame. The opaque run
2573
+ // output remains runtime-to-runtime data and never becomes Captain
2574
+ // evidence (CAPPLAY-10).
2575
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame));
1923
2576
  await runEffect(() => disposeStack('final'));
1924
2577
  }
1925
2578
  return;
@@ -2751,9 +3404,11 @@ export function createPlaybookCaptainShell(
2751
3404
 
2752
3405
  const captainPorts = (): PlaybookPorts => ({
2753
3406
  callPlayer: async () => {
3407
+ admitHostBoundary();
2754
3408
  throw new Error('the session Captain has no players');
2755
3409
  },
2756
3410
  callCaptain: async (prompt, signal) => {
3411
+ admitHostBoundary();
2757
3412
  if (!activeContext) {
2758
3413
  throw new Error('the session Captain called out of a Boss turn');
2759
3414
  }
@@ -2821,29 +3476,37 @@ export function createPlaybookCaptainShell(
2821
3476
  return { status: 'ok' as const, finalText: 'ok' };
2822
3477
  },
2823
3478
  callJudge: async () => {
3479
+ admitHostBoundary();
2824
3480
  throw new Error('the session Captain makes no judge call');
2825
3481
  },
2826
3482
  callPlaybook: async () => {
3483
+ admitHostBoundary();
2827
3484
  throw new Error('the session Captain never calls a playbook');
2828
3485
  },
2829
3486
  // CAPTAIN-9: the session Captain's human status stream is suppressed while
2830
3487
  // its structured telemetry is forwarded.
2831
- emitStatus: async () => {},
2832
- emitTelemetry: async (event) => {
2833
- if (event.topic === 'playbook.trace') {
2834
- const payload = payloadRecord(event.payload);
2835
- if (payload?.type === 'captain.call.started') {
2836
- const identity = payloadRecord(payload.payload);
2837
- const stateId = identity?.stateId;
2838
- servingCall =
2839
- stateId === 'reporting'
2840
- ? 'closingReply'
2841
- : stateId === 'answeringCommand'
2842
- ? 'commandReply'
2843
- : 'decision';
3488
+ emitStatus: async () => {
3489
+ admitHostEmission();
3490
+ },
3491
+ emitTelemetry: (event) => {
3492
+ if (!admitHostEmission()) return Promise.resolve();
3493
+ const emission = (async (): Promise<void> => {
3494
+ if (event.topic === 'playbook.trace') {
3495
+ const payload = payloadRecord(event.payload);
3496
+ if (payload?.type === 'captain.call.started') {
3497
+ const identity = payloadRecord(payload.payload);
3498
+ const stateId = identity?.stateId;
3499
+ servingCall =
3500
+ stateId === 'reporting'
3501
+ ? 'closingReply'
3502
+ : stateId === 'answeringCommand'
3503
+ ? 'commandReply'
3504
+ : 'decision';
3505
+ }
2844
3506
  }
2845
- }
2846
- await requireSession().emitTelemetry(event);
3507
+ await requireSession().emitTelemetry(event);
3508
+ })();
3509
+ return trackTurnCall(emission);
2847
3510
  },
2848
3511
  });
2849
3512
 
@@ -2922,6 +3585,15 @@ export function createPlaybookCaptainShell(
2922
3585
  }
2923
3586
  };
2924
3587
 
3588
+ const rootCompletionFact = (frame: EngagementFrame): string => {
3589
+ const published = leafStateDescription(frame);
3590
+ const description =
3591
+ published === undefined ? '' : compactEvidence(published);
3592
+ return description === ''
3593
+ ? `${frameLabel(frame)} completed; its runtime published no result description.`
3594
+ : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
3595
+ };
3596
+
2925
3597
  const leafStateSummary = (): string | undefined => {
2926
3598
  const leaf = leafFrame();
2927
3599
  if (!leaf) return 'idle: no playbook is engaged';
@@ -3404,9 +4076,6 @@ export function createPlaybookCaptainShell(
3404
4076
  };
3405
4077
  throw outcome.error;
3406
4078
  }
3407
- if (!frames.includes(leaf)) {
3408
- facts.push(`${frameLabel(leaf)} finished and was disposed.`);
3409
- }
3410
4079
  const runFailed = drainRunFailureFacts(facts);
3411
4080
  const summary = leafStateSummary();
3412
4081
  turn.report = {
@@ -3581,9 +4250,14 @@ export function createPlaybookCaptainShell(
3581
4250
  };
3582
4251
 
3583
4252
  const controller: CaptainControllerPort = {
3584
- submit: (selection, signal) => settleSelection(selection, signal),
3585
- resolveParsedTurn: () =>
3586
- shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution,
4253
+ submit: (selection, signal) => {
4254
+ admitHostBoundary();
4255
+ return settleSelection(selection, signal);
4256
+ },
4257
+ resolveParsedTurn: () => {
4258
+ admitHostBoundary();
4259
+ return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
4260
+ },
3587
4261
  };
3588
4262
 
3589
4263
  // -------------------------------------------------------------------------
@@ -3661,48 +4335,597 @@ export function createPlaybookCaptainShell(
3661
4335
  });
3662
4336
  };
3663
4337
 
3664
- return {
3665
- async init(initSession: CaptainSession): Promise<void> {
3666
- session = initSession;
4338
+ const enabledCatalog = () =>
4339
+ Object.freeze(
4340
+ entries.map((entry) =>
4341
+ Object.freeze({
4342
+ id: entry.id,
4343
+ command: enablementById.get(entry.id)!.command,
4344
+ intent: entry.intent,
4345
+ }),
4346
+ ),
4347
+ );
4348
+
4349
+ const captainPlaybookSession = (
4350
+ id: string,
4351
+ ) => ({
4352
+ sessionId: id,
4353
+ playbookId: INTERNAL_CAPTAIN_ID,
4354
+ rootSessionId: id,
4355
+ depth: 0,
4356
+ ports: captainPorts(),
4357
+ });
4358
+
4359
+ const tokenRecord = (
4360
+ tokens: ReadonlyMap<string, string>,
4361
+ ): Readonly<Record<string, string>> => Object.fromEntries(tokens);
4362
+
4363
+ const assertSnapshotMatchesEnablements = (
4364
+ snapshot: PlaybookCaptainShellSnapshot,
4365
+ enabled: ReadonlyMap<string, Enablement>,
4366
+ ): void => {
4367
+ const captain = snapshot.captain.runtime;
4368
+ if (
4369
+ captain.schemaVersion !== 2 ||
4370
+ captain.state.status !== 'active' ||
4371
+ !captain.state.quiescent ||
4372
+ !captain.state.tags.includes('playbook.parked') ||
4373
+ captain.suspendedCall !== undefined ||
4374
+ Object.keys(captain.playerResumeTokens).length > 0 ||
4375
+ captain.pendingBossQuestions.length > 0
4376
+ ) {
4377
+ throw new TypeError(
4378
+ 'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
4379
+ );
4380
+ }
4381
+ if (captain.sequences.turn !== snapshot.sequences.turn) {
4382
+ throw new TypeError(
4383
+ 'Captain shell snapshot Captain and shell turn sequences must match',
4384
+ );
4385
+ }
4386
+ const emptyHistory =
4387
+ snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
4388
+ if (
4389
+ (snapshot.captain.conversation.kind === 'unopened') !== emptyHistory
4390
+ ) {
4391
+ throw new TypeError(
4392
+ 'Captain shell snapshot unopened conversation must exactly match an empty session history',
4393
+ );
4394
+ }
4395
+ if (snapshot.mode === 'chat') return;
4396
+
4397
+ const activePlaybooks = new Set<string>();
4398
+ const activeSessionIds = new Set<string>([snapshot.captain.sessionId]);
4399
+ const issuedIds = new Set(snapshot.issuedSessionIds);
4400
+ const allowedHostPlayerIds = new Set<string>();
4401
+ for (const enablement of enabled.values()) {
4402
+ for (const role of enablement.entry.requiredRoleIds) {
4403
+ allowedHostPlayerIds.add(enablement.hostPlayerId(role));
4404
+ }
4405
+ }
4406
+ for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
4407
+ if (!allowedHostPlayerIds.has(playerId)) {
4408
+ throw new TypeError(
4409
+ `Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`,
4410
+ );
4411
+ }
4412
+ }
4413
+
4414
+ const bindingMaps: Map<string, string>[] = [];
4415
+ const rootSessionId = snapshot.frames[0]!.sessionId;
4416
+ for (const [index, frame] of snapshot.frames.entries()) {
4417
+ const enablement = enabled.get(frame.playbookId);
4418
+ if (!enablement) {
4419
+ throw new TypeError(
4420
+ `Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
4421
+ );
4422
+ }
4423
+ if (activePlaybooks.has(frame.playbookId)) {
4424
+ throw new TypeError(
4425
+ 'Captain shell snapshot engagement path must not contain a playbook cycle',
4426
+ );
4427
+ }
4428
+ activePlaybooks.add(frame.playbookId);
4429
+ if (activeSessionIds.has(frame.sessionId)) {
4430
+ throw new TypeError(
4431
+ 'Captain shell snapshot frame session ids must be unique',
4432
+ );
4433
+ }
4434
+ activeSessionIds.add(frame.sessionId);
4435
+ if (!issuedIds.has(frame.sessionId)) {
4436
+ throw new TypeError(
4437
+ 'Captain shell snapshot frame session id was not historically issued',
4438
+ );
4439
+ }
4440
+ if (
4441
+ frame.depth !== index ||
4442
+ frame.rootSessionId !== rootSessionId ||
4443
+ frame.runtime.state.status !== 'active' ||
4444
+ !frame.runtime.state.quiescent
4445
+ ) {
4446
+ throw new TypeError(
4447
+ 'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
4448
+ );
4449
+ }
4450
+ if (index === 0) {
4451
+ if (
4452
+ frame.sessionId !== frame.rootSessionId ||
4453
+ frame.parentSessionId !== undefined ||
4454
+ frame.parentCallId !== undefined
4455
+ ) {
4456
+ throw new TypeError(
4457
+ 'Captain shell snapshot root frame has child-only identity fields',
4458
+ );
4459
+ }
4460
+ } else {
4461
+ const parent = snapshot.frames[index - 1]!;
4462
+ if (
4463
+ frame.parentSessionId !== parent.sessionId ||
4464
+ frame.parentCallId === undefined
4465
+ ) {
4466
+ throw new TypeError(
4467
+ 'Captain shell snapshot child frame does not identify its immediate parent',
4468
+ );
4469
+ }
4470
+ const pending = parent.runtime.suspendedCall;
4471
+ if (
4472
+ !pending ||
4473
+ pending.callId !== frame.parentCallId ||
4474
+ pending.playbookId !== frame.playbookId ||
4475
+ pending.childSessionId !== frame.sessionId
4476
+ ) {
4477
+ throw new TypeError(
4478
+ 'Captain shell snapshot parent suspended call does not match its child edge',
4479
+ );
4480
+ }
4481
+ }
4482
+
4483
+ const roleBindings = new Map<string, string>();
4484
+ for (const role of enablement.entry.requiredRoleIds) {
4485
+ let inherited: string | undefined;
4486
+ for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
4487
+ inherited = bindingMaps[ancestor]?.get(role);
4488
+ if (inherited !== undefined) break;
4489
+ }
4490
+ roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
4491
+ }
4492
+ bindingMaps.push(roleBindings);
4493
+ const projectedTokens = Object.fromEntries(
4494
+ [...roleBindings].flatMap(([role, hostPlayerId]) => {
4495
+ const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
4496
+ return token === undefined ? [] : [[role, token] as const];
4497
+ }),
4498
+ );
4499
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
4500
+ throw new TypeError(
4501
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`,
4502
+ );
4503
+ }
4504
+ }
4505
+ const leafRuntime = snapshot.frames.at(-1)!.runtime;
4506
+ if (
4507
+ leafRuntime.suspendedCall !== undefined ||
4508
+ !leafRuntime.state.tags.includes('playbook.parked')
4509
+ ) {
4510
+ throw new TypeError(
4511
+ 'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
4512
+ );
4513
+ }
4514
+ };
4515
+
4516
+ const safeCapturePoint = (): boolean => {
4517
+ if (
4518
+ lifecycle !== 'ready' ||
4519
+ terminallyDisposed ||
4520
+ !sessionEmissionsOpen ||
4521
+ !session ||
4522
+ session.signal.aborted ||
4523
+ !captainRuntime ||
4524
+ disposing ||
4525
+ shuttingDown ||
4526
+ activeContext !== undefined ||
4527
+ activeTurn !== undefined ||
4528
+ activeTurnHostCalls !== undefined ||
4529
+ activeTurnSummary !== undefined ||
4530
+ runFailureFacts !== undefined ||
4531
+ servingCall !== undefined ||
4532
+ decisionCall !== undefined ||
4533
+ captainQueue.pending !== 0 ||
4534
+ captainQueue.size !== 0 ||
4535
+ (mode !== 'chat' && mode !== 'engaged.parked')
4536
+ ) {
4537
+ return false;
4538
+ }
4539
+ if (
4540
+ (mode === 'chat' &&
4541
+ (frames.length !== 0 ||
4542
+ pendingChildParents.size !== 0 ||
4543
+ pendingBossQuestions !== undefined ||
4544
+ lastError !== undefined)) ||
4545
+ (mode === 'engaged.parked' && frames.length === 0)
4546
+ ) {
4547
+ return false;
4548
+ }
4549
+ const expectedParents = new Set(frames.slice(0, -1));
4550
+ if (
4551
+ pendingChildParents.size !== expectedParents.size ||
4552
+ [...pendingChildParents].some((frame) => !expectedParents.has(frame))
4553
+ ) {
4554
+ return false;
4555
+ }
4556
+ return frames.every((frame, index) => {
4557
+ const liveInvocation =
4558
+ frame.invocationSignal !== undefined || frame.abortListener !== undefined;
4559
+ return (
4560
+ frame.state !== undefined &&
4561
+ frame.state.status === 'active' &&
4562
+ frame.state.quiescent &&
4563
+ !frame.disposing &&
4564
+ frame.disposePromise === undefined &&
4565
+ frame.removal === undefined &&
4566
+ frame.inFlightHostCalls.size === 0 &&
4567
+ (index === 0
4568
+ ? !liveInvocation
4569
+ : !liveInvocation ||
4570
+ (frame.invocationSignal !== undefined &&
4571
+ !frame.invocationSignal.aborted &&
4572
+ frame.abortListener !== undefined))
4573
+ );
4574
+ });
4575
+ };
4576
+
4577
+ const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
4578
+ if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
4579
+ return undefined;
4580
+ }
4581
+ try {
4582
+ if (
4583
+ typeof captainRuntime.exportSnapshot !== 'function' ||
4584
+ typeof captainRuntime.restore !== 'function'
4585
+ ) {
4586
+ return undefined;
4587
+ }
4588
+ const captainSnapshot = captainRuntime.exportSnapshot();
4589
+ if (captainSnapshot === undefined) return undefined;
4590
+ const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
4591
+ for (const frame of frames) {
4592
+ if (
4593
+ typeof frame.runtime.exportSnapshot !== 'function' ||
4594
+ typeof frame.runtime.restore !== 'function'
4595
+ ) {
4596
+ return undefined;
4597
+ }
4598
+ const runtime = frame.runtime.exportSnapshot();
4599
+ if (
4600
+ runtime === undefined ||
4601
+ !isDeepStrictEqual(frame.state, runtime.state)
4602
+ ) {
4603
+ return undefined;
4604
+ }
4605
+ frameSnapshots.push({
4606
+ playbookId: frame.entry.id,
4607
+ sessionId: frame.sessionId,
4608
+ rootSessionId: frame.rootSessionId,
4609
+ depth: frame.depth,
4610
+ ...(frame.parent
4611
+ ? {
4612
+ parentSessionId: frame.parent.frame.sessionId,
4613
+ parentCallId: frame.parent.callId,
4614
+ }
4615
+ : {}),
4616
+ runtime,
4617
+ });
4618
+ }
4619
+ const common = {
4620
+ schemaVersion: 1 as const,
4621
+ captain: {
4622
+ sessionId: captainSessionId,
4623
+ runtime: captainSnapshot,
4624
+ conversation,
4625
+ },
4626
+ issuedSessionIds: [...issuedSessionIds],
4627
+ sequences: { turn: turnSequence, journal: journalSeq },
4628
+ journal,
4629
+ ...(lastAction === undefined ? {} : { lastAction }),
4630
+ ...(lastSettlementStatus === undefined
4631
+ ? {}
4632
+ : { lastSettlementStatus }),
4633
+ };
4634
+ const candidate: PlaybookCaptainShellSnapshot =
4635
+ mode === 'chat'
4636
+ ? { ...common, mode }
4637
+ : {
4638
+ ...common,
4639
+ mode: 'engaged.parked',
4640
+ frames: frameSnapshots,
4641
+ rootPlayerResumeTokens: tokenRecord(
4642
+ rootFrame()!.playerResumeTokens,
4643
+ ),
4644
+ ...(pendingBossQuestions === undefined
4645
+ ? {}
4646
+ : { pendingBossQuestions: pendingBossQuestions as JsonValue }),
4647
+ ...(lastError === undefined ? {} : { lastError }),
4648
+ };
4649
+ const normalized = assertPlaybookCaptainShellSnapshot(candidate);
4650
+ assertSnapshotMatchesEnablements(normalized, enablementById);
4651
+ return normalized;
4652
+ } catch {
4653
+ return undefined;
4654
+ }
4655
+ };
4656
+
4657
+ const verifyRestoredRuntime = (
4658
+ runtime: PlaybookRuntime,
4659
+ expected: PlaybookRuntimeSnapshot,
4660
+ playbookId: string,
4661
+ allowSuspendedCall: boolean,
4662
+ ): void => {
4663
+ const actual = runtime.exportSnapshot?.();
4664
+ if (actual === undefined) {
4665
+ throw new Error(
4666
+ `restored ${playbookId} runtime did not reach a safe snapshot boundary`,
4667
+ );
4668
+ }
4669
+ const normalized = assertPlaybookRuntimeSnapshot(
4670
+ actual,
4671
+ playbookId,
4672
+ allowSuspendedCall ? { allowSuspendedCall: true } : {},
4673
+ );
4674
+ for (const key of [
4675
+ 'state',
4676
+ 'playerResumeTokens',
4677
+ 'sequences',
4678
+ 'pendingBossQuestions',
4679
+ 'suspendedCall',
4680
+ ] as const) {
4681
+ if (!isDeepStrictEqual(normalized[key], expected[key])) {
4682
+ throw new Error(
4683
+ `restored ${playbookId} runtime changed snapshot field ${key}`,
4684
+ );
4685
+ }
4686
+ }
4687
+ };
4688
+
4689
+ const resetFailedRestore = async (): Promise<readonly unknown[]> => {
4690
+ const cleanupFailures: unknown[] = [];
4691
+ for (const frame of [...frames].reverse()) {
4692
+ frame.disposing = true;
4693
+ try {
4694
+ await frame.runtime.dispose();
4695
+ } catch (error) {
4696
+ cleanupFailures.push(error);
4697
+ }
4698
+ }
4699
+ if (captainRuntime) {
4700
+ shuttingDown = true;
4701
+ try {
4702
+ await captainRuntime.dispose();
4703
+ } catch (error) {
4704
+ cleanupFailures.push(error);
4705
+ }
4706
+ }
4707
+ frames.splice(0);
4708
+ pendingChildParents.clear();
4709
+ issuedSessionIds.clear();
4710
+ journal.splice(0);
4711
+ entries = [];
4712
+ byCommand = new Map();
4713
+ byId = new Map();
4714
+ enablementById = new Map();
4715
+ players = [];
4716
+ session = undefined;
4717
+ sessionEmissionsOpen = false;
4718
+ closedGateAttempted = false;
4719
+ captainRuntime = undefined;
4720
+ captainSessionId = undefined;
4721
+ conversation = { kind: 'unopened' };
4722
+ mode = 'chat';
4723
+ pendingBossQuestions = undefined;
4724
+ lastError = undefined;
4725
+ journalSeq = 0;
4726
+ turnSequence = 0;
4727
+ lastAction = undefined;
4728
+ lastSettlementStatus = undefined;
4729
+ shuttingDown = false;
4730
+ if (cleanupFailures.length > 0) {
4731
+ terminallyDisposed = true;
4732
+ lifecycle = 'closed';
4733
+ } else {
4734
+ lifecycle = 'fresh';
4735
+ }
4736
+ return cleanupFailures;
4737
+ };
4738
+
4739
+ const restoreShellSnapshot = async (
4740
+ initSession: CaptainSession,
4741
+ untrusted: PlaybookCaptainShellSnapshot,
4742
+ ): Promise<void> => {
4743
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
4744
+ throw new Error('Captain shell restore requires a fresh shell');
4745
+ }
4746
+ if (initSession.signal.aborted) {
4747
+ throw new Error('cannot restore an aborted Captain session');
4748
+ }
4749
+ lifecycle = 'restoring';
4750
+ try {
4751
+ const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
4752
+ const built = await buildEnablements(
4753
+ options,
4754
+ initSession.players,
4755
+ loadModule,
4756
+ );
4757
+ for (const enablement of built.enablementById.values()) {
4758
+ enablement.entry.validateOptions(enablement.optionInput);
4759
+ }
4760
+ assertSnapshotMatchesEnablements(snapshot, built.enablementById);
4761
+
4762
+ installSession(initSession, false);
3667
4763
  players = initSession.players;
3668
- const built = await buildEnablements(options, players, loadModule);
3669
4764
  entries = built.entries;
3670
4765
  byCommand = built.byCommand;
3671
4766
  byId = built.byId;
3672
4767
  enablementById = built.enablementById;
3673
- for (const enablement of enablementById.values()) {
3674
- enablement.entry.validateOptions(enablement.optionInput);
3675
- }
3676
- await setMode('chat', 'init');
3677
- // CAPTAIN-16: the session Captain exists from `init`, outside the
3678
- // engagement stack, with its own playbook session id.
3679
- const catalog = Object.freeze(
3680
- entries.map((entry) =>
3681
- Object.freeze({
3682
- id: entry.id,
3683
- command: enablementById.get(entry.id)!.command,
3684
- intent: entry.intent,
3685
- }),
3686
- ),
3687
- );
3688
- captainSessionId = allocateSessionId();
4768
+
3689
4769
  captainRuntime = createCaptainRuntime({
3690
- enabledPlaybooks: catalog,
4770
+ enabledPlaybooks: enabledCatalog(),
3691
4771
  controller,
3692
4772
  });
3693
- await captainRuntime.init({
3694
- sessionId: captainSessionId,
3695
- playbookId: INTERNAL_CAPTAIN_ID,
3696
- rootSessionId: captainSessionId,
3697
- depth: 0,
3698
- ports: captainPorts(),
3699
- });
4773
+ if (typeof captainRuntime.restore !== 'function') {
4774
+ throw new Error('session Captain runtime does not support restore');
4775
+ }
4776
+
4777
+ if (snapshot.mode === 'engaged.parked') {
4778
+ const rootTokens = new Map(
4779
+ Object.entries(snapshot.rootPlayerResumeTokens),
4780
+ );
4781
+ for (const [index, frameSnapshot] of snapshot.frames.entries()) {
4782
+ const parentFrame = frames.at(-1);
4783
+ const frame = makeRestoredFrame(
4784
+ enablementById.get(frameSnapshot.playbookId)!,
4785
+ frameSnapshot,
4786
+ rootTokens,
4787
+ index === 0
4788
+ ? undefined
4789
+ : {
4790
+ frame: parentFrame!,
4791
+ callId: frameSnapshot.parentCallId!,
4792
+ },
4793
+ );
4794
+ if (typeof frame.runtime.restore !== 'function') {
4795
+ throw new Error(
4796
+ `playbook ${frame.entry.id} runtime does not support restore`,
4797
+ );
4798
+ }
4799
+ frames.push(frame);
4800
+ if (parentFrame) pendingChildParents.add(parentFrame);
4801
+ }
4802
+ }
4803
+
4804
+ await captainRuntime.restore(
4805
+ captainPlaybookSession(snapshot.captain.sessionId),
4806
+ snapshot.captain.runtime,
4807
+ );
4808
+ if (snapshot.mode === 'engaged.parked') {
4809
+ for (const [index, frame] of frames.entries()) {
4810
+ await frame.runtime.restore!(
4811
+ frameSession(frame),
4812
+ snapshot.frames[index]!.runtime,
4813
+ );
4814
+ }
4815
+ }
4816
+ if (closedGateAttempted) {
4817
+ throw new Error('a runtime attempted a host emission during restore');
4818
+ }
4819
+ verifyRestoredRuntime(
4820
+ captainRuntime,
4821
+ snapshot.captain.runtime,
4822
+ INTERNAL_CAPTAIN_ID,
4823
+ false,
4824
+ );
4825
+ if (snapshot.mode === 'engaged.parked') {
4826
+ for (const [index, frame] of frames.entries()) {
4827
+ verifyRestoredRuntime(
4828
+ frame.runtime,
4829
+ snapshot.frames[index]!.runtime,
4830
+ frame.entry.id,
4831
+ true,
4832
+ );
4833
+ }
4834
+ if (
4835
+ !isDeepStrictEqual(
4836
+ tokenRecord(rootFrame()!.playerResumeTokens),
4837
+ snapshot.rootPlayerResumeTokens,
4838
+ )
4839
+ ) {
4840
+ throw new Error(
4841
+ 'restored root-owned player continuation changed during restore',
4842
+ );
4843
+ }
4844
+ }
4845
+ if (closedGateAttempted) {
4846
+ throw new Error('a runtime attempted a host emission during restore');
4847
+ }
4848
+ if (requireSession().signal.aborted) {
4849
+ throw new Error('Captain session aborted during restore');
4850
+ }
4851
+ for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
4852
+ journal.push(...snapshot.journal);
4853
+ journalSeq = snapshot.sequences.journal;
4854
+ turnSequence = snapshot.sequences.turn;
4855
+ conversation = snapshot.captain.conversation;
4856
+ captainSessionId = snapshot.captain.sessionId;
4857
+ lastAction = snapshot.lastAction;
4858
+ lastSettlementStatus = snapshot.lastSettlementStatus;
4859
+ mode = snapshot.mode;
4860
+ if (snapshot.mode === 'engaged.parked') {
4861
+ pendingBossQuestions = snapshot.pendingBossQuestions;
4862
+ lastError = snapshot.lastError;
4863
+ }
4864
+ lifecycle = 'ready';
4865
+ // The final commit is deliberately one non-throwing assignment.
4866
+ sessionEmissionsOpen = true;
4867
+ } catch (error) {
4868
+ const cleanupFailures = await resetFailedRestore();
4869
+ if (cleanupFailures.length > 0) {
4870
+ throw new AggregateError(
4871
+ [error, ...cleanupFailures],
4872
+ 'Captain shell restore and cleanup failed',
4873
+ );
4874
+ }
4875
+ throw error;
4876
+ }
4877
+ };
4878
+
4879
+ return {
4880
+ async init(initSession: CaptainSession): Promise<void> {
4881
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
4882
+ throw new Error('Captain shell requires a fresh instance for init');
4883
+ }
4884
+ if (initSession.signal.aborted) {
4885
+ throw new Error('cannot initialize an aborted Captain session');
4886
+ }
4887
+ lifecycle = 'initializing';
4888
+ try {
4889
+ installSession(initSession, true);
4890
+ players = initSession.players;
4891
+ const built = await buildEnablements(options, players, loadModule);
4892
+ entries = built.entries;
4893
+ byCommand = built.byCommand;
4894
+ byId = built.byId;
4895
+ enablementById = built.enablementById;
4896
+ for (const enablement of enablementById.values()) {
4897
+ enablement.entry.validateOptions(enablement.optionInput);
4898
+ }
4899
+ await setMode('chat', 'init');
4900
+ // CAPTAIN-16: the session Captain exists from `init`, outside the
4901
+ // engagement stack, with its own playbook session id.
4902
+ captainSessionId = allocateSessionId();
4903
+ captainRuntime = createCaptainRuntime({
4904
+ enabledPlaybooks: enabledCatalog(),
4905
+ controller,
4906
+ });
4907
+ await captainRuntime.init(captainPlaybookSession(captainSessionId));
4908
+ lifecycle = 'ready';
4909
+ } catch (error) {
4910
+ terminallyDisposed = true;
4911
+ lifecycle = 'closed';
4912
+ throw error;
4913
+ }
3700
4914
  },
3701
4915
 
4916
+ exportSnapshot: exportShellSnapshot,
4917
+
4918
+ restore: restoreShellSnapshot,
4919
+
3702
4920
  async handleBossTurn(
3703
4921
  turn: BossTurn,
3704
4922
  context: CaptainContext,
3705
4923
  ): Promise<void> {
4924
+ if (lifecycle !== 'ready' || terminallyDisposed) {
4925
+ throw new Error(
4926
+ 'init must be called first, or restore must complete before handling a Boss turn',
4927
+ );
4928
+ }
3706
4929
  requireSession();
3707
4930
  if (!captainRuntime) {
3708
4931
  throw new Error('init must be called first');
@@ -3804,11 +5027,17 @@ export function createPlaybookCaptainShell(
3804
5027
  },
3805
5028
 
3806
5029
  async prepareDispose(): Promise<void> {
5030
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
5031
+ throw new Error('cannot dispose while Captain shell setup is in progress');
5032
+ }
3807
5033
  activeContext = undefined;
3808
5034
  await teardown();
3809
5035
  },
3810
5036
 
3811
5037
  async dispose(): Promise<void> {
5038
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
5039
+ throw new Error('cannot dispose while Captain shell setup is in progress');
5040
+ }
3812
5041
  activeContext = undefined;
3813
5042
  await teardown();
3814
5043
  },
@@ -3817,6 +5046,8 @@ export function createPlaybookCaptainShell(
3817
5046
  // CAPTAIN-16: dispose every active frame from leaf to root, then the
3818
5047
  // session Captain last.
3819
5048
  async function teardown(): Promise<void> {
5049
+ terminallyDisposed = true;
5050
+ lifecycle = 'disposing';
3820
5051
  let failure: unknown;
3821
5052
  try {
3822
5053
  await disposeStack('dispose');
@@ -3833,6 +5064,7 @@ export function createPlaybookCaptainShell(
3833
5064
  failure ??= error;
3834
5065
  }
3835
5066
  }
5067
+ lifecycle = 'closed';
3836
5068
  if (failure !== undefined) throw failure;
3837
5069
  }
3838
5070
  }