@sublang/playbook 6.0.0 → 8.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 (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -2,14 +2,20 @@
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
- import type {
8
- BossTurn,
9
- Captain,
10
- CaptainContext,
11
- CaptainSession,
8
+ import {
9
+ isAgentCallSettingsError,
10
+ type AgentCallSettings,
11
+ type BossTurn,
12
+ type Captain,
13
+ type CaptainContext,
14
+ type CaptainSession,
15
+ type PlayerAdapterName,
16
+ type TuningSelection,
12
17
  } from '@sublang/cligent/tmux-play';
18
+ import type { Effort, PermissionPolicy } from '@sublang/cligent';
13
19
  import type {
14
20
  JsonValue,
15
21
  NormalizedError,
@@ -20,12 +26,17 @@ import type {
20
26
  PlaybookPorts,
21
27
  PlaybookRunResult,
22
28
  PlaybookRuntime,
29
+ PlaybookRuntimeSnapshot,
30
+ PlayerResult,
23
31
  PlayerSessionStore,
24
32
  PlaybookState,
25
33
  } from '@sublang/playbook/runtime';
26
34
  import {
35
+ assertPlaybookRuntimeSnapshot,
27
36
  hiddenControlEnvelope,
28
37
  registerPlaybookAbortCleanup,
38
+ snapshotJsonValue,
39
+ validatePlayerResult,
29
40
  } from '../../../src/xstate-runtime.js';
30
41
  import createDefaultCaptainRuntime, {
31
42
  type CaptainControllerPort,
@@ -33,13 +44,37 @@ import createDefaultCaptainRuntime, {
33
44
  type CaptainParsedResolution,
34
45
  type SettlementEvidence,
35
46
  } from '../captain.playbook/captain.playbook.js';
36
- import type { PlaybookSummaryPolicy, RegistryPlayer } from './code.registry.js';
47
+ import type { PlaybookSummaryPolicy } from './code.registry.js';
48
+
49
+ interface SessionAgent {
50
+ readonly adapter: string;
51
+ readonly model: TuningSelection;
52
+ readonly effort: TuningSelection<Effort>;
53
+ readonly instruction?: string;
54
+ readonly permissions?: PermissionPolicy;
55
+ }
37
56
 
38
- export interface CreatePlaybookRuntimeOptions {
39
- captainOptions: unknown;
40
- players: readonly RegistryPlayer[];
57
+ interface PlayerLedgerEntry {
58
+ readonly adapter: string;
59
+ readonly instruction?: string;
60
+ readonly permissions?: PermissionPolicy;
61
+ resumeToken?: string;
41
62
  }
42
63
 
64
+ type DeepReadonly<T> = T extends (...args: never[]) => unknown
65
+ ? T
66
+ : T extends readonly (infer Element)[]
67
+ ? readonly DeepReadonly<Element>[]
68
+ : T extends object
69
+ ? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
70
+ : T;
71
+
72
+ type SnapshotAgentEnvelope = DeepReadonly<
73
+ Omit<SessionAgent, 'model' | 'effort'>
74
+ >;
75
+
76
+ type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
77
+
43
78
  export interface PlaybookCaptainDeps {
44
79
  loadModule?: (specifier: string) => Promise<unknown>;
45
80
  createSessionId?: () => string;
@@ -57,28 +92,142 @@ export interface PlaybookCaptainRegistryEntry {
57
92
  id: string;
58
93
  command: string;
59
94
  intent: string;
95
+ artifactSchema: 2;
60
96
  requiredRoleIds: readonly string[];
97
+ concurrentRoleSets: readonly (readonly string[])[];
61
98
  summaryPolicy?: PlaybookSummaryPolicy;
62
- validateOptions(captainOptions: unknown): unknown;
63
- createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
99
+ validateOptions(optionSlice: unknown): unknown;
100
+ createRuntime(options: unknown): PlaybookRuntime;
101
+ }
102
+
103
+ type PlaybookCaptainConversationSnapshot =
104
+ | { readonly kind: 'unopened' }
105
+ | { readonly kind: 'pinned'; readonly token: string }
106
+ | {
107
+ readonly kind: 'needsCatchUp';
108
+ readonly resume: string | false;
109
+ readonly afterJournalSeq: number;
110
+ }
111
+ | { readonly kind: 'needsSeeding' };
112
+
113
+ interface PlaybookCaptainJournalRecord {
114
+ readonly seq: number;
115
+ readonly turnId: number;
116
+ readonly kind: 'boss' | 'reply' | 'handoff' | 'action' | 'outcome';
117
+ readonly payload: JsonValue;
118
+ }
119
+
120
+ interface PlaybookCaptainFrameSnapshot {
121
+ readonly playbookId: string;
122
+ readonly sessionId: string;
123
+ readonly rootSessionId: string;
124
+ readonly depth: number;
125
+ readonly parentSessionId?: string;
126
+ readonly parentCallId?: string;
127
+ readonly options: JsonValue;
128
+ readonly roleBindings: Readonly<Record<string, string>>;
129
+ readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
130
+ }
131
+
132
+ interface PlaybookCaptainShellSnapshotFields {
133
+ readonly schemaVersion: 3;
134
+ readonly captain: {
135
+ readonly sessionId: string;
136
+ readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
137
+ readonly agent: SnapshotAgentEnvelope;
138
+ readonly conversation: PlaybookCaptainConversationSnapshot;
139
+ };
140
+ readonly playerSessions: Readonly<Record<string, PlayerLedgerSnapshotEntry>>;
141
+ /** Every Captain and engagement UUID issued during this logical session. */
142
+ readonly issuedSessionIds: readonly string[];
143
+ readonly sequences: {
144
+ readonly turn: number;
145
+ readonly journal: number;
146
+ };
147
+ readonly journal: readonly PlaybookCaptainJournalRecord[];
148
+ readonly lastAction?:
149
+ | 'respond'
150
+ | 'start'
151
+ | 'switch'
152
+ | 'dismiss'
153
+ | 'deliver'
154
+ | 'runtime';
155
+ readonly lastSettlementStatus?: 'ok' | 'rejected' | 'failed';
156
+ }
157
+
158
+ /**
159
+ * Complete JSON-safe durable state for one Captain shell between Boss turns.
160
+ * The discriminated mode keeps chat snapshots free of stale engagement data.
161
+ */
162
+ type PlaybookCaptainShellSnapshotValue =
163
+ PlaybookCaptainShellSnapshotFields &
164
+ (
165
+ | {
166
+ readonly mode: 'chat';
167
+ readonly frames?: never;
168
+ readonly pendingBossQuestions?: never;
169
+ readonly lastError?: never;
170
+ }
171
+ | {
172
+ readonly mode: 'engaged.parked';
173
+ /** Root-to-leaf engagement order. */
174
+ readonly frames: readonly PlaybookCaptainFrameSnapshot[];
175
+ readonly pendingBossQuestions?: JsonValue;
176
+ readonly lastError?: { readonly name: string; readonly message: string };
177
+ }
178
+ );
179
+
180
+ export type PlaybookCaptainShellSnapshot =
181
+ DeepReadonly<PlaybookCaptainShellSnapshotValue>;
182
+
183
+ /** tmux and headless front ends share this one durable Captain shell API. */
184
+ export interface PlaybookCaptainShell extends Captain {
185
+ exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
186
+ restore(
187
+ session: CaptainSession,
188
+ snapshot: PlaybookCaptainShellSnapshot,
189
+ ): Promise<void>;
64
190
  }
65
191
 
66
- // Per-enabled-playbook binding the shell resolves at init from
67
- // `captain.options.playbooks`: each playbook binds its local roles to
68
- // `<id>-<role>` host players and carries the generated visible set.
192
+ // Per-enabled-playbook binding the shell resolves at init from the exact
193
+ // normalized `captain.options.playbooks.<id>` role map.
69
194
  interface Enablement {
70
195
  entry: PlaybookCaptainRegistryEntry;
71
196
  command: string;
72
- optionInput: unknown;
73
- boundPlayers: readonly RegistryPlayer[];
74
- hostPlayerId: (localRole: string) => string;
197
+ options: JsonValue;
198
+ roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
75
199
  }
76
200
 
77
201
  interface EffectivePlayerBinding {
78
- readonly hostPlayerId: string;
79
- readonly player: RegistryPlayer;
202
+ readonly playerId: string;
203
+ readonly model: TuningSelection;
204
+ readonly effort: TuningSelection<Effort>;
205
+ readonly agent: SessionAgent;
206
+ }
207
+
208
+ interface PlayerTransactionOwner {
209
+ readonly frame: EngagementFrame;
210
+ readonly roleId: string;
211
+ readonly turnId: number;
212
+ readonly signal: AbortSignal;
213
+ readonly scope: object;
80
214
  }
81
215
 
216
+ type PlayerTransaction =
217
+ | (PlayerTransactionOwner & {
218
+ readonly phase: 'calling';
219
+ abandoned: boolean;
220
+ })
221
+ | (PlayerTransactionOwner & {
222
+ readonly phase: 'awaitingCommit';
223
+ readonly status: PlayerResult['status'];
224
+ readonly expectedToken: string | undefined;
225
+ })
226
+ | (PlayerTransactionOwner & {
227
+ readonly phase: 'quarantined';
228
+ readonly reason: string;
229
+ });
230
+
82
231
  interface EngagementFrame {
83
232
  entry: PlaybookCaptainRegistryEntry;
84
233
  enablement: Enablement;
@@ -87,7 +236,6 @@ interface EngagementFrame {
87
236
  rootSessionId: string;
88
237
  depth: number;
89
238
  playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
90
- playerResumeTokens: Map<string, string>;
91
239
  parent?: {
92
240
  frame: EngagementFrame;
93
241
  callId: string;
@@ -96,6 +244,7 @@ interface EngagementFrame {
96
244
  abortListener?: () => void;
97
245
  invocationSignal?: AbortSignal;
98
246
  inFlightHostCalls: Set<Promise<unknown>>;
247
+ playerCallScope?: object;
99
248
  // Set synchronously before this frame's runtime is asked to dispose, so a
100
249
  // telemetry payload emitted during disposal is never mistaken for evidence
101
250
  // about a live leaf. `disposePromise` cannot serve: it is assigned after
@@ -120,6 +269,26 @@ class VisibilityControlError extends Error {
120
269
  }
121
270
  }
122
271
 
272
+ class AgentSettingsPreflightError extends Error {
273
+ constructor(readonly rejection: unknown) {
274
+ super('agent rejected supplied complete call settings', {
275
+ cause: rejection,
276
+ });
277
+ this.name = 'AgentSettingsPreflightError';
278
+ }
279
+ }
280
+
281
+ async function classifySettingsCall<T>(call: () => Promise<T>): Promise<T> {
282
+ try {
283
+ return await call();
284
+ } catch (error) {
285
+ if (isAgentCallSettingsError(error)) {
286
+ throw new AgentSettingsPreflightError(error);
287
+ }
288
+ throw error;
289
+ }
290
+ }
291
+
123
292
  type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
124
293
 
125
294
  interface ControlLedger {
@@ -161,15 +330,19 @@ interface JournalRecord {
161
330
  type DurableCallKind = 'decision' | 'commandReply' | 'closingReply';
162
331
 
163
332
  /**
164
- * CAPTAIN-35: the three states the durable conversation can be in. Modeling
165
- * them explicitly keeps "this is the session's first call" (correctly
166
- * unseeded) distinct from "a reseed is owed" (must carry the journal digest)
167
- * one boolean cannot hold both, and conflating them left the turn after a
168
- * failed reseed starting a bare conversation with no session memory at all.
333
+ * CAPTAIN-35: the durable conversation distinguishes first use, healthy
334
+ * continuity, a settings-preflight catch-up on retained continuity, and a
335
+ * full reseed after continuity becomes suspect. Modeling those states keeps
336
+ * each recovery path explicit instead of overloading one presence boolean.
169
337
  */
170
338
  type DurableConversation =
171
339
  | { readonly kind: 'unopened' }
172
340
  | { readonly kind: 'pinned'; readonly token: string }
341
+ | {
342
+ readonly kind: 'needsCatchUp';
343
+ readonly resume: string | false;
344
+ readonly afterJournalSeq: number;
345
+ }
173
346
  | { readonly kind: 'needsSeeding' };
174
347
 
175
348
  /**
@@ -191,6 +364,8 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
191
364
  const INTERNAL_CAPTAIN_ID = 'captain';
192
365
  const UUID_PATTERN =
193
366
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
367
+ const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
368
+ const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
194
369
 
195
370
  interface TurnSummaryCounts {
196
371
  interruptions: number;
@@ -206,6 +381,8 @@ interface ActiveTurnSummary {
206
381
  /** The shell state of one Boss turn (DR-029). */
207
382
  interface ActiveTurn {
208
383
  readonly id: number;
384
+ /** Latest journal record represented in a successful Captain call. */
385
+ captainSyncedJournalSeq: number;
209
386
  /** The exact Boss text of the turn; never rewritten (CAPTAIN-31). */
210
387
  readonly bossText: string;
211
388
  /**
@@ -234,7 +411,9 @@ interface ActiveTurn {
234
411
  * with the Boss-appropriate failure reply instead of propagating, so the
235
412
  * Boss's next message settles normally.
236
413
  */
237
- controlFailure?: boolean;
414
+ readonly controlFailures: Set<unknown>;
415
+ /** Exact settings-preflight rejections that performed no provider work. */
416
+ readonly settingsPreflightFailures: Set<unknown>;
238
417
  /**
239
418
  * Every value that escaped an effect invocation this turn — a runtime
240
419
  * driven, an engagement constructed, a stack disposed, an advertised action
@@ -408,7 +587,16 @@ function pendingQuestionLines(pending: unknown): string[] {
408
587
  : typeof record.id === 'string'
409
588
  ? record.id
410
589
  : undefined;
411
- const player = typeof record.player === 'string' ? record.player : undefined;
590
+ const asker =
591
+ typeof record.asker === 'object' && record.asker !== null
592
+ ? (record.asker as Record<string, unknown>)
593
+ : undefined;
594
+ const askerLabel =
595
+ asker?.kind === 'captain'
596
+ ? 'Captain'
597
+ : asker?.kind === 'role' && typeof asker.roleId === 'string'
598
+ ? asker.roleId
599
+ : undefined;
412
600
  const text =
413
601
  typeof record.question === 'string'
414
602
  ? record.question
@@ -420,9 +608,9 @@ function pendingQuestionLines(pending: unknown): string[] {
420
608
  // second time would cut the line at the seam's limit and drop whatever
421
609
  // the shell had already written after the long part.
422
610
  const asked =
423
- player === undefined
611
+ askerLabel === undefined
424
612
  ? digestLine`${quoteEvidence(text)}`
425
- : digestLine`${quoteEvidence(player)} asks: ${quoteEvidence(text)}`;
613
+ : digestLine`${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
426
614
  const marker = id === undefined ? '' : digestLine`(${quoteEvidence(id)}) `;
427
615
  lines.push(`- ${marker}${asked}`);
428
616
  }
@@ -464,7 +652,10 @@ function renderJournalPayload(payload: JsonValue): string {
464
652
  return raw ?? 'null';
465
653
  }
466
654
 
467
- function renderReseedDigest(records: readonly JournalRecord[]): string {
655
+ function renderJournalDigest(
656
+ records: readonly JournalRecord[],
657
+ heading: string,
658
+ ): string {
468
659
  const lines = records.map(
469
660
  (record) =>
470
661
  `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(
@@ -472,12 +663,26 @@ function renderReseedDigest(records: readonly JournalRecord[]): string {
472
663
  )}`,
473
664
  );
474
665
  return [
475
- 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
666
+ heading,
476
667
  'The labeled ControlView and catalog digest blocks outrank conversation memory.',
477
668
  ...(lines.length === 0 ? ['(no earlier turns)'] : lines),
478
669
  ].join('\n');
479
670
  }
480
671
 
672
+ function renderReseedDigest(records: readonly JournalRecord[]): string {
673
+ return renderJournalDigest(
674
+ records,
675
+ 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
676
+ );
677
+ }
678
+
679
+ function renderCatchUpDigest(records: readonly JournalRecord[]): string {
680
+ return renderJournalDigest(
681
+ records,
682
+ 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.',
683
+ );
684
+ }
685
+
481
686
  // DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
482
687
  // internal control vocabulary.
483
688
  const CONTROL_VOCABULARY: readonly RegExp[] = [
@@ -597,16 +802,32 @@ function proseRejection(
597
802
  return undefined;
598
803
  }
599
804
 
805
+ type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
806
+
600
807
  // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
601
- // Cligent's Codex adapter rejects any `allowedTools` value — including the
602
- // empty list that expresses tool-free — because the supported Codex SDK
603
- // cannot enforce one, so requesting it fails every control call before the
604
- // model is reached. Omitting the option is the only way such an adapter can
605
- // run a control call at all; its isolation then rests on the authored
606
- // hidden-judge envelope below rather than on provider enforcement.
607
- const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT: ReadonlySet<string> = new Set([
608
- 'codex',
609
- ]);
808
+ // Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
809
+ // value — including the empty list that expresses tool-free — because their
810
+ // supported provider surfaces cannot enforce one, so requesting it fails
811
+ // every control call before the model is reached. Omitting the option is the
812
+ // only way such an adapter can run a control call at all; its isolation then
813
+ // rests on the authored hidden-judge envelope below rather than on provider
814
+ // enforcement.
815
+ const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
816
+ claude: 'provider-enforced',
817
+ codex: 'prompt-only',
818
+ gemini: 'provider-enforced',
819
+ kimi: 'prompt-only',
820
+ opencode: 'prompt-only',
821
+ } as const satisfies Readonly<Record<PlayerAdapterName, CaptainToolIsolation>>;
822
+
823
+ function requiresPromptOnlyToolIsolation(captainAdapter: string): boolean {
824
+ return (
825
+ Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
826
+ CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[
827
+ captainAdapter as PlayerAdapterName
828
+ ] === 'prompt-only'
829
+ );
830
+ }
610
831
 
611
832
  // The tool half of a control call's options. An empty allowlist means "no
612
833
  // tools available" and is distinct from omission, which grants the adapter's
@@ -617,7 +838,7 @@ function controlCallToolOptions(
617
838
  ): { allowedTools?: readonly string[] } {
618
839
  if (
619
840
  captainAdapter !== undefined &&
620
- ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)
841
+ requiresPromptOnlyToolIsolation(captainAdapter)
621
842
  ) {
622
843
  return {};
623
844
  }
@@ -637,14 +858,6 @@ function forwardedToolOptions(
637
858
  return { allowedTools: requested };
638
859
  }
639
860
 
640
- function readCaptainAdapter(options: unknown): string | undefined {
641
- if (typeof options !== 'object' || options === null) return undefined;
642
- const adapter = (options as Record<string, unknown>).captainAdapter;
643
- return typeof adapter === 'string' && adapter.length > 0
644
- ? adapter
645
- : undefined;
646
- }
647
-
648
861
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
649
862
 
650
863
  interface OutcomeReport {
@@ -755,25 +968,877 @@ function isValidRegistryEntry(
755
968
  ): value is PlaybookCaptainRegistryEntry {
756
969
  if (typeof value !== 'object' || value === null) return false;
757
970
  const e = value as Record<string, unknown>;
971
+ if (
972
+ !Array.isArray(e.requiredRoleIds) ||
973
+ e.requiredRoleIds.some(
974
+ (role) =>
975
+ typeof role !== 'string' ||
976
+ !ROLE_ID_PATTERN.test(role) ||
977
+ role === INTERNAL_CAPTAIN_ID,
978
+ ) ||
979
+ new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
980
+ !Array.isArray(e.concurrentRoleSets)
981
+ ) {
982
+ return false;
983
+ }
984
+ const roles = new Set(e.requiredRoleIds);
985
+ const concurrency = e.concurrentRoleSets as unknown[];
986
+ if (
987
+ concurrency.some(
988
+ (set) =>
989
+ !Array.isArray(set) ||
990
+ set.length < 2 ||
991
+ set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
992
+ new Set(set).size !== set.length,
993
+ ) ||
994
+ new Set(concurrency.map((set) => JSON.stringify(set))).size !==
995
+ concurrency.length
996
+ ) {
997
+ return false;
998
+ }
758
999
  return (
759
1000
  typeof e.id === 'string' &&
760
1001
  typeof e.command === 'string' &&
761
1002
  typeof e.intent === 'string' &&
762
- Array.isArray(e.requiredRoleIds) &&
1003
+ e.artifactSchema === 2 &&
763
1004
  typeof e.validateOptions === 'function' &&
764
1005
  typeof e.createRuntime === 'function'
765
1006
  );
766
1007
  }
767
1008
 
768
- function readPlaybooksConfig(
769
- options: unknown,
770
- ): Record<string, unknown> | undefined {
771
- if (typeof options !== 'object' || options === null) return undefined;
772
- const pb = (options as Record<string, unknown>).playbooks;
773
- if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
774
- return undefined;
1009
+ const SNAPSHOT_ACTIONS = new Set([
1010
+ 'respond',
1011
+ 'start',
1012
+ 'switch',
1013
+ 'dismiss',
1014
+ 'deliver',
1015
+ 'runtime',
1016
+ ] as const);
1017
+ const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
1018
+ 'ok',
1019
+ 'rejected',
1020
+ 'failed',
1021
+ ] as const);
1022
+ const SNAPSHOT_JOURNAL_KINDS = new Set([
1023
+ 'boss',
1024
+ 'reply',
1025
+ 'handoff',
1026
+ 'action',
1027
+ 'outcome',
1028
+ ] as const);
1029
+
1030
+ function snapshotRecord(
1031
+ value: JsonValue | undefined,
1032
+ path: string,
1033
+ ): Record<string, JsonValue> {
1034
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1035
+ throw new TypeError(`${path} must be an object`);
1036
+ }
1037
+ return value as Record<string, JsonValue>;
1038
+ }
1039
+
1040
+ function rejectSnapshotKeys(
1041
+ value: Readonly<Record<string, JsonValue>>,
1042
+ allowed: readonly string[],
1043
+ path: string,
1044
+ ): void {
1045
+ const allowedKeys = new Set(allowed);
1046
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
1047
+ if (unknown.length > 0) {
1048
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
1049
+ }
1050
+ }
1051
+
1052
+ function snapshotString(
1053
+ value: JsonValue | undefined,
1054
+ path: string,
1055
+ allowEmpty = false,
1056
+ ): string {
1057
+ if (
1058
+ typeof value !== 'string' ||
1059
+ (!allowEmpty && value.trim().length === 0)
1060
+ ) {
1061
+ throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
1062
+ }
1063
+ return value;
1064
+ }
1065
+
1066
+ function snapshotInteger(
1067
+ value: JsonValue | undefined,
1068
+ path: string,
1069
+ minimum = 0,
1070
+ ): number {
1071
+ if (!Number.isSafeInteger(value) || (value as number) < minimum) {
1072
+ throw new TypeError(`${path} must be an integer >= ${minimum}`);
1073
+ }
1074
+ return value as number;
1075
+ }
1076
+
1077
+ function snapshotUuid(value: JsonValue | undefined, path: string): string {
1078
+ const id = snapshotString(value, path);
1079
+ if (!UUID_PATTERN.test(id)) {
1080
+ throw new TypeError(`${path} must be a UUID`);
1081
+ }
1082
+ return id;
1083
+ }
1084
+
1085
+ function snapshotPermissions(
1086
+ value: JsonValue | undefined,
1087
+ path: string,
1088
+ ): PermissionPolicy | undefined {
1089
+ if (value === undefined) return undefined;
1090
+ const record = snapshotRecord(value, path);
1091
+ rejectSnapshotKeys(
1092
+ record,
1093
+ ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'],
1094
+ path,
1095
+ );
1096
+ const normalized: PermissionPolicy = {};
1097
+ if (record.mode !== undefined) {
1098
+ if (record.mode !== 'auto' && record.mode !== 'bypass') {
1099
+ throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
1100
+ }
1101
+ normalized.mode = record.mode;
1102
+ }
1103
+ for (const key of [
1104
+ 'fileWrite',
1105
+ 'shellExecute',
1106
+ 'networkAccess',
1107
+ ] as const) {
1108
+ const level = record[key];
1109
+ if (level === undefined) continue;
1110
+ if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
1111
+ throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
1112
+ }
1113
+ normalized[key] = level;
1114
+ }
1115
+ if (record.writablePaths !== undefined) {
1116
+ if (
1117
+ !Array.isArray(record.writablePaths) ||
1118
+ record.writablePaths.some(
1119
+ (entry) => typeof entry !== 'string' || entry.length === 0,
1120
+ )
1121
+ ) {
1122
+ throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
1123
+ }
1124
+ normalized.writablePaths = [...record.writablePaths];
1125
+ }
1126
+ return normalized;
1127
+ }
1128
+
1129
+ function livePermissions(
1130
+ value: DeepReadonly<PermissionPolicy> | undefined,
1131
+ ): PermissionPolicy | undefined {
1132
+ if (value === undefined) return undefined;
1133
+ return {
1134
+ ...(value.mode === undefined ? {} : { mode: value.mode }),
1135
+ ...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
1136
+ ...(value.shellExecute === undefined
1137
+ ? {}
1138
+ : { shellExecute: value.shellExecute }),
1139
+ ...(value.networkAccess === undefined
1140
+ ? {}
1141
+ : { networkAccess: value.networkAccess }),
1142
+ ...(value.writablePaths === undefined
1143
+ ? {}
1144
+ : { writablePaths: [...value.writablePaths] }),
1145
+ };
1146
+ }
1147
+
1148
+ function snapshotFixedAgent(
1149
+ value: JsonValue | undefined,
1150
+ path: string,
1151
+ ): SnapshotAgentEnvelope {
1152
+ const record = snapshotRecord(value, path);
1153
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
1154
+ const adapter = snapshotString(record.adapter, `${path}.adapter`);
1155
+ const instruction =
1156
+ record.instruction === undefined
1157
+ ? undefined
1158
+ : snapshotString(record.instruction, `${path}.instruction`, true);
1159
+ const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
1160
+ return {
1161
+ adapter,
1162
+ ...(instruction === undefined ? {} : { instruction }),
1163
+ ...(permissions === undefined ? {} : { permissions }),
1164
+ };
1165
+ }
1166
+
1167
+ function snapshotPlayerSessions(
1168
+ value: JsonValue | undefined,
1169
+ path: string,
1170
+ ): Readonly<Record<string, PlayerLedgerSnapshotEntry>> {
1171
+ const sessions = snapshotRecord(value, path);
1172
+ return Object.fromEntries(
1173
+ Object.entries(sessions).map(([playerId, raw]) => {
1174
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1175
+ throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
1176
+ }
1177
+ const record = snapshotRecord(raw, `${path}.${playerId}`);
1178
+ rejectSnapshotKeys(
1179
+ record,
1180
+ ['adapter', 'instruction', 'permissions', 'resumeToken'],
1181
+ `${path}.${playerId}`,
1182
+ );
1183
+ const fixed = snapshotFixedAgent(
1184
+ Object.fromEntries(
1185
+ Object.entries(record).filter(([key]) => key !== 'resumeToken'),
1186
+ ) as JsonValue,
1187
+ `${path}.${playerId}`,
1188
+ );
1189
+ const resumeToken =
1190
+ record.resumeToken === undefined
1191
+ ? undefined
1192
+ : snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
1193
+ return [
1194
+ playerId,
1195
+ { ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
1196
+ ];
1197
+ }),
1198
+ );
1199
+ }
1200
+
1201
+ function snapshotFrameRoleBindings(
1202
+ value: JsonValue | undefined,
1203
+ path: string,
1204
+ ): Readonly<Record<string, string>> {
1205
+ const bindings = snapshotRecord(value, path);
1206
+ return Object.fromEntries(
1207
+ Object.entries(bindings).map(([roleId, raw]) => {
1208
+ if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
1209
+ throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
1210
+ }
1211
+ const playerId = snapshotString(raw, `${path}.${roleId}`);
1212
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1213
+ throw new TypeError(`${path}.${roleId} has invalid player id`);
1214
+ }
1215
+ return [roleId, playerId];
1216
+ }),
1217
+ );
1218
+ }
1219
+
1220
+ function normalizeHostPlayerResult(
1221
+ value: unknown,
1222
+ expectedPlayerId: string,
1223
+ ): PlayerResult {
1224
+ const path = 'tmux-play delegated-player result';
1225
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1226
+ throw new TypeError(`${path} must be an object`);
1227
+ }
1228
+ const prototype = Object.getPrototypeOf(value) as unknown;
1229
+ if (prototype !== Object.prototype && prototype !== null) {
1230
+ throw new TypeError(`${path} must be a plain JSON object`);
1231
+ }
1232
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1233
+ const allowedKeys = new Set([
1234
+ 'status',
1235
+ 'playerId',
1236
+ 'turnId',
1237
+ 'resumeToken',
1238
+ 'finalText',
1239
+ 'error',
1240
+ ]);
1241
+ const normalized: Record<string, unknown> = {};
1242
+ for (const key of Reflect.ownKeys(descriptors)) {
1243
+ if (typeof key === 'symbol') {
1244
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
1245
+ }
1246
+ const descriptor = descriptors[key];
1247
+ if (!allowedKeys.has(key)) {
1248
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
1249
+ }
1250
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
1251
+ throw new TypeError(`${path}.${key} must be an enumerable data property`);
1252
+ }
1253
+ // Cligent deliberately exposes optional result members as own
1254
+ // `undefined` data properties. Omit only those members before taking the
1255
+ // immutable JSON snapshot; every other value still passes the strict
1256
+ // JSON validator below.
1257
+ if (descriptor.value !== undefined) normalized[key] = descriptor.value;
1258
+ }
1259
+ const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
1260
+ rejectSnapshotKeys(
1261
+ record,
1262
+ ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
1263
+ path,
1264
+ );
1265
+ if (record.playerId !== expectedPlayerId) {
1266
+ throw new TypeError(`${path}.playerId does not match the requested player`);
1267
+ }
1268
+ snapshotInteger(record.turnId, `${path}.turnId`, 1);
1269
+ return validatePlayerResult(
1270
+ {
1271
+ status: record.status,
1272
+ ...(record.resumeToken === undefined
1273
+ ? {}
1274
+ : { resumeToken: record.resumeToken }),
1275
+ ...(record.finalText === undefined
1276
+ ? {}
1277
+ : { finalText: record.finalText }),
1278
+ ...(record.error === undefined ? {} : { error: record.error }),
1279
+ },
1280
+ path,
1281
+ );
1282
+ }
1283
+
1284
+ /** Validate, detach, and freeze one untrusted shell snapshot. */
1285
+ export function assertPlaybookCaptainShellSnapshot(
1286
+ value: unknown,
1287
+ ): PlaybookCaptainShellSnapshot {
1288
+ const detached = snapshotJsonValue(value, 'Captain shell snapshot');
1289
+ const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
1290
+ const mode = snapshot.mode;
1291
+ const commonKeys = [
1292
+ 'schemaVersion',
1293
+ 'captain',
1294
+ 'playerSessions',
1295
+ 'issuedSessionIds',
1296
+ 'sequences',
1297
+ 'journal',
1298
+ 'lastAction',
1299
+ 'lastSettlementStatus',
1300
+ 'mode',
1301
+ ];
1302
+ if (mode === 'chat') {
1303
+ rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
1304
+ } else if (mode === 'engaged.parked') {
1305
+ rejectSnapshotKeys(
1306
+ snapshot,
1307
+ [
1308
+ ...commonKeys,
1309
+ 'frames',
1310
+ 'pendingBossQuestions',
1311
+ 'lastError',
1312
+ ],
1313
+ 'Captain shell snapshot',
1314
+ );
1315
+ } else {
1316
+ throw new TypeError(
1317
+ 'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
1318
+ );
1319
+ }
1320
+ if (snapshot.schemaVersion !== 3) {
1321
+ throw new TypeError(
1322
+ `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
1323
+ );
1324
+ }
1325
+
1326
+ const captain = snapshotRecord(
1327
+ snapshot.captain,
1328
+ 'Captain shell snapshot.captain',
1329
+ );
1330
+ rejectSnapshotKeys(
1331
+ captain,
1332
+ ['sessionId', 'runtime', 'agent', 'conversation'],
1333
+ 'Captain shell snapshot.captain',
1334
+ );
1335
+ const captainSessionId = snapshotUuid(
1336
+ captain.sessionId,
1337
+ 'Captain shell snapshot.captain.sessionId',
1338
+ );
1339
+ const captainRuntime = assertPlaybookRuntimeSnapshot(
1340
+ captain.runtime,
1341
+ INTERNAL_CAPTAIN_ID,
1342
+ );
1343
+ const captainAgent = snapshotFixedAgent(
1344
+ captain.agent,
1345
+ 'Captain shell snapshot.captain.agent',
1346
+ );
1347
+ const conversation = snapshotRecord(
1348
+ captain.conversation,
1349
+ 'Captain shell snapshot.captain.conversation',
1350
+ );
1351
+ let normalizedConversation: PlaybookCaptainConversationSnapshot;
1352
+ if (conversation.kind === 'pinned') {
1353
+ rejectSnapshotKeys(
1354
+ conversation,
1355
+ ['kind', 'token'],
1356
+ 'Captain shell snapshot.captain.conversation',
1357
+ );
1358
+ normalizedConversation = {
1359
+ kind: 'pinned',
1360
+ token: snapshotString(
1361
+ conversation.token,
1362
+ 'Captain shell snapshot.captain.conversation.token',
1363
+ ),
1364
+ };
1365
+ } else if (conversation.kind === 'needsCatchUp') {
1366
+ rejectSnapshotKeys(
1367
+ conversation,
1368
+ ['kind', 'resume', 'afterJournalSeq'],
1369
+ 'Captain shell snapshot.captain.conversation',
1370
+ );
1371
+ normalizedConversation = {
1372
+ kind: 'needsCatchUp',
1373
+ resume:
1374
+ conversation.resume === false
1375
+ ? false
1376
+ : snapshotString(
1377
+ conversation.resume,
1378
+ 'Captain shell snapshot.captain.conversation.resume',
1379
+ ),
1380
+ afterJournalSeq: snapshotInteger(
1381
+ conversation.afterJournalSeq,
1382
+ 'Captain shell snapshot.captain.conversation.afterJournalSeq',
1383
+ ),
1384
+ };
1385
+ } else if (
1386
+ conversation.kind === 'unopened' ||
1387
+ conversation.kind === 'needsSeeding'
1388
+ ) {
1389
+ rejectSnapshotKeys(
1390
+ conversation,
1391
+ ['kind'],
1392
+ 'Captain shell snapshot.captain.conversation',
1393
+ );
1394
+ normalizedConversation = { kind: conversation.kind };
1395
+ } else {
1396
+ throw new TypeError(
1397
+ 'Captain shell snapshot.captain.conversation.kind is not supported',
1398
+ );
1399
+ }
1400
+
1401
+ if (!Array.isArray(snapshot.issuedSessionIds)) {
1402
+ throw new TypeError(
1403
+ 'Captain shell snapshot.issuedSessionIds must be an array',
1404
+ );
1405
+ }
1406
+ const issued = snapshot.issuedSessionIds.map((id, index) =>
1407
+ snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`),
1408
+ );
1409
+ if (new Set(issued).size !== issued.length) {
1410
+ throw new TypeError(
1411
+ 'Captain shell snapshot.issuedSessionIds must not contain duplicates',
1412
+ );
1413
+ }
1414
+ if (issued[0] !== captainSessionId) {
1415
+ throw new TypeError(
1416
+ 'Captain shell snapshot Captain session id must be the first issued id',
1417
+ );
1418
+ }
1419
+
1420
+ const sequences = snapshotRecord(
1421
+ snapshot.sequences,
1422
+ 'Captain shell snapshot.sequences',
1423
+ );
1424
+ rejectSnapshotKeys(
1425
+ sequences,
1426
+ ['turn', 'journal'],
1427
+ 'Captain shell snapshot.sequences',
1428
+ );
1429
+ const turnSequence = snapshotInteger(
1430
+ sequences.turn,
1431
+ 'Captain shell snapshot.sequences.turn',
1432
+ );
1433
+ const journalSequence = snapshotInteger(
1434
+ sequences.journal,
1435
+ 'Captain shell snapshot.sequences.journal',
1436
+ );
1437
+
1438
+ if (!Array.isArray(snapshot.journal)) {
1439
+ throw new TypeError('Captain shell snapshot.journal must be an array');
1440
+ }
1441
+ const normalizedJournal: PlaybookCaptainJournalRecord[] = [];
1442
+ let previousTurn = 0;
1443
+ let bossRecords = 0;
1444
+ for (const [index, value] of snapshot.journal.entries()) {
1445
+ const record = snapshotRecord(
1446
+ value,
1447
+ `Captain shell snapshot.journal[${index}]`,
1448
+ );
1449
+ rejectSnapshotKeys(
1450
+ record,
1451
+ ['seq', 'turnId', 'kind', 'payload'],
1452
+ `Captain shell snapshot.journal[${index}]`,
1453
+ );
1454
+ const seq = snapshotInteger(
1455
+ record.seq,
1456
+ `Captain shell snapshot.journal[${index}].seq`,
1457
+ 1,
1458
+ );
1459
+ if (seq !== index + 1) {
1460
+ throw new TypeError(
1461
+ 'Captain shell snapshot journal sequence must be contiguous from one',
1462
+ );
1463
+ }
1464
+ const turnId = snapshotInteger(
1465
+ record.turnId,
1466
+ `Captain shell snapshot.journal[${index}].turnId`,
1467
+ 1,
1468
+ );
1469
+ if (turnId < previousTurn || turnId > turnSequence) {
1470
+ throw new TypeError(
1471
+ 'Captain shell snapshot journal turn ids must be ordered and in range',
1472
+ );
1473
+ }
1474
+ const kind = record.kind;
1475
+ if (
1476
+ typeof kind !== 'string' ||
1477
+ !SNAPSHOT_JOURNAL_KINDS.has(
1478
+ kind as PlaybookCaptainJournalRecord['kind'],
1479
+ )
1480
+ ) {
1481
+ throw new TypeError(
1482
+ `Captain shell snapshot.journal[${index}].kind is not supported`,
1483
+ );
1484
+ }
1485
+ if (turnId !== previousTurn) {
1486
+ if (turnId !== previousTurn + 1 || kind !== 'boss') {
1487
+ throw new TypeError(
1488
+ 'Captain shell snapshot journal must begin every turn with one boss record',
1489
+ );
1490
+ }
1491
+ bossRecords++;
1492
+ previousTurn = turnId;
1493
+ } else if (kind === 'boss') {
1494
+ throw new TypeError(
1495
+ 'Captain shell snapshot journal must contain one boss record per turn',
1496
+ );
1497
+ }
1498
+ normalizedJournal.push({
1499
+ seq,
1500
+ turnId,
1501
+ kind: kind as PlaybookCaptainJournalRecord['kind'],
1502
+ payload: record.payload as JsonValue,
1503
+ });
1504
+ }
1505
+ if (
1506
+ journalSequence !== normalizedJournal.length ||
1507
+ bossRecords !== turnSequence
1508
+ ) {
1509
+ throw new TypeError(
1510
+ 'Captain shell snapshot sequences do not match the complete journal',
1511
+ );
1512
+ }
1513
+ const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
1514
+ if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
1515
+ throw new TypeError(
1516
+ 'Captain shell snapshot history is empty exactly when its conversation is unopened',
1517
+ );
1518
+ }
1519
+ if (
1520
+ normalizedConversation.kind === 'needsCatchUp' &&
1521
+ normalizedConversation.afterJournalSeq >= journalSequence
1522
+ ) {
1523
+ throw new TypeError(
1524
+ 'Captain shell snapshot catch-up watermark must precede the current journal sequence',
1525
+ );
1526
+ }
1527
+ if (
1528
+ normalizedConversation.kind === 'needsCatchUp' &&
1529
+ ((normalizedConversation.resume === false) !==
1530
+ (normalizedConversation.afterJournalSeq === 0))
1531
+ ) {
1532
+ throw new TypeError(
1533
+ 'Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero',
1534
+ );
1535
+ }
1536
+
1537
+ let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
1538
+ if (snapshot.lastAction !== undefined) {
1539
+ if (
1540
+ typeof snapshot.lastAction !== 'string' ||
1541
+ !SNAPSHOT_ACTIONS.has(
1542
+ snapshot.lastAction as NonNullable<typeof lastAction>,
1543
+ )
1544
+ ) {
1545
+ throw new TypeError('Captain shell snapshot.lastAction is not supported');
1546
+ }
1547
+ lastAction = snapshot.lastAction as NonNullable<typeof lastAction>;
1548
+ }
1549
+ let lastSettlementStatus: PlaybookCaptainShellSnapshotFields['lastSettlementStatus'];
1550
+ if (snapshot.lastSettlementStatus !== undefined) {
1551
+ if (
1552
+ typeof snapshot.lastSettlementStatus !== 'string' ||
1553
+ !SNAPSHOT_SETTLEMENT_STATUSES.has(
1554
+ snapshot.lastSettlementStatus as NonNullable<
1555
+ typeof lastSettlementStatus
1556
+ >,
1557
+ )
1558
+ ) {
1559
+ throw new TypeError(
1560
+ 'Captain shell snapshot.lastSettlementStatus is not supported',
1561
+ );
1562
+ }
1563
+ lastSettlementStatus = snapshot.lastSettlementStatus as NonNullable<
1564
+ typeof lastSettlementStatus
1565
+ >;
1566
+ }
1567
+ const playerSessions = snapshotPlayerSessions(
1568
+ snapshot.playerSessions,
1569
+ 'Captain shell snapshot.playerSessions',
1570
+ );
1571
+ const common: PlaybookCaptainShellSnapshotFields = {
1572
+ schemaVersion: 3,
1573
+ captain: {
1574
+ sessionId: captainSessionId,
1575
+ runtime: captainRuntime,
1576
+ agent: captainAgent,
1577
+ conversation: normalizedConversation,
1578
+ },
1579
+ playerSessions,
1580
+ issuedSessionIds: issued,
1581
+ sequences: { turn: turnSequence, journal: journalSequence },
1582
+ journal: normalizedJournal,
1583
+ ...(lastAction === undefined ? {} : { lastAction }),
1584
+ ...(lastSettlementStatus === undefined
1585
+ ? {}
1586
+ : { lastSettlementStatus }),
1587
+ };
1588
+ if (
1589
+ captainRuntime.state.status !== 'active' ||
1590
+ !captainRuntime.state.quiescent ||
1591
+ !captainRuntime.state.tags.includes('playbook.parked') ||
1592
+ captainRuntime.suspendedCall !== undefined ||
1593
+ Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
1594
+ captainRuntime.pendingBossQuestions.length > 0
1595
+ ) {
1596
+ throw new TypeError(
1597
+ 'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
1598
+ );
1599
+ }
1600
+ if (captainRuntime.sequences.turn !== turnSequence) {
1601
+ throw new TypeError(
1602
+ 'Captain shell snapshot Captain and shell turn sequences must match',
1603
+ );
1604
+ }
1605
+ if (mode === 'chat') {
1606
+ return snapshotJsonValue(
1607
+ { ...common, mode },
1608
+ 'Captain shell snapshot',
1609
+ ) as unknown as PlaybookCaptainShellSnapshot;
1610
+ }
1611
+
1612
+ if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
1613
+ throw new TypeError(
1614
+ 'Captain shell snapshot.frames must be a non-empty array',
1615
+ );
1616
+ }
1617
+ const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
1618
+ for (const [index, value] of snapshot.frames.entries()) {
1619
+ const frame = snapshotRecord(
1620
+ value,
1621
+ `Captain shell snapshot.frames[${index}]`,
1622
+ );
1623
+ rejectSnapshotKeys(
1624
+ frame,
1625
+ [
1626
+ 'playbookId',
1627
+ 'sessionId',
1628
+ 'rootSessionId',
1629
+ 'depth',
1630
+ 'parentSessionId',
1631
+ 'parentCallId',
1632
+ 'options',
1633
+ 'roleBindings',
1634
+ 'runtime',
1635
+ ],
1636
+ `Captain shell snapshot.frames[${index}]`,
1637
+ );
1638
+ const playbookId = snapshotString(
1639
+ frame.playbookId,
1640
+ `Captain shell snapshot.frames[${index}].playbookId`,
1641
+ );
1642
+ const sessionId = snapshotUuid(
1643
+ frame.sessionId,
1644
+ `Captain shell snapshot.frames[${index}].sessionId`,
1645
+ );
1646
+ const rootSessionId = snapshotUuid(
1647
+ frame.rootSessionId,
1648
+ `Captain shell snapshot.frames[${index}].rootSessionId`,
1649
+ );
1650
+ const depth = snapshotInteger(
1651
+ frame.depth,
1652
+ `Captain shell snapshot.frames[${index}].depth`,
1653
+ );
1654
+ const parentSessionId =
1655
+ frame.parentSessionId === undefined
1656
+ ? undefined
1657
+ : snapshotUuid(
1658
+ frame.parentSessionId,
1659
+ `Captain shell snapshot.frames[${index}].parentSessionId`,
1660
+ );
1661
+ const parentCallId =
1662
+ frame.parentCallId === undefined
1663
+ ? undefined
1664
+ : snapshotString(
1665
+ frame.parentCallId,
1666
+ `Captain shell snapshot.frames[${index}].parentCallId`,
1667
+ );
1668
+ const runtime = assertPlaybookRuntimeSnapshot(
1669
+ frame.runtime,
1670
+ playbookId,
1671
+ { allowSuspendedCall: true },
1672
+ );
1673
+ const options = frame.options as JsonValue;
1674
+ const roleBindings = snapshotFrameRoleBindings(
1675
+ frame.roleBindings,
1676
+ `Captain shell snapshot.frames[${index}].roleBindings`,
1677
+ );
1678
+ normalizedFrames.push({
1679
+ playbookId,
1680
+ sessionId,
1681
+ rootSessionId,
1682
+ depth,
1683
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
1684
+ ...(parentCallId === undefined ? {} : { parentCallId }),
1685
+ options,
1686
+ roleBindings,
1687
+ runtime,
1688
+ });
1689
+ }
1690
+ let normalizedLastError:
1691
+ | { readonly name: string; readonly message: string }
1692
+ | undefined;
1693
+ if (snapshot.lastError !== undefined) {
1694
+ const error = snapshotRecord(
1695
+ snapshot.lastError,
1696
+ 'Captain shell snapshot.lastError',
1697
+ );
1698
+ rejectSnapshotKeys(
1699
+ error,
1700
+ ['name', 'message'],
1701
+ 'Captain shell snapshot.lastError',
1702
+ );
1703
+ normalizedLastError = {
1704
+ name: snapshotString(
1705
+ error.name,
1706
+ 'Captain shell snapshot.lastError.name',
1707
+ true,
1708
+ ),
1709
+ message: snapshotString(
1710
+ error.message,
1711
+ 'Captain shell snapshot.lastError.message',
1712
+ true,
1713
+ ),
1714
+ };
1715
+ }
1716
+ const activePlaybooks = new Set<string>();
1717
+ const activeSessionIds = new Set<string>([captainSessionId]);
1718
+ const issuedIds = new Set(issued);
1719
+ const rootSessionId = normalizedFrames[0]!.sessionId;
1720
+ for (const [index, frame] of normalizedFrames.entries()) {
1721
+ if (activePlaybooks.has(frame.playbookId)) {
1722
+ throw new TypeError(
1723
+ 'Captain shell snapshot engagement path must not contain a playbook cycle',
1724
+ );
1725
+ }
1726
+ activePlaybooks.add(frame.playbookId);
1727
+ if (activeSessionIds.has(frame.sessionId)) {
1728
+ throw new TypeError(
1729
+ 'Captain shell snapshot frame session ids must be unique',
1730
+ );
1731
+ }
1732
+ activeSessionIds.add(frame.sessionId);
1733
+ if (!issuedIds.has(frame.sessionId)) {
1734
+ throw new TypeError(
1735
+ 'Captain shell snapshot frame session id was not historically issued',
1736
+ );
1737
+ }
1738
+ if (
1739
+ frame.depth !== index ||
1740
+ frame.rootSessionId !== rootSessionId ||
1741
+ frame.runtime.state.status !== 'active' ||
1742
+ !frame.runtime.state.quiescent
1743
+ ) {
1744
+ throw new TypeError(
1745
+ 'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
1746
+ );
1747
+ }
1748
+ if (index === 0) {
1749
+ if (
1750
+ frame.sessionId !== frame.rootSessionId ||
1751
+ frame.parentSessionId !== undefined ||
1752
+ frame.parentCallId !== undefined
1753
+ ) {
1754
+ throw new TypeError(
1755
+ 'Captain shell snapshot root frame has child-only identity fields',
1756
+ );
1757
+ }
1758
+ } else {
1759
+ const parent = normalizedFrames[index - 1]!;
1760
+ const pending = parent.runtime.suspendedCall;
1761
+ if (
1762
+ frame.parentSessionId !== parent.sessionId ||
1763
+ frame.parentCallId === undefined
1764
+ ) {
1765
+ throw new TypeError(
1766
+ 'Captain shell snapshot child frame does not identify its immediate parent',
1767
+ );
1768
+ }
1769
+ if (
1770
+ !pending ||
1771
+ pending.callId !== frame.parentCallId ||
1772
+ pending.playbookId !== frame.playbookId ||
1773
+ pending.childSessionId !== frame.sessionId
1774
+ ) {
1775
+ throw new TypeError(
1776
+ 'Captain shell snapshot parent suspended call does not match its child edge',
1777
+ );
1778
+ }
1779
+ }
1780
+ for (const playerId of Object.values(frame.roleBindings)) {
1781
+ if (playerSessions[playerId] === undefined) {
1782
+ throw new TypeError(
1783
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`,
1784
+ );
1785
+ }
1786
+ }
1787
+ for (const question of frame.runtime.pendingBossQuestions) {
1788
+ if (
1789
+ question.asker.kind === 'role' &&
1790
+ frame.roleBindings[question.asker.roleId] === undefined
1791
+ ) {
1792
+ throw new TypeError(
1793
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`,
1794
+ );
1795
+ }
1796
+ }
1797
+ const projectedTokens = Object.fromEntries(
1798
+ Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
1799
+ const token = playerSessions[playerId]?.resumeToken;
1800
+ return token === undefined ? [] : [[role, token] as const];
1801
+ }),
1802
+ );
1803
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
1804
+ throw new TypeError(
1805
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`,
1806
+ );
1807
+ }
775
1808
  }
776
- return pb as Record<string, unknown>;
1809
+ const leafRuntime = normalizedFrames.at(-1)!.runtime;
1810
+ if (
1811
+ leafRuntime.suspendedCall !== undefined ||
1812
+ !leafRuntime.state.tags.includes('playbook.parked')
1813
+ ) {
1814
+ throw new TypeError(
1815
+ 'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
1816
+ );
1817
+ }
1818
+ if (
1819
+ !isDeepStrictEqual(
1820
+ snapshot.pendingBossQuestions ?? [],
1821
+ leafRuntime.pendingBossQuestions,
1822
+ )
1823
+ ) {
1824
+ throw new TypeError(
1825
+ 'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
1826
+ );
1827
+ }
1828
+ return snapshotJsonValue(
1829
+ {
1830
+ ...common,
1831
+ mode,
1832
+ frames: normalizedFrames,
1833
+ ...(snapshot.pendingBossQuestions === undefined
1834
+ ? {}
1835
+ : { pendingBossQuestions: snapshot.pendingBossQuestions }),
1836
+ ...(normalizedLastError === undefined
1837
+ ? {}
1838
+ : { lastError: normalizedLastError }),
1839
+ },
1840
+ 'Captain shell snapshot',
1841
+ ) as unknown as PlaybookCaptainShellSnapshot;
777
1842
  }
778
1843
 
779
1844
  interface BuiltRegistry {
@@ -781,14 +1846,115 @@ interface BuiltRegistry {
781
1846
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
782
1847
  byId: Map<string, PlaybookCaptainRegistryEntry>;
783
1848
  enablementById: Map<string, Enablement>;
1849
+ captainAgent: SessionAgent;
1850
+ playerAgents: Map<string, SessionAgent>;
1851
+ }
1852
+
1853
+ function snapshotTuningSelection(
1854
+ value: JsonValue | undefined,
1855
+ path: string,
1856
+ ): TuningSelection {
1857
+ const selection = snapshotRecord(value, path);
1858
+ if (selection.kind === 'provider-default') {
1859
+ rejectSnapshotKeys(selection, ['kind'], path);
1860
+ return { kind: 'provider-default' };
1861
+ }
1862
+ if (selection.kind === 'value') {
1863
+ rejectSnapshotKeys(selection, ['kind', 'value'], path);
1864
+ return {
1865
+ kind: 'value',
1866
+ value: snapshotString(selection.value, `${path}.value`),
1867
+ };
1868
+ }
1869
+ throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
1870
+ }
1871
+
1872
+ const EFFORT_VALUES: ReadonlySet<string> = new Set([
1873
+ 'on',
1874
+ 'minimal',
1875
+ 'low',
1876
+ 'medium',
1877
+ 'high',
1878
+ 'xhigh',
1879
+ 'max',
1880
+ 'ultra',
1881
+ 'ultracode',
1882
+ 'off',
1883
+ ]);
1884
+
1885
+ function snapshotEffortSelection(
1886
+ value: JsonValue | undefined,
1887
+ path: string,
1888
+ ): TuningSelection<Effort> {
1889
+ const selection = snapshotTuningSelection(value, path);
1890
+ if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
1891
+ throw new TypeError(`${path}.value is not a supported effort selection`);
1892
+ }
1893
+ return selection as TuningSelection<Effort>;
784
1894
  }
785
1895
 
786
- // Resolve the active registry at init from `captain.options.playbooks`
787
- // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
788
- // module and bound to namespaced `<id>-<role>` host players.
1896
+ function snapshotSessionAgent(
1897
+ value: JsonValue | undefined,
1898
+ path: string,
1899
+ ): SessionAgent {
1900
+ const agent = snapshotRecord(value, path);
1901
+ rejectSnapshotKeys(
1902
+ agent,
1903
+ ['adapter', 'model', 'effort', 'instruction', 'permissions'],
1904
+ path,
1905
+ );
1906
+ const fixed = snapshotFixedAgent(
1907
+ Object.fromEntries(
1908
+ Object.entries(agent).filter(
1909
+ ([key]) => key !== 'model' && key !== 'effort',
1910
+ ),
1911
+ ) as JsonValue,
1912
+ path,
1913
+ );
1914
+ return {
1915
+ adapter: fixed.adapter,
1916
+ ...(fixed.instruction === undefined
1917
+ ? {}
1918
+ : { instruction: fixed.instruction }),
1919
+ ...(fixed.permissions === undefined
1920
+ ? {}
1921
+ : { permissions: livePermissions(fixed.permissions) }),
1922
+ model: snapshotTuningSelection(agent.model, `${path}.model`),
1923
+ effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
1924
+ };
1925
+ }
1926
+
1927
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, 'model' | 'effort'> {
1928
+ return {
1929
+ adapter: agent.adapter,
1930
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1931
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1932
+ };
1933
+ }
1934
+
1935
+ function callSettings(
1936
+ agent: SessionAgent,
1937
+ tuning: Pick<SessionAgent, 'model' | 'effort'> = agent,
1938
+ ): AgentCallSettings {
1939
+ return {
1940
+ model: tuning.model,
1941
+ effort: tuning.effort,
1942
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1943
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1944
+ };
1945
+ }
1946
+
1947
+ function promptIdentity(binding: EffectivePlayerBinding): string {
1948
+ return binding.model.kind === 'value'
1949
+ ? binding.model.value
1950
+ : binding.agent.adapter;
1951
+ }
1952
+
1953
+ // Resolve the active registry at init from exact normalized role and session
1954
+ // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1955
+ // exists at this boundary.
789
1956
  async function buildEnablements(
790
1957
  options: unknown,
791
- players: readonly RegistryPlayer[],
792
1958
  loadModule: (specifier: string) => Promise<unknown>,
793
1959
  ): Promise<BuiltRegistry> {
794
1960
  const entries: PlaybookCaptainRegistryEntry[] = [];
@@ -796,10 +1962,63 @@ async function buildEnablements(
796
1962
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
797
1963
  const enablementById = new Map<string, Enablement>();
798
1964
 
799
- const config = readPlaybooksConfig(options);
800
- if (config === undefined) {
1965
+ const detached = snapshotJsonValue(options, 'captain.options');
1966
+ const top = snapshotRecord(detached, 'captain.options');
1967
+ rejectSnapshotKeys(
1968
+ top,
1969
+ ['playbooks', 'sessionAgents', 'captainAdapter'],
1970
+ 'captain.options',
1971
+ );
1972
+ const configValue = top.playbooks;
1973
+ if (
1974
+ typeof configValue !== 'object' ||
1975
+ configValue === null ||
1976
+ Array.isArray(configValue)
1977
+ ) {
801
1978
  throw new Error('captain.options.playbooks is required');
802
1979
  }
1980
+ const config = configValue as Record<string, JsonValue>;
1981
+
1982
+ const sessionAgents = snapshotRecord(
1983
+ top.sessionAgents,
1984
+ 'captain.options.sessionAgents',
1985
+ );
1986
+ rejectSnapshotKeys(
1987
+ sessionAgents,
1988
+ ['captain', 'players'],
1989
+ 'captain.options.sessionAgents',
1990
+ );
1991
+ const captainAgent = snapshotSessionAgent(
1992
+ sessionAgents.captain,
1993
+ 'captain.options.sessionAgents.captain',
1994
+ );
1995
+ if (
1996
+ top.captainAdapter !== undefined &&
1997
+ top.captainAdapter !== captainAgent.adapter
1998
+ ) {
1999
+ throw new Error(
2000
+ 'captain.options.captainAdapter must equal sessionAgents.captain.adapter',
2001
+ );
2002
+ }
2003
+ const playerAgentRecord = snapshotRecord(
2004
+ sessionAgents.players,
2005
+ 'captain.options.sessionAgents.players',
2006
+ );
2007
+ const playerAgents = new Map<string, SessionAgent>();
2008
+ for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
2009
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2010
+ throw new Error(
2011
+ `captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`,
2012
+ );
2013
+ }
2014
+ playerAgents.set(
2015
+ playerId,
2016
+ snapshotSessionAgent(
2017
+ agent,
2018
+ `captain.options.sessionAgents.players.${playerId}`,
2019
+ ),
2020
+ );
2021
+ }
803
2022
 
804
2023
  const ids = Object.keys(config);
805
2024
  if (ids.length === 0) {
@@ -817,7 +2036,12 @@ async function buildEnablements(
817
2036
  if (typeof block !== 'object' || block === null || Array.isArray(block)) {
818
2037
  throw new Error(`captain.options.playbooks.${id} must be an object`);
819
2038
  }
820
- const record = block as Record<string, unknown>;
2039
+ const record = block as Record<string, JsonValue>;
2040
+ rejectSnapshotKeys(
2041
+ record,
2042
+ ['from', 'command', 'roles', 'options'],
2043
+ `captain.options.playbooks.${id}`,
2044
+ );
821
2045
  const from = record.from;
822
2046
  if (typeof from !== 'string' || from.length === 0) {
823
2047
  throw new Error(
@@ -864,48 +2088,118 @@ async function buildEnablements(
864
2088
  `captain.options.playbooks has a duplicate effective command "${command}"`,
865
2089
  );
866
2090
  }
867
- const boundPlayers = entry.requiredRoleIds.map((role) => {
868
- const host = players.find((p) => p.id === `${entry.id}-${role}`);
869
- return {
870
- id: role,
871
- ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
872
- ...(host?.model !== undefined ? { model: host.model } : {}),
873
- };
874
- });
2091
+ const roleRecord = snapshotRecord(
2092
+ record.roles,
2093
+ `captain.options.playbooks.${id}.roles`,
2094
+ );
2095
+ const required = new Set(entry.requiredRoleIds);
2096
+ const configuredRoles = Object.keys(roleRecord);
2097
+ const missing = entry.requiredRoleIds.filter(
2098
+ (role) => !Object.hasOwn(roleRecord, role),
2099
+ );
2100
+ const extra = configuredRoles.filter((role) => !required.has(role));
2101
+ if (missing.length > 0 || extra.length > 0) {
2102
+ throw new Error(
2103
+ `captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`,
2104
+ );
2105
+ }
2106
+ const roleBindings = new Map<string, EffectivePlayerBinding>();
2107
+ for (const role of entry.requiredRoleIds) {
2108
+ const path = `captain.options.playbooks.${id}.roles.${role}`;
2109
+ const rawBinding = snapshotRecord(roleRecord[role], path);
2110
+ rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
2111
+ const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
2112
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2113
+ throw new Error(`${path}.playerId is not a canonical player id`);
2114
+ }
2115
+ const agent = playerAgents.get(playerId);
2116
+ if (!agent) {
2117
+ throw new Error(
2118
+ `${path}.playerId names absent session player ${JSON.stringify(playerId)}`,
2119
+ );
2120
+ }
2121
+ roleBindings.set(role, {
2122
+ playerId,
2123
+ model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
2124
+ effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
2125
+ agent,
2126
+ });
2127
+ }
2128
+ for (const concurrentRoles of entry.concurrentRoleSets) {
2129
+ const playerIds = concurrentRoles.map(
2130
+ (role) => roleBindings.get(role)!.playerId,
2131
+ );
2132
+ if (new Set(playerIds).size !== playerIds.length) {
2133
+ throw new Error(
2134
+ `captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`,
2135
+ );
2136
+ }
2137
+ }
2138
+ const validatedOptions = snapshotJsonValue(
2139
+ entry.validateOptions(record.options),
2140
+ `captain.options.playbooks.${id}.options`,
2141
+ );
875
2142
  entries.push(entry);
876
2143
  byId.set(entry.id, entry);
877
2144
  byCommand.set(command, entry);
878
2145
  enablementById.set(entry.id, {
879
2146
  entry,
880
2147
  command,
881
- optionInput: record.options,
882
- boundPlayers,
883
- hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
2148
+ options: validatedOptions,
2149
+ roleBindings,
884
2150
  });
885
2151
  }
886
- return { entries, byCommand, byId, enablementById };
2152
+ const referenced = new Set(
2153
+ [...enablementById.values()].flatMap((enablement) =>
2154
+ [...enablement.roleBindings.values()].map((binding) => binding.playerId),
2155
+ ),
2156
+ );
2157
+ const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
2158
+ if (unreferenced !== undefined) {
2159
+ throw new Error(
2160
+ `captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`,
2161
+ );
2162
+ }
2163
+ return {
2164
+ entries,
2165
+ byCommand,
2166
+ byId,
2167
+ enablementById,
2168
+ captainAgent,
2169
+ playerAgents,
2170
+ };
887
2171
  }
888
2172
 
889
2173
  export function createPlaybookCaptainShell(
890
2174
  options: unknown,
891
2175
  deps: PlaybookCaptainDeps = {},
892
- ): Captain {
2176
+ ): PlaybookCaptainShell {
893
2177
  const loadModule =
894
2178
  deps.loadModule ?? ((specifier: string) => import(specifier));
895
2179
  const createSessionId = deps.createSessionId ?? randomUUID;
896
2180
  const createCaptainRuntime: NonNullable<
897
2181
  PlaybookCaptainDeps['createCaptainRuntime']
898
2182
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
899
- // DR-013 A1: the launcher passes the resolved captain adapter through
900
- // `captain.options`; a raw `--config` launch leaves it undefined, which
901
- // keeps the enforced empty allowlist and its fail-closed behavior.
902
- const captainAdapter = readCaptainAdapter(options);
2183
+ let captainAgent: SessionAgent | undefined;
2184
+ let captainAdapter: string | undefined;
2185
+ let playerAgents = new Map<string, SessionAgent>();
2186
+ const playerLedger = new Map<string, PlayerLedgerEntry>();
2187
+ const playerTransactions = new Map<string, PlayerTransaction>();
903
2188
  let entries: readonly PlaybookCaptainRegistryEntry[] = [];
904
2189
  let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
905
2190
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
906
2191
  let enablementById = new Map<string, Enablement>();
907
2192
  let session: CaptainSession | undefined;
908
- let players: readonly RegistryPlayer[] = [];
2193
+ let sessionEmissionsOpen = false;
2194
+ let closedGateAttempted = false;
2195
+ let lifecycle:
2196
+ | 'fresh'
2197
+ | 'initializing'
2198
+ | 'restoring'
2199
+ | 'ready'
2200
+ | 'disposing'
2201
+ | 'closed' = 'fresh';
2202
+ let terminallyDisposed = false;
909
2203
  let activeContext: CaptainContext | undefined;
910
2204
  const frames: EngagementFrame[] = [];
911
2205
  let mode: ShellMode = 'chat';
@@ -918,18 +2212,58 @@ export function createPlaybookCaptainShell(
918
2212
  const captainQueue = new PQueue({ concurrency: 1 });
919
2213
  let disposing = false;
920
2214
 
2215
+ const admitHostBoundary = (): void => {
2216
+ if (sessionEmissionsOpen) return;
2217
+ closedGateAttempted = true;
2218
+ throw new Error('Captain shell host boundaries are closed during restore');
2219
+ };
2220
+
2221
+ const admitHostEmission = (): boolean => {
2222
+ if (sessionEmissionsOpen) return true;
2223
+ closedGateAttempted = true;
2224
+ return false;
2225
+ };
2226
+
2227
+ const installSession = (
2228
+ initSession: CaptainSession,
2229
+ emissionsOpen: boolean,
2230
+ ): void => {
2231
+ sessionEmissionsOpen = emissionsOpen;
2232
+ closedGateAttempted = false;
2233
+ session = {
2234
+ signal: initSession.signal,
2235
+ players: initSession.players,
2236
+ emitStatus: async (message, data) => {
2237
+ if (!admitHostEmission()) return;
2238
+ await initSession.emitStatus(message, data);
2239
+ },
2240
+ emitTelemetry: async (event) => {
2241
+ if (!admitHostEmission()) return;
2242
+ await initSession.emitTelemetry(event);
2243
+ },
2244
+ setVisiblePlayers: async (playerIds) => {
2245
+ if (!admitHostEmission()) return;
2246
+ await initSession.setVisiblePlayers(playerIds);
2247
+ },
2248
+ };
2249
+ };
2250
+
921
2251
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
922
2252
  let captainRuntime: PlaybookRuntime | undefined;
923
2253
  let captainSessionId: string | undefined;
924
- // CAPTAIN-35: the conversation is exactly one of unopened, pinned, or
925
- // owed-a-reseed. There is no fourth state in which a non-first call starts a
926
- // bare conversation.
2254
+ // CAPTAIN-35: a preflight settings rejection retains proven continuity but
2255
+ // records the exact journal suffix still owed; other continuity failures
2256
+ // require a fresh, full reseed.
927
2257
  let conversation: DurableConversation = { kind: 'unopened' };
928
2258
  let shuttingDown = false;
929
2259
  const journal: JournalRecord[] = [];
930
2260
  let journalSeq = 0;
931
2261
  let turnSequence = 0;
932
2262
  let activeTurn: ActiveTurn | undefined;
2263
+ // `PlayerSessionStore.restore` is authoritative only while the shell is
2264
+ // awaiting the exact owning runtime's restore during a closed-gate shell
2265
+ // restoration. A runtime cannot use the store as a general ledger writer.
2266
+ let restoringPlayerSessionFrame: EngagementFrame | undefined;
933
2267
  // The durable call the runtime is about to make, taken from the paired
934
2268
  // `captain.call.started` boundary the engine emits before the port call
935
2269
  // (CAPTAIN-9): the shell never infers a call's kind from its prose.
@@ -1018,7 +2352,10 @@ export function createPlaybookCaptainShell(
1018
2352
  // (CAPTAIN-5/CAPTAIN-6).
1019
2353
  ...(captainRuntime
1020
2354
  ? {
1021
- durableConversation: conversation.kind === 'pinned',
2355
+ durableConversation:
2356
+ conversation.kind === 'pinned' ||
2357
+ (conversation.kind === 'needsCatchUp' &&
2358
+ conversation.resume !== false),
1022
2359
  sessionJournal: true,
1023
2360
  }
1024
2361
  : {}),
@@ -1235,43 +2572,149 @@ export function createPlaybookCaptainShell(
1235
2572
  ) => Promise<PlaybookCallStart>;
1236
2573
 
1237
2574
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
1238
- callPlayer: async (playerId, prompt, signal, options) => {
1239
- if (!activeContext) {
2575
+ callPlayer: async (roleId, prompt, signal, options) => {
2576
+ admitHostBoundary();
2577
+ if (!activeContext || !activeTurn || !frame.playerCallScope) {
1240
2578
  throw new Error('callPlayer invoked outside a Boss turn');
1241
2579
  }
1242
2580
  const context = activeContext;
2581
+ const admittedTurn = activeTurn;
2582
+ const scope = frame.playerCallScope;
1243
2583
  signal.throwIfAborted();
1244
- const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
1245
- const result = await trackHostCall(
1246
- frame,
1247
- context.callPlayer(hostPlayerId, prompt, {
1248
- resume: options.resume,
1249
- }),
1250
- );
1251
- // CaptainContext is turn-scoped and cannot accept a narrower XState
1252
- // invocation signal. Recheck after the host call so a sibling
1253
- // cancellation is still reported as aborted and cannot rotate a
1254
- // stopped branch's player token in the linked runtime.
1255
- signal.throwIfAborted();
1256
- // CAPTAIN-20: only a player call that actually produced work is an
1257
- // interruption the Boss was spared. A call that errored or aborted
1258
- // saved nothing, so it never feeds the saved-counts gate.
1259
- const summary = activeTurnSummary;
1260
- if (summary && summaryIncludes(frame) && result.status === 'ok') {
1261
- summary.counts.interruptions++;
2584
+ const binding = bindingFor(frame, roleId);
2585
+ const ledger = playerLedger.get(binding.playerId);
2586
+ if (!ledger) {
2587
+ throw new Error(
2588
+ `${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
2589
+ );
1262
2590
  }
1263
- return {
1264
- status: result.status,
1265
- ...(result.resumeToken !== undefined
1266
- ? { resumeToken: result.resumeToken }
1267
- : {}),
1268
- ...(result.finalText !== undefined
1269
- ? { finalText: result.finalText }
1270
- : {}),
1271
- ...(result.error !== undefined ? { error: result.error } : {}),
2591
+ const expectedResume = ledger.resumeToken ?? false;
2592
+ if (options.resume !== expectedResume) {
2593
+ throw new Error(
2594
+ `${frameLabel(frame)} player continuation changed before dispatch`,
2595
+ );
2596
+ }
2597
+ if (playerTransactions.has(binding.playerId)) {
2598
+ throw new Error(
2599
+ `session player ${JSON.stringify(binding.playerId)} already has a call in flight`,
2600
+ );
2601
+ }
2602
+ const settings = callSettings(binding.agent, binding);
2603
+ const calling: PlayerTransaction = {
2604
+ phase: 'calling',
2605
+ frame,
2606
+ roleId,
2607
+ turnId: admittedTurn.id,
2608
+ signal,
2609
+ scope,
2610
+ abandoned: false,
1272
2611
  };
2612
+ playerTransactions.set(binding.playerId, calling);
2613
+ let result: PlayerResult;
2614
+ let hostResolved = false;
2615
+ try {
2616
+ let rawResult: unknown;
2617
+ try {
2618
+ rawResult = await trackHostCall(
2619
+ frame,
2620
+ classifySettingsCall(() =>
2621
+ context.callPlayer(binding.playerId, prompt, {
2622
+ resume: options.resume,
2623
+ settings,
2624
+ }),
2625
+ ),
2626
+ );
2627
+ hostResolved = true;
2628
+ } catch (error) {
2629
+ if (error instanceof AgentSettingsPreflightError) {
2630
+ if (
2631
+ playerTransactions.get(binding.playerId) !== calling ||
2632
+ calling.abandoned ||
2633
+ signal.aborted ||
2634
+ activeTurn !== admittedTurn ||
2635
+ frame.playerCallScope !== scope ||
2636
+ !frames.includes(frame)
2637
+ ) {
2638
+ if (playerTransactions.get(binding.playerId) === calling) {
2639
+ playerTransactions.delete(binding.playerId);
2640
+ }
2641
+ signal.throwIfAborted();
2642
+ throw new Error(
2643
+ `${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`,
2644
+ );
2645
+ }
2646
+ throw rememberSettingsPreflight(error.rejection);
2647
+ }
2648
+ throw error;
2649
+ }
2650
+ result = normalizeHostPlayerResult(rawResult, binding.playerId);
2651
+ const transitionRequired =
2652
+ result.resumeToken !== undefined || result.status === 'ok';
2653
+ if (
2654
+ playerTransactions.get(binding.playerId) !== calling ||
2655
+ calling.abandoned ||
2656
+ signal.aborted ||
2657
+ activeTurn !== admittedTurn ||
2658
+ frame.playerCallScope !== scope ||
2659
+ !frames.includes(frame)
2660
+ ) {
2661
+ if (playerTransactions.get(binding.playerId) === calling) {
2662
+ if (transitionRequired) {
2663
+ playerTransactions.set(binding.playerId, {
2664
+ phase: 'quarantined',
2665
+ frame,
2666
+ roleId,
2667
+ turnId: admittedTurn.id,
2668
+ signal,
2669
+ scope,
2670
+ reason:
2671
+ 'a transition-worthy result arrived after its runtime operation ended',
2672
+ });
2673
+ } else {
2674
+ playerTransactions.delete(binding.playerId);
2675
+ }
2676
+ }
2677
+ signal.throwIfAborted();
2678
+ throw new Error(
2679
+ `${frameLabel(frame)} player result arrived after its runtime operation ended`,
2680
+ );
2681
+ }
2682
+ if (transitionRequired) {
2683
+ playerTransactions.set(binding.playerId, {
2684
+ phase: 'awaitingCommit',
2685
+ frame,
2686
+ roleId,
2687
+ turnId: admittedTurn.id,
2688
+ signal,
2689
+ scope,
2690
+ status: result.status,
2691
+ expectedToken: result.resumeToken,
2692
+ });
2693
+ } else {
2694
+ playerTransactions.delete(binding.playerId);
2695
+ }
2696
+ } catch (error) {
2697
+ if (playerTransactions.get(binding.playerId) === calling) {
2698
+ if (hostResolved) {
2699
+ playerTransactions.set(binding.playerId, {
2700
+ phase: 'quarantined',
2701
+ frame,
2702
+ roleId,
2703
+ turnId: admittedTurn.id,
2704
+ signal,
2705
+ scope,
2706
+ reason: 'a late player result could not be validated',
2707
+ });
2708
+ } else {
2709
+ playerTransactions.delete(binding.playerId);
2710
+ }
2711
+ }
2712
+ throw error;
2713
+ }
2714
+ return result;
1273
2715
  },
1274
2716
  callCaptain: async (prompt, signal, options) => {
2717
+ admitHostBoundary();
1275
2718
  if (!activeContext) {
1276
2719
  throw new Error('callCaptain invoked outside a Boss turn');
1277
2720
  }
@@ -1295,6 +2738,7 @@ export function createPlaybookCaptainShell(
1295
2738
  };
1296
2739
  },
1297
2740
  callJudge: async (prompt, signal) => {
2741
+ admitHostBoundary();
1298
2742
  if (!activeContext) {
1299
2743
  throw new Error('callJudge invoked outside a Boss turn');
1300
2744
  }
@@ -1330,6 +2774,7 @@ export function createPlaybookCaptainShell(
1330
2774
  return result.finalText;
1331
2775
  },
1332
2776
  callPlaybook: (request, signal) => {
2777
+ admitHostBoundary();
1333
2778
  const opening = callNestedPlaybook(frame, request, signal);
1334
2779
  let exposed!: Promise<PlaybookCallStart>;
1335
2780
  const registerOpeningCleanup = (): void => {
@@ -1342,29 +2787,41 @@ export function createPlaybookCaptainShell(
1342
2787
  if (signal.aborted) registerOpeningCleanup();
1343
2788
  return exposed;
1344
2789
  },
1345
- emitStatus: async (message, data) => {
1346
- await requireSession().emitStatus(
1347
- message,
1348
- data as Record<string, unknown> | undefined,
2790
+ emitStatus: (message, data) => {
2791
+ if (!admitHostEmission()) return Promise.resolve();
2792
+ return trackHostCall(
2793
+ frame,
2794
+ (async (): Promise<void> => {
2795
+ await requireSession().emitStatus(
2796
+ message,
2797
+ data as Record<string, unknown> | undefined,
2798
+ );
2799
+ })(),
1349
2800
  );
1350
2801
  },
1351
- emitTelemetry: async (event) => {
1352
- if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1353
- await mirrorSubRuntimeTelemetry(frame, event.payload);
1354
- }
1355
- await requireSession().emitTelemetry(event);
2802
+ emitTelemetry: (event) => {
2803
+ if (!admitHostEmission()) return Promise.resolve();
2804
+ const emission = (async (): Promise<void> => {
2805
+ if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
2806
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
2807
+ }
2808
+ await requireSession().emitTelemetry(event);
2809
+ })();
2810
+ return trackHostCall(frame, emission);
1356
2811
  },
1357
2812
  });
1358
2813
 
1359
2814
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
1360
- // visibility for that playbook's generated host players. A pane
2815
+ // visibility for that playbook's explicitly bound session players. A pane
1361
2816
  // reconciliation failure is display-only in tmux-play and does not
1362
2817
  // reject; the legacy path carries no generated set and skips this.
1363
2818
  const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
1364
2819
  const ids = [...new Set(
1365
- [...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId),
2820
+ [...frame.playerBindings.values()].map(({ playerId }) => playerId),
1366
2821
  )];
1367
- if (!ids || ids.length === 0 || !activeContext) return;
2822
+ // A roleless frame does not ask a non-empty host roster to show `[]`:
2823
+ // tmux-play reserves that value for a genuinely empty configured roster.
2824
+ if (ids.length === 0 || !activeContext) return;
1368
2825
  try {
1369
2826
  await activeContext.setVisiblePlayers(ids);
1370
2827
  } catch (error) {
@@ -1402,44 +2859,20 @@ export function createPlaybookCaptainShell(
1402
2859
  return typeof stack === 'string' ? { ...compact, stack } : compact;
1403
2860
  };
1404
2861
 
2862
+ const makePlayerBindings = (
2863
+ enablement: Enablement,
2864
+ ): ReadonlyMap<string, EffectivePlayerBinding> => {
2865
+ return new Map(enablement.roleBindings);
2866
+ };
2867
+
1405
2868
  const makeFrame = (
1406
2869
  enablement: Enablement,
1407
2870
  parent?: { frame: EngagementFrame; callId: string },
1408
2871
  ): EngagementFrame => {
1409
2872
  const entry = enablement.entry;
1410
2873
  const sessionId = allocateSessionId();
1411
- const playerBindings = new Map<string, EffectivePlayerBinding>();
1412
- for (const role of entry.requiredRoleIds) {
1413
- let inherited: EffectivePlayerBinding | undefined;
1414
- for (
1415
- let ancestor = parent?.frame;
1416
- ancestor && inherited === undefined;
1417
- ancestor = ancestor.parent?.frame
1418
- ) {
1419
- inherited = ancestor.playerBindings.get(role);
1420
- }
1421
- if (inherited) {
1422
- playerBindings.set(role, inherited);
1423
- continue;
1424
- }
1425
- const configured = enablement.boundPlayers.find(
1426
- (player) => player.id === role,
1427
- ) ?? { id: role };
1428
- playerBindings.set(role, {
1429
- hostPlayerId: enablement.hostPlayerId(role),
1430
- player: configured,
1431
- });
1432
- }
1433
- const playerResumeTokens =
1434
- parent?.frame.playerResumeTokens ?? new Map<string, string>();
1435
- const runtime = entry.createRuntime({
1436
- captainOptions: enablement.optionInput,
1437
- players: [...playerBindings].map(([role, { player }]) => ({
1438
- id: role,
1439
- ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
1440
- ...(player.model === undefined ? {} : { model: player.model }),
1441
- })),
1442
- });
2874
+ const playerBindings = makePlayerBindings(enablement);
2875
+ const runtime = entry.createRuntime(enablement.options);
1443
2876
  return {
1444
2877
  entry,
1445
2878
  enablement,
@@ -1448,46 +2881,190 @@ export function createPlaybookCaptainShell(
1448
2881
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
1449
2882
  depth: parent ? parent.frame.depth + 1 : 0,
1450
2883
  playerBindings,
1451
- playerResumeTokens,
1452
2884
  ...(parent ? { parent } : {}),
1453
2885
  inFlightHostCalls: new Set(),
1454
2886
  };
1455
2887
  };
1456
2888
 
2889
+ const makeRestoredFrame = (
2890
+ enablement: Enablement,
2891
+ snapshot: PlaybookCaptainFrameSnapshot,
2892
+ parent?: { frame: EngagementFrame; callId: string },
2893
+ ): EngagementFrame => {
2894
+ const entry = enablement.entry;
2895
+ const playerBindings = makePlayerBindings(enablement);
2896
+ const runtime = entry.createRuntime(enablement.options);
2897
+ return {
2898
+ entry,
2899
+ enablement,
2900
+ runtime,
2901
+ sessionId: snapshot.sessionId,
2902
+ rootSessionId: snapshot.rootSessionId,
2903
+ depth: snapshot.depth,
2904
+ playerBindings,
2905
+ ...(parent ? { parent } : {}),
2906
+ state: snapshot.runtime.state,
2907
+ inFlightHostCalls: new Set(),
2908
+ };
2909
+ };
2910
+
1457
2911
  const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
1458
2912
  select(playerId) {
1459
2913
  const binding = bindingFor(frame, playerId);
1460
- return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
2914
+ return playerLedger.get(binding.playerId)?.resumeToken ?? false;
1461
2915
  },
1462
2916
  update(playerId, resumeToken) {
1463
2917
  const binding = bindingFor(frame, playerId);
1464
- if (resumeToken === undefined) {
1465
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1466
- } else {
1467
- frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
2918
+ const ledger = playerLedger.get(binding.playerId);
2919
+ if (!ledger) {
2920
+ throw new Error(
2921
+ `${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
2922
+ );
2923
+ }
2924
+ const pending = playerTransactions.get(binding.playerId);
2925
+ if (
2926
+ pending?.phase !== 'awaitingCommit' ||
2927
+ pending.frame !== frame ||
2928
+ pending.roleId !== playerId ||
2929
+ pending.scope !== frame.playerCallScope ||
2930
+ pending.expectedToken !== resumeToken
2931
+ ) {
2932
+ throw new Error(
2933
+ `${frameLabel(frame)} player update does not acknowledge a validated host result`,
2934
+ );
2935
+ }
2936
+ if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
2937
+ playerTransactions.set(binding.playerId, {
2938
+ phase: 'quarantined',
2939
+ frame: pending.frame,
2940
+ roleId: pending.roleId,
2941
+ turnId: pending.turnId,
2942
+ signal: pending.signal,
2943
+ scope: pending.scope,
2944
+ reason: 'the runtime aborted before committing a validated result',
2945
+ });
2946
+ throw new Error(
2947
+ `${frameLabel(frame)} rejected a late or aborted player continuation update`,
2948
+ );
2949
+ }
2950
+ try {
2951
+ if (resumeToken === undefined) delete ledger.resumeToken;
2952
+ else ledger.resumeToken = resumeToken;
2953
+ // CAPTAIN-20: a result counts only after the runtime validated it and
2954
+ // atomically published its authorized continuation transition.
2955
+ const summary = activeTurnSummary;
2956
+ if (
2957
+ pending.status === 'ok' &&
2958
+ summary &&
2959
+ summaryIncludes(frame)
2960
+ ) {
2961
+ summary.counts.interruptions++;
2962
+ }
2963
+ } finally {
2964
+ playerTransactions.delete(binding.playerId);
1468
2965
  }
1469
2966
  },
1470
2967
  snapshot() {
1471
2968
  const tokens: Record<string, string> = {};
1472
2969
  for (const [playerId, binding] of frame.playerBindings) {
1473
- const token = frame.playerResumeTokens.get(binding.hostPlayerId);
2970
+ const token = playerLedger.get(binding.playerId)?.resumeToken;
1474
2971
  if (token !== undefined) tokens[playerId] = token;
1475
2972
  }
1476
2973
  return tokens;
1477
2974
  },
1478
2975
  restore(tokens) {
1479
- for (const binding of frame.playerBindings.values()) {
1480
- frame.playerResumeTokens.delete(binding.hostPlayerId);
2976
+ if (
2977
+ lifecycle !== 'restoring' ||
2978
+ restoringPlayerSessionFrame !== frame
2979
+ ) {
2980
+ throw new Error(
2981
+ `${frameLabel(frame)} player-session restore is only available during shell restoration`,
2982
+ );
1481
2983
  }
2984
+ const byPlayer = new Map<string, string | undefined>();
1482
2985
  for (const [playerId, token] of Object.entries(tokens)) {
1483
2986
  const binding = bindingFor(frame, playerId);
1484
- frame.playerResumeTokens.set(binding.hostPlayerId, token);
2987
+ const previous = byPlayer.get(binding.playerId);
2988
+ if (previous !== undefined && previous !== token) {
2989
+ throw new Error(
2990
+ `${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`,
2991
+ );
2992
+ }
2993
+ byPlayer.set(binding.playerId, token);
2994
+ }
2995
+ for (const binding of frame.playerBindings.values()) {
2996
+ if (!byPlayer.has(binding.playerId)) byPlayer.set(binding.playerId, undefined);
2997
+ }
2998
+ for (const [playerId, token] of byPlayer) {
2999
+ const ledger = playerLedger.get(playerId);
3000
+ if (!ledger) {
3001
+ throw new Error(
3002
+ `${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`,
3003
+ );
3004
+ }
3005
+ if (token === undefined) delete ledger.resumeToken;
3006
+ else ledger.resumeToken = token;
1485
3007
  }
1486
3008
  },
1487
3009
  });
1488
3010
 
1489
- const initFrame = async (frame: EngagementFrame): Promise<void> => {
1490
- await frame.runtime.init({
3011
+ const closePlayerCallScope = (
3012
+ frame: EngagementFrame,
3013
+ scope: object,
3014
+ ): Error | undefined => {
3015
+ if (frame.playerCallScope === scope) frame.playerCallScope = undefined;
3016
+ const missing: string[] = [];
3017
+ for (const [playerId, transaction] of playerTransactions) {
3018
+ if (transaction.frame !== frame || transaction.scope !== scope) continue;
3019
+ if (transaction.phase === 'calling') {
3020
+ transaction.abandoned = true;
3021
+ } else {
3022
+ playerTransactions.set(playerId, {
3023
+ phase: 'quarantined',
3024
+ frame: transaction.frame,
3025
+ roleId: transaction.roleId,
3026
+ turnId: transaction.turnId,
3027
+ signal: transaction.signal,
3028
+ scope: transaction.scope,
3029
+ reason:
3030
+ transaction.phase === 'awaitingCommit'
3031
+ ? 'the runtime returned without committing a validated result'
3032
+ : transaction.reason,
3033
+ });
3034
+ }
3035
+ if (!transaction.signal.aborted) missing.push(playerId);
3036
+ }
3037
+ return missing.length === 0
3038
+ ? undefined
3039
+ : new Error(
3040
+ `${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`,
3041
+ );
3042
+ };
3043
+
3044
+ const runFrameOperation = async <T>(
3045
+ frame: EngagementFrame,
3046
+ operation: () => Promise<T>,
3047
+ ): Promise<T> => {
3048
+ if (frame.playerCallScope !== undefined) {
3049
+ throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
3050
+ }
3051
+ const scope = {};
3052
+ frame.playerCallScope = scope;
3053
+ let outcome:
3054
+ | { readonly ok: true; readonly value: T }
3055
+ | { readonly ok: false; readonly error: unknown };
3056
+ try {
3057
+ outcome = { ok: true, value: await operation() };
3058
+ } catch (error) {
3059
+ outcome = { ok: false, error };
3060
+ }
3061
+ const cleanupError = closePlayerCallScope(frame, scope);
3062
+ if (!outcome.ok) throw outcome.error;
3063
+ if (cleanupError !== undefined) throw cleanupError;
3064
+ return outcome.value;
3065
+ };
3066
+
3067
+ const frameSession = (frame: EngagementFrame) => ({
1491
3068
  sessionId: frame.sessionId,
1492
3069
  playbookId: frame.entry.id,
1493
3070
  rootSessionId: frame.rootSessionId,
@@ -1498,9 +3075,21 @@ export function createPlaybookCaptainShell(
1498
3075
  }
1499
3076
  : {}),
1500
3077
  depth: frame.depth,
3078
+ roleBindings: Object.fromEntries(
3079
+ [...frame.playerBindings].map(([roleId, binding]) => [
3080
+ roleId,
3081
+ {
3082
+ playerId: binding.playerId,
3083
+ promptIdentity: promptIdentity(binding),
3084
+ },
3085
+ ]),
3086
+ ),
1501
3087
  playerSessions: playerSessionStore(frame),
1502
3088
  ports: createPorts(frame),
1503
3089
  });
3090
+
3091
+ const initFrame = async (frame: EngagementFrame): Promise<void> => {
3092
+ await frame.runtime.init(frameSession(frame));
1504
3093
  };
1505
3094
 
1506
3095
  const clearLeafLedger = (): void => {
@@ -1818,8 +3407,10 @@ export function createPlaybookCaptainShell(
1818
3407
  // exception filed against an effect that never ran.
1819
3408
  await requestVisibility(frame);
1820
3409
  await setMode('engaged.driving', 'submit');
1821
- const result = await runEffect(() =>
1822
- frame.runtime.handleBossInput({ text, signal }),
3410
+ const result = await runFrameOperation(frame, () =>
3411
+ runEffect(() =>
3412
+ frame.runtime.handleBossInput({ text, signal }),
3413
+ ),
1823
3414
  );
1824
3415
  frame.state = result.state;
1825
3416
  return result;
@@ -1873,12 +3464,14 @@ export function createPlaybookCaptainShell(
1873
3464
  }
1874
3465
  let result: PlaybookRunResult;
1875
3466
  try {
1876
- result = await runEffect(() =>
1877
- parent.runtime.resumePlaybookCall({
1878
- callId: parentLink.callId,
1879
- result: effectiveResult,
1880
- signal: context.signal,
1881
- }),
3467
+ result = await runFrameOperation(parent, () =>
3468
+ runEffect(() =>
3469
+ parent.runtime.resumePlaybookCall({
3470
+ callId: parentLink.callId,
3471
+ result: effectiveResult,
3472
+ signal: context.signal,
3473
+ }),
3474
+ ),
1882
3475
  );
1883
3476
  } catch (error) {
1884
3477
  if (disposing || invocationSignal?.aborted) return;
@@ -1920,6 +3513,12 @@ export function createPlaybookCaptainShell(
1920
3513
  if (frame.parent) {
1921
3514
  await resumeParent(frame, callResultFor(frame, result), context);
1922
3515
  } else {
3516
+ // CAPTAIN-20: the root is still alive here, so this is the one
3517
+ // authoritative boundary that can retain the Boss-facing meaning its
3518
+ // runtime publishes before disposal removes the frame. The opaque run
3519
+ // output remains runtime-to-runtime data and never becomes Captain
3520
+ // evidence (CAPPLAY-10).
3521
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame));
1923
3522
  await runEffect(() => disposeStack('final'));
1924
3523
  }
1925
3524
  return;
@@ -2296,7 +3895,9 @@ export function createPlaybookCaptainShell(
2296
3895
  const pending = view.pendingQuestions.map(
2297
3896
  (question) =>
2298
3897
  digestLine`- (${quoteEvidence(question.questionId)}) ${quoteEvidence(
2299
- question.player,
3898
+ question.asker.kind === 'captain'
3899
+ ? 'Captain'
3900
+ : question.asker.roleId,
2300
3901
  )} asks: ${quoteEvidence(question.question)}`,
2301
3902
  );
2302
3903
  lines.push(
@@ -2420,6 +4021,7 @@ export function createPlaybookCaptainShell(
2420
4021
  try {
2421
4022
  await trackTurnCall(settlement.context.emitReply(settlement.text));
2422
4023
  } catch (error) {
4024
+ conversation = { kind: 'needsSeeding' };
2423
4025
  const normalized = normalizeErrorCompact(error) ?? {
2424
4026
  name: 'Error',
2425
4027
  message: String(error),
@@ -2526,8 +4128,11 @@ export function createPlaybookCaptainShell(
2526
4128
  * text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
2527
4129
  * the Captain may need to repeat.
2528
4130
  */
2529
- const reseedDigest = (): string => {
2530
- for (const record of journal) {
4131
+ const conversationDigest = (
4132
+ records: readonly JournalRecord[],
4133
+ render: (records: readonly JournalRecord[]) => string,
4134
+ ): string => {
4135
+ for (const record of records) {
2531
4136
  if (
2532
4137
  record.kind === 'action' &&
2533
4138
  typeof record.payload === 'object' &&
@@ -2538,14 +4143,47 @@ export function createPlaybookCaptainShell(
2538
4143
  if (typeof actionId === 'string') recordSuppliedIdentifier(actionId);
2539
4144
  }
2540
4145
  }
2541
- return renderReseedDigest(journal);
4146
+ return render(records);
2542
4147
  };
2543
4148
 
4149
+ const reseedDigest = (): string =>
4150
+ conversationDigest(journal, renderReseedDigest);
4151
+
4152
+ const catchUpDigest = (afterJournalSeq: number): string =>
4153
+ conversationDigest(
4154
+ journal.filter((record) => record.seq > afterJournalSeq),
4155
+ renderCatchUpDigest,
4156
+ );
4157
+
2544
4158
  const markControlFailure = <E>(error: E): E => {
2545
- if (activeTurn) activeTurn.controlFailure = true;
4159
+ activeTurn?.controlFailures.add(error);
2546
4160
  return error;
2547
4161
  };
2548
4162
 
4163
+ const markSettingsRejection = <E>(error: E): E => markControlFailure(error);
4164
+
4165
+ const rememberSettingsPreflight = <E>(error: E): E => {
4166
+ activeTurn?.settingsPreflightFailures.add(error);
4167
+ return markSettingsRejection(error);
4168
+ };
4169
+
4170
+ const markConversationCatchUp = (): void => {
4171
+ if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
4172
+ return;
4173
+ }
4174
+ conversation = {
4175
+ kind: 'needsCatchUp',
4176
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
4177
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
4178
+ };
4179
+ };
4180
+
4181
+ const markConversationUnsynchronized = (): void => {
4182
+ if (conversation.kind !== 'needsCatchUp') {
4183
+ conversation = { kind: 'needsSeeding' };
4184
+ }
4185
+ };
4186
+
2549
4187
  /**
2550
4188
  * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
2551
4189
  * engagement constructed, a stack disposed, an advertised action applied.
@@ -2600,6 +4238,7 @@ export function createPlaybookCaptainShell(
2600
4238
  context: CaptainContext,
2601
4239
  prompt: string,
2602
4240
  resume: string | false,
4241
+ attempt: { providerBoundaryEntered: boolean },
2603
4242
  ): Promise<{
2604
4243
  status: string;
2605
4244
  finalText?: string;
@@ -2608,11 +4247,15 @@ export function createPlaybookCaptainShell(
2608
4247
  }> => {
2609
4248
  const queued = captainQueue.add(async () => {
2610
4249
  context.signal.throwIfAborted();
2611
- const result = await context.callCaptain(prompt, {
2612
- visibility: 'hidden',
2613
- resume,
2614
- ...controlCallToolOptions(captainAdapter),
2615
- });
4250
+ attempt.providerBoundaryEntered = true;
4251
+ const result = await classifySettingsCall(() =>
4252
+ context.callCaptain(prompt, {
4253
+ visibility: 'hidden',
4254
+ resume,
4255
+ ...controlCallToolOptions(captainAdapter),
4256
+ settings: callSettings(captainAgent!),
4257
+ }),
4258
+ );
2616
4259
  context.signal.throwIfAborted();
2617
4260
  return result;
2618
4261
  });
@@ -2633,8 +4276,17 @@ export function createPlaybookCaptainShell(
2633
4276
  context: CaptainContext,
2634
4277
  compose: (options: { reseedDigest?: string }) => string,
2635
4278
  ): Promise<DurableCallOutcome> => {
2636
- const resume = conversation.kind === 'pinned' ? conversation.token : false;
4279
+ const startingConversation = conversation;
4280
+ const resume =
4281
+ startingConversation.kind === 'pinned'
4282
+ ? startingConversation.token
4283
+ : startingConversation.kind === 'needsCatchUp'
4284
+ ? startingConversation.resume
4285
+ : false;
2637
4286
  const seedFirstCall = conversation.kind === 'needsSeeding';
4287
+ const catchUpFirstCall = conversation.kind === 'needsCatchUp';
4288
+ const representedJournalSeq = journalSeq;
4289
+ const firstAttempt = { providerBoundaryEntered: false };
2638
4290
  let result:
2639
4291
  | { status: string; finalText?: string; resumeToken?: string; error?: string }
2640
4292
  | undefined;
@@ -2643,14 +4295,42 @@ export function createPlaybookCaptainShell(
2643
4295
  result = await rawDurableCall(
2644
4296
  context,
2645
4297
  compose(
2646
- seedFirstCall ? { reseedDigest: reseedDigest() } : {},
4298
+ seedFirstCall
4299
+ ? { reseedDigest: reseedDigest() }
4300
+ : startingConversation.kind === 'needsCatchUp'
4301
+ ? {
4302
+ reseedDigest: catchUpDigest(
4303
+ startingConversation.afterJournalSeq,
4304
+ ),
4305
+ }
4306
+ : {},
2647
4307
  ),
2648
4308
  resume,
4309
+ firstAttempt,
2649
4310
  );
2650
4311
  } catch (error) {
2651
4312
  if (context.signal.aborted) {
2652
- conversation = { kind: 'needsSeeding' };
2653
- throw error;
4313
+ if (firstAttempt.providerBoundaryEntered) {
4314
+ conversation = { kind: 'needsSeeding' };
4315
+ } else {
4316
+ markConversationUnsynchronized();
4317
+ }
4318
+ throw context.signal.reason ?? error;
4319
+ }
4320
+ if (error instanceof AgentSettingsPreflightError) {
4321
+ if (
4322
+ conversation.kind !== 'needsCatchUp' &&
4323
+ conversation.kind !== 'needsSeeding'
4324
+ ) {
4325
+ conversation = {
4326
+ kind: 'needsCatchUp',
4327
+ resume:
4328
+ conversation.kind === 'pinned' ? conversation.token : false,
4329
+ afterJournalSeq:
4330
+ activeTurn?.captainSyncedJournalSeq ?? 0,
4331
+ };
4332
+ }
4333
+ throw rememberSettingsPreflight(error.rejection);
2654
4334
  }
2655
4335
  failure = error;
2656
4336
  }
@@ -2661,11 +4341,14 @@ export function createPlaybookCaptainShell(
2661
4341
  result.resumeToken === undefined;
2662
4342
  if (!unsynchronized) {
2663
4343
  conversation = { kind: 'pinned', token: result!.resumeToken! };
4344
+ if (activeTurn) {
4345
+ activeTurn.captainSyncedJournalSeq = representedJournalSeq;
4346
+ }
2664
4347
  return {
2665
4348
  ...(result!.finalText !== undefined
2666
4349
  ? { finalText: result!.finalText }
2667
4350
  : {}),
2668
- correctiveSpent: seedFirstCall,
4351
+ correctiveSpent: seedFirstCall || catchUpFirstCall,
2669
4352
  };
2670
4353
  }
2671
4354
  // Only the model-side conversation is replaced: the stack, player
@@ -2677,16 +4360,21 @@ export function createPlaybookCaptainShell(
2677
4360
  let reissued:
2678
4361
  | { status: string; finalText?: string; resumeToken?: string; error?: string }
2679
4362
  | undefined;
4363
+ const reissueAttempt = { providerBoundaryEntered: false };
2680
4364
  try {
2681
4365
  reissued = await rawDurableCall(
2682
4366
  context,
2683
4367
  compose({ reseedDigest: recap }),
2684
4368
  false,
4369
+ reissueAttempt,
2685
4370
  );
2686
4371
  } catch (error) {
2687
4372
  if (context.signal.aborted) {
2688
4373
  conversation = { kind: 'needsSeeding' };
2689
- throw error;
4374
+ throw context.signal.reason ?? error;
4375
+ }
4376
+ if (error instanceof AgentSettingsPreflightError) {
4377
+ throw rememberSettingsPreflight(error.rejection);
2690
4378
  }
2691
4379
  throw markControlFailure(new CaptainContinuityError(error));
2692
4380
  }
@@ -2699,6 +4387,7 @@ export function createPlaybookCaptainShell(
2699
4387
  );
2700
4388
  }
2701
4389
  conversation = { kind: 'pinned', token: reissued.resumeToken };
4390
+ if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
2702
4391
  return {
2703
4392
  ...(reissued.finalText !== undefined
2704
4393
  ? { finalText: reissued.finalText }
@@ -2751,9 +4440,11 @@ export function createPlaybookCaptainShell(
2751
4440
 
2752
4441
  const captainPorts = (): PlaybookPorts => ({
2753
4442
  callPlayer: async () => {
4443
+ admitHostBoundary();
2754
4444
  throw new Error('the session Captain has no players');
2755
4445
  },
2756
4446
  callCaptain: async (prompt, signal) => {
4447
+ admitHostBoundary();
2757
4448
  if (!activeContext) {
2758
4449
  throw new Error('the session Captain called out of a Boss turn');
2759
4450
  }
@@ -2821,29 +4512,37 @@ export function createPlaybookCaptainShell(
2821
4512
  return { status: 'ok' as const, finalText: 'ok' };
2822
4513
  },
2823
4514
  callJudge: async () => {
4515
+ admitHostBoundary();
2824
4516
  throw new Error('the session Captain makes no judge call');
2825
4517
  },
2826
4518
  callPlaybook: async () => {
4519
+ admitHostBoundary();
2827
4520
  throw new Error('the session Captain never calls a playbook');
2828
4521
  },
2829
4522
  // CAPTAIN-9: the session Captain's human status stream is suppressed while
2830
4523
  // 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';
4524
+ emitStatus: async () => {
4525
+ admitHostEmission();
4526
+ },
4527
+ emitTelemetry: (event) => {
4528
+ if (!admitHostEmission()) return Promise.resolve();
4529
+ const emission = (async (): Promise<void> => {
4530
+ if (event.topic === 'playbook.trace') {
4531
+ const payload = payloadRecord(event.payload);
4532
+ if (payload?.type === 'captain.call.started') {
4533
+ const identity = payloadRecord(payload.payload);
4534
+ const stateId = identity?.stateId;
4535
+ servingCall =
4536
+ stateId === 'reporting'
4537
+ ? 'closingReply'
4538
+ : stateId === 'answeringCommand'
4539
+ ? 'commandReply'
4540
+ : 'decision';
4541
+ }
2844
4542
  }
2845
- }
2846
- await requireSession().emitTelemetry(event);
4543
+ await requireSession().emitTelemetry(event);
4544
+ })();
4545
+ return trackTurnCall(emission);
2847
4546
  },
2848
4547
  });
2849
4548
 
@@ -2922,6 +4621,15 @@ export function createPlaybookCaptainShell(
2922
4621
  }
2923
4622
  };
2924
4623
 
4624
+ const rootCompletionFact = (frame: EngagementFrame): string => {
4625
+ const published = leafStateDescription(frame);
4626
+ const description =
4627
+ published === undefined ? '' : compactEvidence(published);
4628
+ return description === ''
4629
+ ? `${frameLabel(frame)} completed; its runtime published no result description.`
4630
+ : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
4631
+ };
4632
+
2925
4633
  const leafStateSummary = (): string | undefined => {
2926
4634
  const leaf = leafFrame();
2927
4635
  if (!leaf) return 'idle: no playbook is engaged';
@@ -3086,7 +4794,7 @@ export function createPlaybookCaptainShell(
3086
4794
  message: String(error),
3087
4795
  };
3088
4796
  if (aborted) {
3089
- conversation = { kind: 'needsSeeding' };
4797
+ markConversationUnsynchronized();
3090
4798
  if (turn?.outcomePending) {
3091
4799
  turn.settlementFacts.push(
3092
4800
  `The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`,
@@ -3404,9 +5112,6 @@ export function createPlaybookCaptainShell(
3404
5112
  };
3405
5113
  throw outcome.error;
3406
5114
  }
3407
- if (!frames.includes(leaf)) {
3408
- facts.push(`${frameLabel(leaf)} finished and was disposed.`);
3409
- }
3410
5115
  const runFailed = drainRunFailureFacts(facts);
3411
5116
  const summary = leafStateSummary();
3412
5117
  turn.report = {
@@ -3489,7 +5194,11 @@ export function createPlaybookCaptainShell(
3489
5194
  // receipt rather than acting twice.
3490
5195
  const key = `turn-${turn.id}-apply-${actionId}`;
3491
5196
  const outcome = await withCounting(leaf, async () =>
3492
- runEffect(() => leaf.runtime.apply!({ actionId, key, signal })),
5197
+ runFrameOperation(leaf, () =>
5198
+ runEffect(() =>
5199
+ leaf.runtime.apply!({ actionId, key, signal }),
5200
+ ),
5201
+ ),
3493
5202
  );
3494
5203
  if (outcome.error !== undefined) throw outcome.error;
3495
5204
  const receipt = outcome.result!;
@@ -3581,9 +5290,14 @@ export function createPlaybookCaptainShell(
3581
5290
  };
3582
5291
 
3583
5292
  const controller: CaptainControllerPort = {
3584
- submit: (selection, signal) => settleSelection(selection, signal),
3585
- resolveParsedTurn: () =>
3586
- shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution,
5293
+ submit: (selection, signal) => {
5294
+ admitHostBoundary();
5295
+ return settleSelection(selection, signal);
5296
+ },
5297
+ resolveParsedTurn: () => {
5298
+ admitHostBoundary();
5299
+ return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
5300
+ },
3587
5301
  };
3588
5302
 
3589
5303
  // -------------------------------------------------------------------------
@@ -3648,7 +5362,11 @@ export function createPlaybookCaptainShell(
3648
5362
  // The durable Captain conversation did not receive the shell-authored
3649
5363
  // fallback. Force its next call through the journal so it cannot interpret
3650
5364
  // the Boss's follow-up without the reply the Boss was given this turn.
3651
- conversation = { kind: 'needsSeeding' };
5365
+ if (activeTurn?.settingsPreflightFailures.has(error)) {
5366
+ markConversationCatchUp();
5367
+ } else {
5368
+ markConversationUnsynchronized();
5369
+ }
3652
5370
  // A rejected presentation may already have emitted bytes. It is therefore
3653
5371
  // final for this turn even though the Promise did not prove it was shown.
3654
5372
  if (activeTurn?.presentationAttempted === true) return;
@@ -3661,48 +5379,551 @@ export function createPlaybookCaptainShell(
3661
5379
  });
3662
5380
  };
3663
5381
 
3664
- return {
3665
- async init(initSession: CaptainSession): Promise<void> {
3666
- session = initSession;
3667
- players = initSession.players;
3668
- const built = await buildEnablements(options, players, loadModule);
5382
+ const enabledCatalog = () =>
5383
+ Object.freeze(
5384
+ entries.map((entry) =>
5385
+ Object.freeze({
5386
+ id: entry.id,
5387
+ command: enablementById.get(entry.id)!.command,
5388
+ intent: entry.intent,
5389
+ }),
5390
+ ),
5391
+ );
5392
+
5393
+ const captainPlaybookSession = (
5394
+ id: string,
5395
+ ) => ({
5396
+ sessionId: id,
5397
+ playbookId: INTERNAL_CAPTAIN_ID,
5398
+ rootSessionId: id,
5399
+ depth: 0,
5400
+ roleBindings: {},
5401
+ ports: captainPorts(),
5402
+ });
5403
+
5404
+ const playerLedgerRecord = (): Readonly<
5405
+ Record<string, PlayerLedgerSnapshotEntry>
5406
+ > =>
5407
+ Object.fromEntries(
5408
+ [...playerLedger].map(([playerId, entry]) => [
5409
+ playerId,
5410
+ {
5411
+ adapter: entry.adapter,
5412
+ ...(entry.instruction === undefined
5413
+ ? {}
5414
+ : { instruction: entry.instruction }),
5415
+ ...(entry.permissions === undefined
5416
+ ? {}
5417
+ : { permissions: entry.permissions }),
5418
+ ...(entry.resumeToken === undefined
5419
+ ? {}
5420
+ : { resumeToken: entry.resumeToken }),
5421
+ },
5422
+ ]),
5423
+ );
5424
+
5425
+ const assertSnapshotMatchesEnablements = (
5426
+ snapshot: PlaybookCaptainShellSnapshot,
5427
+ enabled: ReadonlyMap<string, Enablement>,
5428
+ ): void => {
5429
+ if (
5430
+ captainAgent === undefined ||
5431
+ !isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))
5432
+ ) {
5433
+ throw new TypeError(
5434
+ 'Captain shell snapshot Captain agent is incompatible with current config',
5435
+ );
5436
+ }
5437
+ const configuredPlayerIds = [...playerAgents.keys()].sort();
5438
+ const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
5439
+ if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
5440
+ throw new TypeError(
5441
+ 'Captain shell snapshot player ledger does not match current referenced players',
5442
+ );
5443
+ }
5444
+ for (const playerId of configuredPlayerIds) {
5445
+ const saved = snapshot.playerSessions[playerId]!;
5446
+ const configured = playerAgents.get(playerId)!;
5447
+ const savedFixed = {
5448
+ adapter: saved.adapter,
5449
+ ...(saved.instruction === undefined
5450
+ ? {}
5451
+ : { instruction: saved.instruction }),
5452
+ ...(saved.permissions === undefined
5453
+ ? {}
5454
+ : { permissions: saved.permissions }),
5455
+ };
5456
+ if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
5457
+ throw new TypeError(
5458
+ `Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`,
5459
+ );
5460
+ }
5461
+ }
5462
+ if (snapshot.mode === 'chat') return;
5463
+ for (const frame of snapshot.frames) {
5464
+ const enablement = enabled.get(frame.playbookId);
5465
+ if (!enablement) {
5466
+ throw new TypeError(
5467
+ `Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
5468
+ );
5469
+ }
5470
+ const configuredBindings = Object.fromEntries(
5471
+ [...enablement.roleBindings].map(([role, binding]) => [
5472
+ role,
5473
+ binding.playerId,
5474
+ ]),
5475
+ );
5476
+ if (!isDeepStrictEqual(frame.options, enablement.options)) {
5477
+ throw new TypeError(
5478
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`,
5479
+ );
5480
+ }
5481
+ if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
5482
+ throw new TypeError(
5483
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
5484
+ );
5485
+ }
5486
+ }
5487
+ };
5488
+
5489
+ const safeCapturePoint = (): boolean => {
5490
+ if (
5491
+ lifecycle !== 'ready' ||
5492
+ terminallyDisposed ||
5493
+ !sessionEmissionsOpen ||
5494
+ !session ||
5495
+ session.signal.aborted ||
5496
+ !captainRuntime ||
5497
+ disposing ||
5498
+ shuttingDown ||
5499
+ activeContext !== undefined ||
5500
+ activeTurn !== undefined ||
5501
+ activeTurnHostCalls !== undefined ||
5502
+ activeTurnSummary !== undefined ||
5503
+ runFailureFacts !== undefined ||
5504
+ servingCall !== undefined ||
5505
+ decisionCall !== undefined ||
5506
+ playerTransactions.size !== 0 ||
5507
+ captainQueue.pending !== 0 ||
5508
+ captainQueue.size !== 0 ||
5509
+ (mode !== 'chat' && mode !== 'engaged.parked')
5510
+ ) {
5511
+ return false;
5512
+ }
5513
+ if (
5514
+ (mode === 'chat' &&
5515
+ (frames.length !== 0 ||
5516
+ pendingChildParents.size !== 0 ||
5517
+ pendingBossQuestions !== undefined ||
5518
+ lastError !== undefined)) ||
5519
+ (mode === 'engaged.parked' && frames.length === 0)
5520
+ ) {
5521
+ return false;
5522
+ }
5523
+ const expectedParents = new Set(frames.slice(0, -1));
5524
+ if (
5525
+ pendingChildParents.size !== expectedParents.size ||
5526
+ [...pendingChildParents].some((frame) => !expectedParents.has(frame))
5527
+ ) {
5528
+ return false;
5529
+ }
5530
+ return frames.every((frame, index) => {
5531
+ const liveInvocation =
5532
+ frame.invocationSignal !== undefined || frame.abortListener !== undefined;
5533
+ return (
5534
+ frame.state !== undefined &&
5535
+ frame.state.status === 'active' &&
5536
+ frame.state.quiescent &&
5537
+ !frame.disposing &&
5538
+ frame.disposePromise === undefined &&
5539
+ frame.removal === undefined &&
5540
+ frame.inFlightHostCalls.size === 0 &&
5541
+ (index === 0
5542
+ ? !liveInvocation
5543
+ : !liveInvocation ||
5544
+ (frame.invocationSignal !== undefined &&
5545
+ !frame.invocationSignal.aborted &&
5546
+ frame.abortListener !== undefined))
5547
+ );
5548
+ });
5549
+ };
5550
+
5551
+ const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
5552
+ if (
5553
+ !safeCapturePoint() ||
5554
+ !captainRuntime ||
5555
+ !captainSessionId ||
5556
+ !captainAgent
5557
+ ) {
5558
+ return undefined;
5559
+ }
5560
+ try {
5561
+ if (
5562
+ typeof captainRuntime.exportSnapshot !== 'function' ||
5563
+ typeof captainRuntime.restore !== 'function'
5564
+ ) {
5565
+ return undefined;
5566
+ }
5567
+ const captainSnapshot = captainRuntime.exportSnapshot();
5568
+ if (captainSnapshot === undefined) return undefined;
5569
+ const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
5570
+ for (const frame of frames) {
5571
+ if (
5572
+ typeof frame.runtime.exportSnapshot !== 'function' ||
5573
+ typeof frame.runtime.restore !== 'function'
5574
+ ) {
5575
+ return undefined;
5576
+ }
5577
+ const runtime = frame.runtime.exportSnapshot();
5578
+ if (
5579
+ runtime === undefined ||
5580
+ !isDeepStrictEqual(frame.state, runtime.state)
5581
+ ) {
5582
+ return undefined;
5583
+ }
5584
+ frameSnapshots.push({
5585
+ playbookId: frame.entry.id,
5586
+ sessionId: frame.sessionId,
5587
+ rootSessionId: frame.rootSessionId,
5588
+ depth: frame.depth,
5589
+ ...(frame.parent
5590
+ ? {
5591
+ parentSessionId: frame.parent.frame.sessionId,
5592
+ parentCallId: frame.parent.callId,
5593
+ }
5594
+ : {}),
5595
+ options: frame.enablement.options,
5596
+ roleBindings: Object.fromEntries(
5597
+ [...frame.playerBindings].map(([role, binding]) => [
5598
+ role,
5599
+ binding.playerId,
5600
+ ]),
5601
+ ),
5602
+ runtime,
5603
+ });
5604
+ }
5605
+ const common = {
5606
+ schemaVersion: 3 as const,
5607
+ captain: {
5608
+ sessionId: captainSessionId,
5609
+ runtime: captainSnapshot,
5610
+ agent: fixedAgent(captainAgent),
5611
+ conversation,
5612
+ },
5613
+ playerSessions: playerLedgerRecord(),
5614
+ issuedSessionIds: [...issuedSessionIds],
5615
+ sequences: { turn: turnSequence, journal: journalSeq },
5616
+ journal,
5617
+ ...(lastAction === undefined ? {} : { lastAction }),
5618
+ ...(lastSettlementStatus === undefined
5619
+ ? {}
5620
+ : { lastSettlementStatus }),
5621
+ };
5622
+ const candidate: PlaybookCaptainShellSnapshot =
5623
+ mode === 'chat'
5624
+ ? { ...common, mode }
5625
+ : {
5626
+ ...common,
5627
+ mode: 'engaged.parked',
5628
+ frames: frameSnapshots,
5629
+ ...(pendingBossQuestions === undefined
5630
+ ? {}
5631
+ : { pendingBossQuestions: pendingBossQuestions as JsonValue }),
5632
+ ...(lastError === undefined ? {} : { lastError }),
5633
+ };
5634
+ const normalized = assertPlaybookCaptainShellSnapshot(candidate);
5635
+ assertSnapshotMatchesEnablements(normalized, enablementById);
5636
+ return normalized;
5637
+ } catch {
5638
+ return undefined;
5639
+ }
5640
+ };
5641
+
5642
+ const verifyRestoredRuntime = (
5643
+ runtime: PlaybookRuntime,
5644
+ expected: PlaybookRuntimeSnapshot,
5645
+ playbookId: string,
5646
+ allowSuspendedCall: boolean,
5647
+ ): void => {
5648
+ const actual = runtime.exportSnapshot?.();
5649
+ if (actual === undefined) {
5650
+ throw new Error(
5651
+ `restored ${playbookId} runtime did not reach a safe snapshot boundary`,
5652
+ );
5653
+ }
5654
+ const normalized = assertPlaybookRuntimeSnapshot(
5655
+ actual,
5656
+ playbookId,
5657
+ allowSuspendedCall ? { allowSuspendedCall: true } : {},
5658
+ );
5659
+ for (const key of [
5660
+ 'state',
5661
+ 'roleResumeTokens',
5662
+ 'sequences',
5663
+ 'pendingBossQuestions',
5664
+ 'suspendedCall',
5665
+ ] as const) {
5666
+ if (!isDeepStrictEqual(normalized[key], expected[key])) {
5667
+ throw new Error(
5668
+ `restored ${playbookId} runtime changed snapshot field ${key}`,
5669
+ );
5670
+ }
5671
+ }
5672
+ };
5673
+
5674
+ const resetFailedRestore = async (): Promise<readonly unknown[]> => {
5675
+ const cleanupFailures: unknown[] = [];
5676
+ for (const frame of [...frames].reverse()) {
5677
+ frame.disposing = true;
5678
+ try {
5679
+ await frame.runtime.dispose();
5680
+ } catch (error) {
5681
+ cleanupFailures.push(error);
5682
+ }
5683
+ }
5684
+ if (captainRuntime) {
5685
+ shuttingDown = true;
5686
+ try {
5687
+ await captainRuntime.dispose();
5688
+ } catch (error) {
5689
+ cleanupFailures.push(error);
5690
+ }
5691
+ }
5692
+ frames.splice(0);
5693
+ pendingChildParents.clear();
5694
+ issuedSessionIds.clear();
5695
+ journal.splice(0);
5696
+ entries = [];
5697
+ byCommand = new Map();
5698
+ byId = new Map();
5699
+ enablementById = new Map();
5700
+ captainAgent = undefined;
5701
+ captainAdapter = undefined;
5702
+ playerAgents = new Map();
5703
+ playerLedger.clear();
5704
+ playerTransactions.clear();
5705
+ session = undefined;
5706
+ sessionEmissionsOpen = false;
5707
+ closedGateAttempted = false;
5708
+ captainRuntime = undefined;
5709
+ captainSessionId = undefined;
5710
+ conversation = { kind: 'unopened' };
5711
+ mode = 'chat';
5712
+ pendingBossQuestions = undefined;
5713
+ lastError = undefined;
5714
+ journalSeq = 0;
5715
+ turnSequence = 0;
5716
+ lastAction = undefined;
5717
+ lastSettlementStatus = undefined;
5718
+ shuttingDown = false;
5719
+ if (cleanupFailures.length > 0) {
5720
+ terminallyDisposed = true;
5721
+ lifecycle = 'closed';
5722
+ } else {
5723
+ lifecycle = 'fresh';
5724
+ }
5725
+ return cleanupFailures;
5726
+ };
5727
+
5728
+ const restoreShellSnapshot = async (
5729
+ initSession: CaptainSession,
5730
+ untrusted: PlaybookCaptainShellSnapshot,
5731
+ ): Promise<void> => {
5732
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
5733
+ throw new Error('Captain shell restore requires a fresh shell');
5734
+ }
5735
+ if (initSession.signal.aborted) {
5736
+ throw new Error('cannot restore an aborted Captain session');
5737
+ }
5738
+ lifecycle = 'restoring';
5739
+ try {
5740
+ const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
5741
+ const built = await buildEnablements(options, loadModule);
5742
+ captainAgent = built.captainAgent;
5743
+ captainAdapter = captainAgent.adapter;
5744
+ playerAgents = built.playerAgents;
5745
+ assertSnapshotMatchesEnablements(snapshot, built.enablementById);
5746
+
5747
+ installSession(initSession, false);
3669
5748
  entries = built.entries;
3670
5749
  byCommand = built.byCommand;
3671
5750
  byId = built.byId;
3672
5751
  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();
5752
+ for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
5753
+ playerLedger.set(playerId, {
5754
+ adapter: saved.adapter,
5755
+ ...(saved.instruction === undefined
5756
+ ? {}
5757
+ : { instruction: saved.instruction }),
5758
+ ...(saved.permissions === undefined
5759
+ ? {}
5760
+ : { permissions: livePermissions(saved.permissions) }),
5761
+ ...(saved.resumeToken === undefined
5762
+ ? {}
5763
+ : { resumeToken: saved.resumeToken }),
5764
+ });
5765
+ }
5766
+
3689
5767
  captainRuntime = createCaptainRuntime({
3690
- enabledPlaybooks: catalog,
5768
+ enabledPlaybooks: enabledCatalog(),
3691
5769
  controller,
3692
5770
  });
3693
- await captainRuntime.init({
3694
- sessionId: captainSessionId,
3695
- playbookId: INTERNAL_CAPTAIN_ID,
3696
- rootSessionId: captainSessionId,
3697
- depth: 0,
3698
- ports: captainPorts(),
3699
- });
5771
+ if (typeof captainRuntime.restore !== 'function') {
5772
+ throw new Error('session Captain runtime does not support restore');
5773
+ }
5774
+
5775
+ if (snapshot.mode === 'engaged.parked') {
5776
+ for (const [index, frameSnapshot] of snapshot.frames.entries()) {
5777
+ const parentFrame = frames.at(-1);
5778
+ const frame = makeRestoredFrame(
5779
+ enablementById.get(frameSnapshot.playbookId)!,
5780
+ frameSnapshot,
5781
+ index === 0
5782
+ ? undefined
5783
+ : {
5784
+ frame: parentFrame!,
5785
+ callId: frameSnapshot.parentCallId!,
5786
+ },
5787
+ );
5788
+ if (typeof frame.runtime.restore !== 'function') {
5789
+ throw new Error(
5790
+ `playbook ${frame.entry.id} runtime does not support restore`,
5791
+ );
5792
+ }
5793
+ frames.push(frame);
5794
+ if (parentFrame) pendingChildParents.add(parentFrame);
5795
+ }
5796
+ }
5797
+
5798
+ await captainRuntime.restore(
5799
+ captainPlaybookSession(snapshot.captain.sessionId),
5800
+ snapshot.captain.runtime,
5801
+ );
5802
+ if (snapshot.mode === 'engaged.parked') {
5803
+ for (const [index, frame] of frames.entries()) {
5804
+ restoringPlayerSessionFrame = frame;
5805
+ try {
5806
+ await frame.runtime.restore!(
5807
+ frameSession(frame),
5808
+ snapshot.frames[index]!.runtime,
5809
+ );
5810
+ } finally {
5811
+ restoringPlayerSessionFrame = undefined;
5812
+ }
5813
+ }
5814
+ }
5815
+ if (closedGateAttempted) {
5816
+ throw new Error('a runtime attempted a host emission during restore');
5817
+ }
5818
+ verifyRestoredRuntime(
5819
+ captainRuntime,
5820
+ snapshot.captain.runtime,
5821
+ INTERNAL_CAPTAIN_ID,
5822
+ false,
5823
+ );
5824
+ if (snapshot.mode === 'engaged.parked') {
5825
+ for (const [index, frame] of frames.entries()) {
5826
+ verifyRestoredRuntime(
5827
+ frame.runtime,
5828
+ snapshot.frames[index]!.runtime,
5829
+ frame.entry.id,
5830
+ true,
5831
+ );
5832
+ }
5833
+ if (
5834
+ !isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)
5835
+ ) {
5836
+ throw new Error(
5837
+ 'restored Captain-session player continuation changed during restore',
5838
+ );
5839
+ }
5840
+ }
5841
+ if (closedGateAttempted) {
5842
+ throw new Error('a runtime attempted a host emission during restore');
5843
+ }
5844
+ if (requireSession().signal.aborted) {
5845
+ throw new Error('Captain session aborted during restore');
5846
+ }
5847
+ for (const id of snapshot.issuedSessionIds) issuedSessionIds.add(id);
5848
+ journal.push(...snapshot.journal);
5849
+ journalSeq = snapshot.sequences.journal;
5850
+ turnSequence = snapshot.sequences.turn;
5851
+ conversation = snapshot.captain.conversation;
5852
+ captainSessionId = snapshot.captain.sessionId;
5853
+ lastAction = snapshot.lastAction;
5854
+ lastSettlementStatus = snapshot.lastSettlementStatus;
5855
+ mode = snapshot.mode;
5856
+ if (snapshot.mode === 'engaged.parked') {
5857
+ pendingBossQuestions = snapshot.pendingBossQuestions;
5858
+ lastError = snapshot.lastError;
5859
+ }
5860
+ lifecycle = 'ready';
5861
+ // The final commit is deliberately one non-throwing assignment.
5862
+ sessionEmissionsOpen = true;
5863
+ } catch (error) {
5864
+ const cleanupFailures = await resetFailedRestore();
5865
+ if (cleanupFailures.length > 0) {
5866
+ throw new AggregateError(
5867
+ [error, ...cleanupFailures],
5868
+ 'Captain shell restore and cleanup failed',
5869
+ );
5870
+ }
5871
+ throw error;
5872
+ }
5873
+ };
5874
+
5875
+ return {
5876
+ async init(initSession: CaptainSession): Promise<void> {
5877
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
5878
+ throw new Error('Captain shell requires a fresh instance for init');
5879
+ }
5880
+ if (initSession.signal.aborted) {
5881
+ throw new Error('cannot initialize an aborted Captain session');
5882
+ }
5883
+ lifecycle = 'initializing';
5884
+ try {
5885
+ installSession(initSession, true);
5886
+ const built = await buildEnablements(options, loadModule);
5887
+ entries = built.entries;
5888
+ byCommand = built.byCommand;
5889
+ byId = built.byId;
5890
+ enablementById = built.enablementById;
5891
+ captainAgent = built.captainAgent;
5892
+ captainAdapter = captainAgent.adapter;
5893
+ playerAgents = built.playerAgents;
5894
+ for (const [playerId, agent] of playerAgents) {
5895
+ playerLedger.set(playerId, fixedAgent(agent));
5896
+ }
5897
+ await setMode('chat', 'init');
5898
+ // CAPTAIN-16: the session Captain exists from `init`, outside the
5899
+ // engagement stack, with its own playbook session id.
5900
+ captainSessionId = allocateSessionId();
5901
+ captainRuntime = createCaptainRuntime({
5902
+ enabledPlaybooks: enabledCatalog(),
5903
+ controller,
5904
+ });
5905
+ await captainRuntime.init(captainPlaybookSession(captainSessionId));
5906
+ lifecycle = 'ready';
5907
+ } catch (error) {
5908
+ terminallyDisposed = true;
5909
+ lifecycle = 'closed';
5910
+ throw error;
5911
+ }
3700
5912
  },
3701
5913
 
5914
+ exportSnapshot: exportShellSnapshot,
5915
+
5916
+ restore: restoreShellSnapshot,
5917
+
3702
5918
  async handleBossTurn(
3703
5919
  turn: BossTurn,
3704
5920
  context: CaptainContext,
3705
5921
  ): Promise<void> {
5922
+ if (lifecycle !== 'ready' || terminallyDisposed) {
5923
+ throw new Error(
5924
+ 'init must be called first, or restore must complete before handling a Boss turn',
5925
+ );
5926
+ }
3706
5927
  requireSession();
3707
5928
  if (!captainRuntime) {
3708
5929
  throw new Error('init must be called first');
@@ -3719,6 +5940,7 @@ export function createPlaybookCaptainShell(
3719
5940
  const parsed = resolveCommandTurn(turn.prompt);
3720
5941
  activeTurn = {
3721
5942
  id: ++turnSequence,
5943
+ captainSyncedJournalSeq: journalSeq,
3722
5944
  bossText: turn.prompt,
3723
5945
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
3724
5946
  ...(parsed ? { resolution: parsed.resolution } : {}),
@@ -3726,6 +5948,8 @@ export function createPlaybookCaptainShell(
3726
5948
  presentationAttempted: false,
3727
5949
  settlementFacts: [],
3728
5950
  effectThrows: new Set<unknown>(),
5951
+ controlFailures: new Set<unknown>(),
5952
+ settingsPreflightFailures: new Set<unknown>(),
3729
5953
  suppliedIdentifiers: new Set<string>(),
3730
5954
  outcomeRecorded: false,
3731
5955
  };
@@ -3746,13 +5970,16 @@ export function createPlaybookCaptainShell(
3746
5970
  new Error('the session Captain turn failed at its boundary'),
3747
5971
  );
3748
5972
  } else if (result.outcome === 'aborted') {
3749
- conversation = { kind: 'needsSeeding' };
5973
+ markConversationUnsynchronized();
3750
5974
  if (activeTurn && !activeTurn.outcomeRecorded) {
3751
5975
  activeTurn.settlementFacts.push(
3752
5976
  'The Boss turn was aborted before it settled; no action was repeated automatically.',
3753
5977
  );
3754
5978
  journalOutcome([...activeTurn.settlementFacts]);
3755
5979
  }
5980
+ if (context.signal.aborted) {
5981
+ throw context.signal.reason;
5982
+ }
3756
5983
  } else if (
3757
5984
  result.outcome !== 'suspended' &&
3758
5985
  !context.signal.aborted &&
@@ -3771,10 +5998,10 @@ export function createPlaybookCaptainShell(
3771
5998
  }
3772
5999
  } catch (error) {
3773
6000
  if (context.signal.aborted) {
3774
- conversation = { kind: 'needsSeeding' };
6001
+ markConversationUnsynchronized();
3775
6002
  throw error;
3776
6003
  }
3777
- const controlFailure = activeTurn?.controlFailure === true;
6004
+ const controlFailure = activeTurn?.controlFailures.has(error) === true;
3778
6005
  await settleTurnFailure(context, error);
3779
6006
  if (activeTurn?.presentationError !== undefined) {
3780
6007
  throw activeTurn.presentationError;
@@ -3784,7 +6011,7 @@ export function createPlaybookCaptainShell(
3784
6011
  if (!controlFailure) throw error;
3785
6012
  } finally {
3786
6013
  if (context.signal.aborted) {
3787
- conversation = { kind: 'needsSeeding' };
6014
+ markConversationUnsynchronized();
3788
6015
  if (activeTurn && !activeTurn.outcomeRecorded) {
3789
6016
  activeTurn.settlementFacts.push(
3790
6017
  'The Boss turn was aborted before it settled; no action was repeated automatically.',
@@ -3804,11 +6031,17 @@ export function createPlaybookCaptainShell(
3804
6031
  },
3805
6032
 
3806
6033
  async prepareDispose(): Promise<void> {
6034
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
6035
+ throw new Error('cannot dispose while Captain shell setup is in progress');
6036
+ }
3807
6037
  activeContext = undefined;
3808
6038
  await teardown();
3809
6039
  },
3810
6040
 
3811
6041
  async dispose(): Promise<void> {
6042
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
6043
+ throw new Error('cannot dispose while Captain shell setup is in progress');
6044
+ }
3812
6045
  activeContext = undefined;
3813
6046
  await teardown();
3814
6047
  },
@@ -3817,6 +6050,8 @@ export function createPlaybookCaptainShell(
3817
6050
  // CAPTAIN-16: dispose every active frame from leaf to root, then the
3818
6051
  // session Captain last.
3819
6052
  async function teardown(): Promise<void> {
6053
+ terminallyDisposed = true;
6054
+ lifecycle = 'disposing';
3820
6055
  let failure: unknown;
3821
6056
  try {
3822
6057
  await disposeStack('dispose');
@@ -3833,6 +6068,10 @@ export function createPlaybookCaptainShell(
3833
6068
  failure ??= error;
3834
6069
  }
3835
6070
  }
6071
+ // Quarantine is session-wide by design. Only terminal teardown may drop
6072
+ // its ownership after every frame host call and the Captain are drained.
6073
+ playerTransactions.clear();
6074
+ lifecycle = 'closed';
3836
6075
  if (failure !== undefined) throw failure;
3837
6076
  }
3838
6077
  }