@sublang/playbook 5.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.
Files changed (72) hide show
  1. package/README.md +22 -14
  2. package/docs/cli.md +138 -90
  3. package/docs/configuration.md +113 -29
  4. package/docs/embedding.md +24 -16
  5. package/package.json +42 -21
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
  8. package/reference/sdlc/code.md +55 -97
  9. package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
  10. package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
  11. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  12. package/reference/sdlc/code.playbook/bin/run.js +1171 -983
  13. package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  18. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  19. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  20. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  22. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  23. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  25. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  26. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
  28. package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
  31. package/reference/sdlc/decide.md +54 -0
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  35. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  37. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
  38. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  40. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  42. package/reference/sdlc/review.md +81 -0
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  45. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  46. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  48. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  51. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  52. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  53. package/slc/gears2fsm.md +13 -4
  54. package/slc/link.md +83 -14
  55. package/slc/text2gears.md +22 -2
  56. package/src/runtime.d.ts +21 -2
  57. package/src/runtime.ts +38 -6
  58. package/src/xstate-playbook-runtime.d.ts +9 -2
  59. package/src/xstate-playbook-runtime.js +319 -35
  60. package/src/xstate-playbook-runtime.ts +412 -41
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +384 -57
  63. package/src/xstate-runtime.ts +542 -71
  64. package/reference/sdlc/discuss.md +0 -93
  65. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  68. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  69. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  70. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  71. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  72. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -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,11 +21,15 @@ import type {
20
21
  PlaybookPorts,
21
22
  PlaybookRunResult,
22
23
  PlaybookRuntime,
24
+ PlaybookRuntimeSnapshot,
25
+ PlayerSessionStore,
23
26
  PlaybookState,
24
27
  } from '@sublang/playbook/runtime';
25
28
  import {
29
+ assertPlaybookRuntimeSnapshot,
26
30
  hiddenControlEnvelope,
27
31
  registerPlaybookAbortCleanup,
32
+ snapshotJsonValue,
28
33
  } from '../../../src/xstate-runtime.js';
29
34
  import createDefaultCaptainRuntime, {
30
35
  type CaptainControllerPort,
@@ -62,6 +67,86 @@ export interface PlaybookCaptainRegistryEntry {
62
67
  createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
63
68
  }
64
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
+
65
150
  // Per-enabled-playbook binding the shell resolves at init from
66
151
  // `captain.options.playbooks`: each playbook binds its local roles to
67
152
  // `<id>-<role>` host players and carries the generated visible set.
@@ -71,7 +156,11 @@ interface Enablement {
71
156
  optionInput: unknown;
72
157
  boundPlayers: readonly RegistryPlayer[];
73
158
  hostPlayerId: (localRole: string) => string;
74
- visiblePlayerIds?: readonly string[];
159
+ }
160
+
161
+ interface EffectivePlayerBinding {
162
+ readonly hostPlayerId: string;
163
+ readonly player: RegistryPlayer;
75
164
  }
76
165
 
77
166
  interface EngagementFrame {
@@ -81,6 +170,8 @@ interface EngagementFrame {
81
170
  sessionId: string;
82
171
  rootSessionId: string;
83
172
  depth: number;
173
+ playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
174
+ playerResumeTokens: Map<string, string>;
84
175
  parent?: {
85
176
  frame: EngagementFrame;
86
177
  callId: string;
@@ -758,6 +849,467 @@ function isValidRegistryEntry(
758
849
  );
759
850
  }
760
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
+
761
1313
  function readPlaybooksConfig(
762
1314
  options: unknown,
763
1315
  ): Record<string, unknown> | undefined {
@@ -874,9 +1426,6 @@ async function buildEnablements(
874
1426
  optionInput: record.options,
875
1427
  boundPlayers,
876
1428
  hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
877
- visiblePlayerIds: entry.requiredRoleIds.map(
878
- (role) => `${entry.id}-${role}`,
879
- ),
880
1429
  });
881
1430
  }
882
1431
  return { entries, byCommand, byId, enablementById };
@@ -885,7 +1434,7 @@ async function buildEnablements(
885
1434
  export function createPlaybookCaptainShell(
886
1435
  options: unknown,
887
1436
  deps: PlaybookCaptainDeps = {},
888
- ): Captain {
1437
+ ): PlaybookCaptainShell {
889
1438
  const loadModule =
890
1439
  deps.loadModule ?? ((specifier: string) => import(specifier));
891
1440
  const createSessionId = deps.createSessionId ?? randomUUID;
@@ -901,6 +1450,16 @@ export function createPlaybookCaptainShell(
901
1450
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
902
1451
  let enablementById = new Map<string, Enablement>();
903
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;
904
1463
  let players: readonly RegistryPlayer[] = [];
905
1464
  let activeContext: CaptainContext | undefined;
906
1465
  const frames: EngagementFrame[] = [];
@@ -914,6 +1473,42 @@ export function createPlaybookCaptainShell(
914
1473
  const captainQueue = new PQueue({ concurrency: 1 });
915
1474
  let disposing = false;
916
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
+
917
1512
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
918
1513
  let captainRuntime: PlaybookRuntime | undefined;
919
1514
  let captainSessionId: string | undefined;
@@ -956,6 +1551,29 @@ export function createPlaybookCaptainShell(
956
1551
  const frameLabel = (frame: EngagementFrame): string =>
957
1552
  `/${frame.enablement.command}`;
958
1553
 
1554
+ const bindingFor = (
1555
+ frame: EngagementFrame,
1556
+ localRole: string,
1557
+ ): EffectivePlayerBinding => {
1558
+ const binding = frame.playerBindings.get(localRole);
1559
+ if (!binding) {
1560
+ throw new Error(
1561
+ `${frameLabel(frame)} resolved undeclared player role ${JSON.stringify(localRole)}`,
1562
+ );
1563
+ }
1564
+ return binding;
1565
+ };
1566
+
1567
+ const summaryIncludes = (frame: EngagementFrame): boolean => {
1568
+ const owner = activeTurnSummary?.owner;
1569
+ if (!owner) return false;
1570
+ for (let current: EngagementFrame | undefined = frame; current; ) {
1571
+ if (current === owner) return true;
1572
+ current = current.parent?.frame;
1573
+ }
1574
+ return false;
1575
+ };
1576
+
959
1577
  const requireSession = (): CaptainSession => {
960
1578
  if (!session) {
961
1579
  throw new Error('init must be called first');
@@ -1170,7 +1788,8 @@ export function createPlaybookCaptainShell(
1170
1788
  const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
1171
1789
  frame.state = state;
1172
1790
 
1173
- if (activeTurnSummary?.owner === frame) {
1791
+ const summary = activeTurnSummary;
1792
+ if (summary && summaryIncludes(frame)) {
1174
1793
  for (const stateId of state.activeStateIds) {
1175
1794
  const newlyActive = !previousActiveIds.has(stateId);
1176
1795
  const structuredEntry =
@@ -1179,9 +1798,9 @@ export function createPlaybookCaptainShell(
1179
1798
  if (!newlyActive && !structuredEntry) continue;
1180
1799
  const countLabel = stateCountLabel(stateId, frame.entry);
1181
1800
  if (countLabel) {
1182
- activeTurnSummary.stateCounts.set(
1801
+ summary.stateCounts.set(
1183
1802
  countLabel,
1184
- (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1,
1803
+ (summary.stateCounts.get(countLabel) ?? 0) + 1,
1185
1804
  );
1186
1805
  }
1187
1806
  }
@@ -1208,12 +1827,13 @@ export function createPlaybookCaptainShell(
1208
1827
 
1209
1828
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
1210
1829
  callPlayer: async (playerId, prompt, signal, options) => {
1830
+ admitHostBoundary();
1211
1831
  if (!activeContext) {
1212
1832
  throw new Error('callPlayer invoked outside a Boss turn');
1213
1833
  }
1214
1834
  const context = activeContext;
1215
1835
  signal.throwIfAborted();
1216
- const hostPlayerId = frame.enablement.hostPlayerId(playerId);
1836
+ const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
1217
1837
  const result = await trackHostCall(
1218
1838
  frame,
1219
1839
  context.callPlayer(hostPlayerId, prompt, {
@@ -1228,8 +1848,9 @@ export function createPlaybookCaptainShell(
1228
1848
  // CAPTAIN-20: only a player call that actually produced work is an
1229
1849
  // interruption the Boss was spared. A call that errored or aborted
1230
1850
  // saved nothing, so it never feeds the saved-counts gate.
1231
- if (activeTurnSummary?.owner === frame && result.status === 'ok') {
1232
- activeTurnSummary.counts.interruptions++;
1851
+ const summary = activeTurnSummary;
1852
+ if (summary && summaryIncludes(frame) && result.status === 'ok') {
1853
+ summary.counts.interruptions++;
1233
1854
  }
1234
1855
  return {
1235
1856
  status: result.status,
@@ -1243,6 +1864,7 @@ export function createPlaybookCaptainShell(
1243
1864
  };
1244
1865
  },
1245
1866
  callCaptain: async (prompt, signal, options) => {
1867
+ admitHostBoundary();
1246
1868
  if (!activeContext) {
1247
1869
  throw new Error('callCaptain invoked outside a Boss turn');
1248
1870
  }
@@ -1266,6 +1888,7 @@ export function createPlaybookCaptainShell(
1266
1888
  };
1267
1889
  },
1268
1890
  callJudge: async (prompt, signal) => {
1891
+ admitHostBoundary();
1269
1892
  if (!activeContext) {
1270
1893
  throw new Error('callJudge invoked outside a Boss turn');
1271
1894
  }
@@ -1289,16 +1912,19 @@ export function createPlaybookCaptainShell(
1289
1912
  throw new Error('callCaptain returned status=ok with no finalText');
1290
1913
  }
1291
1914
  const guard = guardFromJudgeReply(result.finalText);
1915
+ const summary = activeTurnSummary;
1292
1916
  if (
1293
1917
  guard &&
1294
- activeTurnSummary?.owner === frame &&
1918
+ summary &&
1919
+ summaryIncludes(frame) &&
1295
1920
  frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
1296
1921
  ) {
1297
- activeTurnSummary.counts.copyPastes++;
1922
+ summary.counts.copyPastes++;
1298
1923
  }
1299
1924
  return result.finalText;
1300
1925
  },
1301
1926
  callPlaybook: (request, signal) => {
1927
+ admitHostBoundary();
1302
1928
  const opening = callNestedPlaybook(frame, request, signal);
1303
1929
  let exposed!: Promise<PlaybookCallStart>;
1304
1930
  const registerOpeningCleanup = (): void => {
@@ -1311,17 +1937,27 @@ export function createPlaybookCaptainShell(
1311
1937
  if (signal.aborted) registerOpeningCleanup();
1312
1938
  return exposed;
1313
1939
  },
1314
- emitStatus: async (message, data) => {
1315
- await requireSession().emitStatus(
1316
- message,
1317
- 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
+ })(),
1318
1950
  );
1319
1951
  },
1320
- emitTelemetry: async (event) => {
1321
- if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1322
- await mirrorSubRuntimeTelemetry(frame, event.payload);
1323
- }
1324
- 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);
1325
1961
  },
1326
1962
  });
1327
1963
 
@@ -1329,8 +1965,10 @@ export function createPlaybookCaptainShell(
1329
1965
  // visibility for that playbook's generated host players. A pane
1330
1966
  // reconciliation failure is display-only in tmux-play and does not
1331
1967
  // reject; the legacy path carries no generated set and skips this.
1332
- const requestVisibility = async (enablement: Enablement): Promise<void> => {
1333
- const ids = enablement.visiblePlayerIds;
1968
+ const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
1969
+ const ids = [...new Set(
1970
+ [...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId),
1971
+ )];
1334
1972
  if (!ids || ids.length === 0 || !activeContext) return;
1335
1973
  try {
1336
1974
  await activeContext.setVisiblePlayers(ids);
@@ -1369,15 +2007,52 @@ export function createPlaybookCaptainShell(
1369
2007
  return typeof stack === 'string' ? { ...compact, stack } : compact;
1370
2008
  };
1371
2009
 
2010
+ const makePlayerBindings = (
2011
+ enablement: Enablement,
2012
+ parent?: { frame: EngagementFrame; callId: string },
2013
+ ): ReadonlyMap<string, EffectivePlayerBinding> => {
2014
+ const entry = enablement.entry;
2015
+ const playerBindings = new Map<string, EffectivePlayerBinding>();
2016
+ for (const role of entry.requiredRoleIds) {
2017
+ let inherited: EffectivePlayerBinding | undefined;
2018
+ for (
2019
+ let ancestor = parent?.frame;
2020
+ ancestor && inherited === undefined;
2021
+ ancestor = ancestor.parent?.frame
2022
+ ) {
2023
+ inherited = ancestor.playerBindings.get(role);
2024
+ }
2025
+ if (inherited) {
2026
+ playerBindings.set(role, inherited);
2027
+ continue;
2028
+ }
2029
+ const configured = enablement.boundPlayers.find(
2030
+ (player) => player.id === role,
2031
+ ) ?? { id: role };
2032
+ playerBindings.set(role, {
2033
+ hostPlayerId: enablement.hostPlayerId(role),
2034
+ player: configured,
2035
+ });
2036
+ }
2037
+ return playerBindings;
2038
+ };
2039
+
1372
2040
  const makeFrame = (
1373
2041
  enablement: Enablement,
1374
2042
  parent?: { frame: EngagementFrame; callId: string },
1375
2043
  ): EngagementFrame => {
1376
2044
  const entry = enablement.entry;
1377
2045
  const sessionId = allocateSessionId();
2046
+ const playerBindings = makePlayerBindings(enablement, parent);
2047
+ const playerResumeTokens =
2048
+ parent?.frame.playerResumeTokens ?? new Map<string, string>();
1378
2049
  const runtime = entry.createRuntime({
1379
2050
  captainOptions: enablement.optionInput,
1380
- players: enablement.boundPlayers,
2051
+ players: [...playerBindings].map(([role, { player }]) => ({
2052
+ id: role,
2053
+ ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
2054
+ ...(player.model === undefined ? {} : { model: player.model }),
2055
+ })),
1381
2056
  });
1382
2057
  return {
1383
2058
  entry,
@@ -1386,13 +2061,77 @@ export function createPlaybookCaptainShell(
1386
2061
  sessionId,
1387
2062
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
1388
2063
  depth: parent ? parent.frame.depth + 1 : 0,
2064
+ playerBindings,
2065
+ playerResumeTokens,
1389
2066
  ...(parent ? { parent } : {}),
1390
2067
  inFlightHostCalls: new Set(),
1391
2068
  };
1392
2069
  };
1393
2070
 
1394
- const initFrame = async (frame: EngagementFrame): Promise<void> => {
1395
- await frame.runtime.init({
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
+
2102
+ const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
2103
+ select(playerId) {
2104
+ const binding = bindingFor(frame, playerId);
2105
+ return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
2106
+ },
2107
+ update(playerId, resumeToken) {
2108
+ const binding = bindingFor(frame, playerId);
2109
+ if (resumeToken === undefined) {
2110
+ frame.playerResumeTokens.delete(binding.hostPlayerId);
2111
+ } else {
2112
+ frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
2113
+ }
2114
+ },
2115
+ snapshot() {
2116
+ const tokens: Record<string, string> = {};
2117
+ for (const [playerId, binding] of frame.playerBindings) {
2118
+ const token = frame.playerResumeTokens.get(binding.hostPlayerId);
2119
+ if (token !== undefined) tokens[playerId] = token;
2120
+ }
2121
+ return tokens;
2122
+ },
2123
+ restore(tokens) {
2124
+ for (const binding of frame.playerBindings.values()) {
2125
+ frame.playerResumeTokens.delete(binding.hostPlayerId);
2126
+ }
2127
+ for (const [playerId, token] of Object.entries(tokens)) {
2128
+ const binding = bindingFor(frame, playerId);
2129
+ frame.playerResumeTokens.set(binding.hostPlayerId, token);
2130
+ }
2131
+ },
2132
+ });
2133
+
2134
+ const frameSession = (frame: EngagementFrame) => ({
1396
2135
  sessionId: frame.sessionId,
1397
2136
  playbookId: frame.entry.id,
1398
2137
  rootSessionId: frame.rootSessionId,
@@ -1403,8 +2142,12 @@ export function createPlaybookCaptainShell(
1403
2142
  }
1404
2143
  : {}),
1405
2144
  depth: frame.depth,
2145
+ playerSessions: playerSessionStore(frame),
1406
2146
  ports: createPorts(frame),
1407
2147
  });
2148
+
2149
+ const initFrame = async (frame: EngagementFrame): Promise<void> => {
2150
+ await frame.runtime.init(frameSession(frame));
1408
2151
  };
1409
2152
 
1410
2153
  const clearLeafLedger = (): void => {
@@ -1599,7 +2342,7 @@ export function createPlaybookCaptainShell(
1599
2342
  }
1600
2343
  let visibilityError: unknown;
1601
2344
  try {
1602
- await requestVisibility(parent.enablement);
2345
+ await requestVisibility(parent);
1603
2346
  } catch (error) {
1604
2347
  visibilityError = error;
1605
2348
  }
@@ -1720,7 +2463,7 @@ export function createPlaybookCaptainShell(
1720
2463
  // boundary — a `setVisiblePlayers` or telemetry rejection here leaves the
1721
2464
  // runtime uninvoked and owes the Boss the CAPTAIN-34 reply rather than an
1722
2465
  // exception filed against an effect that never ran.
1723
- await requestVisibility(frame.enablement);
2466
+ await requestVisibility(frame);
1724
2467
  await setMode('engaged.driving', 'submit');
1725
2468
  const result = await runEffect(() =>
1726
2469
  frame.runtime.handleBossInput({ text, signal }),
@@ -1824,6 +2567,12 @@ export function createPlaybookCaptainShell(
1824
2567
  if (frame.parent) {
1825
2568
  await resumeParent(frame, callResultFor(frame, result), context);
1826
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));
1827
2576
  await runEffect(() => disposeStack('final'));
1828
2577
  }
1829
2578
  return;
@@ -1867,7 +2616,7 @@ export function createPlaybookCaptainShell(
1867
2616
  cleanupError = error;
1868
2617
  }
1869
2618
  if (frames.includes(parent)) {
1870
- await requestVisibility(parent.enablement);
2619
+ await requestVisibility(parent);
1871
2620
  }
1872
2621
  if (cleanupError !== undefined) throw cleanupError;
1873
2622
  };
@@ -2005,7 +2754,7 @@ export function createPlaybookCaptainShell(
2005
2754
  }
2006
2755
  if (frames.includes(parent)) {
2007
2756
  try {
2008
- await requestVisibility(parent.enablement);
2757
+ await requestVisibility(parent);
2009
2758
  } catch (visibilityError) {
2010
2759
  visibilityControlFailure = true;
2011
2760
  boundaryError = new AggregateError(
@@ -2655,9 +3404,11 @@ export function createPlaybookCaptainShell(
2655
3404
 
2656
3405
  const captainPorts = (): PlaybookPorts => ({
2657
3406
  callPlayer: async () => {
3407
+ admitHostBoundary();
2658
3408
  throw new Error('the session Captain has no players');
2659
3409
  },
2660
3410
  callCaptain: async (prompt, signal) => {
3411
+ admitHostBoundary();
2661
3412
  if (!activeContext) {
2662
3413
  throw new Error('the session Captain called out of a Boss turn');
2663
3414
  }
@@ -2725,29 +3476,37 @@ export function createPlaybookCaptainShell(
2725
3476
  return { status: 'ok' as const, finalText: 'ok' };
2726
3477
  },
2727
3478
  callJudge: async () => {
3479
+ admitHostBoundary();
2728
3480
  throw new Error('the session Captain makes no judge call');
2729
3481
  },
2730
3482
  callPlaybook: async () => {
3483
+ admitHostBoundary();
2731
3484
  throw new Error('the session Captain never calls a playbook');
2732
3485
  },
2733
3486
  // CAPTAIN-9: the session Captain's human status stream is suppressed while
2734
3487
  // its structured telemetry is forwarded.
2735
- emitStatus: async () => {},
2736
- emitTelemetry: async (event) => {
2737
- if (event.topic === 'playbook.trace') {
2738
- const payload = payloadRecord(event.payload);
2739
- if (payload?.type === 'captain.call.started') {
2740
- const identity = payloadRecord(payload.payload);
2741
- const stateId = identity?.stateId;
2742
- servingCall =
2743
- stateId === 'reporting'
2744
- ? 'closingReply'
2745
- : stateId === 'answeringCommand'
2746
- ? 'commandReply'
2747
- : '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
+ }
2748
3506
  }
2749
- }
2750
- await requireSession().emitTelemetry(event);
3507
+ await requireSession().emitTelemetry(event);
3508
+ })();
3509
+ return trackTurnCall(emission);
2751
3510
  },
2752
3511
  });
2753
3512
 
@@ -2826,6 +3585,15 @@ export function createPlaybookCaptainShell(
2826
3585
  }
2827
3586
  };
2828
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
+
2829
3597
  const leafStateSummary = (): string | undefined => {
2830
3598
  const leaf = leafFrame();
2831
3599
  if (!leaf) return 'idle: no playbook is engaged';
@@ -3308,9 +4076,6 @@ export function createPlaybookCaptainShell(
3308
4076
  };
3309
4077
  throw outcome.error;
3310
4078
  }
3311
- if (!frames.includes(leaf)) {
3312
- facts.push(`${frameLabel(leaf)} finished and was disposed.`);
3313
- }
3314
4079
  const runFailed = drainRunFailureFacts(facts);
3315
4080
  const summary = leafStateSummary();
3316
4081
  turn.report = {
@@ -3485,9 +4250,14 @@ export function createPlaybookCaptainShell(
3485
4250
  };
3486
4251
 
3487
4252
  const controller: CaptainControllerPort = {
3488
- submit: (selection, signal) => settleSelection(selection, signal),
3489
- resolveParsedTurn: () =>
3490
- 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
+ },
3491
4261
  };
3492
4262
 
3493
4263
  // -------------------------------------------------------------------------
@@ -3565,48 +4335,597 @@ export function createPlaybookCaptainShell(
3565
4335
  });
3566
4336
  };
3567
4337
 
3568
- return {
3569
- async init(initSession: CaptainSession): Promise<void> {
3570
- 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);
3571
4763
  players = initSession.players;
3572
- const built = await buildEnablements(options, players, loadModule);
3573
4764
  entries = built.entries;
3574
4765
  byCommand = built.byCommand;
3575
4766
  byId = built.byId;
3576
4767
  enablementById = built.enablementById;
3577
- for (const enablement of enablementById.values()) {
3578
- enablement.entry.validateOptions(enablement.optionInput);
3579
- }
3580
- await setMode('chat', 'init');
3581
- // CAPTAIN-16: the session Captain exists from `init`, outside the
3582
- // engagement stack, with its own playbook session id.
3583
- const catalog = Object.freeze(
3584
- entries.map((entry) =>
3585
- Object.freeze({
3586
- id: entry.id,
3587
- command: enablementById.get(entry.id)!.command,
3588
- intent: entry.intent,
3589
- }),
3590
- ),
3591
- );
3592
- captainSessionId = allocateSessionId();
4768
+
3593
4769
  captainRuntime = createCaptainRuntime({
3594
- enabledPlaybooks: catalog,
4770
+ enabledPlaybooks: enabledCatalog(),
3595
4771
  controller,
3596
4772
  });
3597
- await captainRuntime.init({
3598
- sessionId: captainSessionId,
3599
- playbookId: INTERNAL_CAPTAIN_ID,
3600
- rootSessionId: captainSessionId,
3601
- depth: 0,
3602
- ports: captainPorts(),
3603
- });
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
+ }
3604
4914
  },
3605
4915
 
4916
+ exportSnapshot: exportShellSnapshot,
4917
+
4918
+ restore: restoreShellSnapshot,
4919
+
3606
4920
  async handleBossTurn(
3607
4921
  turn: BossTurn,
3608
4922
  context: CaptainContext,
3609
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
+ }
3610
4929
  requireSession();
3611
4930
  if (!captainRuntime) {
3612
4931
  throw new Error('init must be called first');
@@ -3708,11 +5027,17 @@ export function createPlaybookCaptainShell(
3708
5027
  },
3709
5028
 
3710
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
+ }
3711
5033
  activeContext = undefined;
3712
5034
  await teardown();
3713
5035
  },
3714
5036
 
3715
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
+ }
3716
5041
  activeContext = undefined;
3717
5042
  await teardown();
3718
5043
  },
@@ -3721,6 +5046,8 @@ export function createPlaybookCaptainShell(
3721
5046
  // CAPTAIN-16: dispose every active frame from leaf to root, then the
3722
5047
  // session Captain last.
3723
5048
  async function teardown(): Promise<void> {
5049
+ terminallyDisposed = true;
5050
+ lifecycle = 'disposing';
3724
5051
  let failure: unknown;
3725
5052
  try {
3726
5053
  await disposeStack('dispose');
@@ -3737,6 +5064,7 @@ export function createPlaybookCaptainShell(
3737
5064
  failure ??= error;
3738
5065
  }
3739
5066
  }
5067
+ lifecycle = 'closed';
3740
5068
  if (failure !== undefined) throw failure;
3741
5069
  }
3742
5070
  }