@sublang/playbook 7.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 (61) hide show
  1. package/README.md +17 -4
  2. package/docs/cli.md +74 -29
  3. package/docs/configuration.md +209 -112
  4. package/docs/embedding.md +71 -25
  5. package/package.json +4 -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 +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  18. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  22. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  48. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +27 -23
  53. package/slc/link.md +113 -93
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +20 -16
  56. package/src/runtime.ts +19 -23
  57. package/src/xstate-playbook-runtime.d.ts +21 -17
  58. package/src/xstate-playbook-runtime.js +241 -149
  59. package/src/xstate-playbook-runtime.ts +331 -178
  60. package/src/xstate-runtime.js +63 -24
  61. package/src/xstate-runtime.ts +96 -28
@@ -5,12 +5,17 @@ import { randomUUID } from 'node:crypto';
5
5
  import { isDeepStrictEqual } from 'node:util';
6
6
  import PQueue from 'p-queue';
7
7
 
8
- import type {
9
- BossTurn,
10
- Captain,
11
- CaptainContext,
12
- 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,
13
17
  } from '@sublang/cligent/tmux-play';
18
+ import type { Effort, PermissionPolicy } from '@sublang/cligent';
14
19
  import type {
15
20
  JsonValue,
16
21
  NormalizedError,
@@ -22,6 +27,7 @@ import type {
22
27
  PlaybookRunResult,
23
28
  PlaybookRuntime,
24
29
  PlaybookRuntimeSnapshot,
30
+ PlayerResult,
25
31
  PlayerSessionStore,
26
32
  PlaybookState,
27
33
  } from '@sublang/playbook/runtime';
@@ -30,6 +36,7 @@ import {
30
36
  hiddenControlEnvelope,
31
37
  registerPlaybookAbortCleanup,
32
38
  snapshotJsonValue,
39
+ validatePlayerResult,
33
40
  } from '../../../src/xstate-runtime.js';
34
41
  import createDefaultCaptainRuntime, {
35
42
  type CaptainControllerPort,
@@ -37,13 +44,37 @@ import createDefaultCaptainRuntime, {
37
44
  type CaptainParsedResolution,
38
45
  type SettlementEvidence,
39
46
  } from '../captain.playbook/captain.playbook.js';
40
- 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
+ }
41
56
 
42
- export interface CreatePlaybookRuntimeOptions {
43
- captainOptions: unknown;
44
- players: readonly RegistryPlayer[];
57
+ interface PlayerLedgerEntry {
58
+ readonly adapter: string;
59
+ readonly instruction?: string;
60
+ readonly permissions?: PermissionPolicy;
61
+ resumeToken?: string;
45
62
  }
46
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
+
47
78
  export interface PlaybookCaptainDeps {
48
79
  loadModule?: (specifier: string) => Promise<unknown>;
49
80
  createSessionId?: () => string;
@@ -61,15 +92,22 @@ export interface PlaybookCaptainRegistryEntry {
61
92
  id: string;
62
93
  command: string;
63
94
  intent: string;
95
+ artifactSchema: 2;
64
96
  requiredRoleIds: readonly string[];
97
+ concurrentRoleSets: readonly (readonly string[])[];
65
98
  summaryPolicy?: PlaybookSummaryPolicy;
66
- validateOptions(captainOptions: unknown): unknown;
67
- createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
99
+ validateOptions(optionSlice: unknown): unknown;
100
+ createRuntime(options: unknown): PlaybookRuntime;
68
101
  }
69
102
 
70
103
  type PlaybookCaptainConversationSnapshot =
71
104
  | { readonly kind: 'unopened' }
72
105
  | { readonly kind: 'pinned'; readonly token: string }
106
+ | {
107
+ readonly kind: 'needsCatchUp';
108
+ readonly resume: string | false;
109
+ readonly afterJournalSeq: number;
110
+ }
73
111
  | { readonly kind: 'needsSeeding' };
74
112
 
75
113
  interface PlaybookCaptainJournalRecord {
@@ -86,16 +124,20 @@ interface PlaybookCaptainFrameSnapshot {
86
124
  readonly depth: number;
87
125
  readonly parentSessionId?: string;
88
126
  readonly parentCallId?: string;
89
- readonly runtime: PlaybookRuntimeSnapshot;
127
+ readonly options: JsonValue;
128
+ readonly roleBindings: Readonly<Record<string, string>>;
129
+ readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
90
130
  }
91
131
 
92
132
  interface PlaybookCaptainShellSnapshotFields {
93
- readonly schemaVersion: 1;
133
+ readonly schemaVersion: 3;
94
134
  readonly captain: {
95
135
  readonly sessionId: string;
96
- readonly runtime: PlaybookRuntimeSnapshot;
136
+ readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
137
+ readonly agent: SnapshotAgentEnvelope;
97
138
  readonly conversation: PlaybookCaptainConversationSnapshot;
98
139
  };
140
+ readonly playerSessions: Readonly<Record<string, PlayerLedgerSnapshotEntry>>;
99
141
  /** Every Captain and engagement UUID issued during this logical session. */
100
142
  readonly issuedSessionIds: readonly string[];
101
143
  readonly sequences: {
@@ -117,13 +159,12 @@ interface PlaybookCaptainShellSnapshotFields {
117
159
  * Complete JSON-safe durable state for one Captain shell between Boss turns.
118
160
  * The discriminated mode keeps chat snapshots free of stale engagement data.
119
161
  */
120
- export type PlaybookCaptainShellSnapshot =
162
+ type PlaybookCaptainShellSnapshotValue =
121
163
  PlaybookCaptainShellSnapshotFields &
122
164
  (
123
165
  | {
124
166
  readonly mode: 'chat';
125
167
  readonly frames?: never;
126
- readonly rootPlayerResumeTokens?: never;
127
168
  readonly pendingBossQuestions?: never;
128
169
  readonly lastError?: never;
129
170
  }
@@ -131,13 +172,14 @@ export type PlaybookCaptainShellSnapshot =
131
172
  readonly mode: 'engaged.parked';
132
173
  /** Root-to-leaf engagement order. */
133
174
  readonly frames: readonly PlaybookCaptainFrameSnapshot[];
134
- /** Root-owned continuation, keyed by effective host-player id. */
135
- readonly rootPlayerResumeTokens: Readonly<Record<string, string>>;
136
175
  readonly pendingBossQuestions?: JsonValue;
137
176
  readonly lastError?: { readonly name: string; readonly message: string };
138
177
  }
139
178
  );
140
179
 
180
+ export type PlaybookCaptainShellSnapshot =
181
+ DeepReadonly<PlaybookCaptainShellSnapshotValue>;
182
+
141
183
  /** tmux and headless front ends share this one durable Captain shell API. */
142
184
  export interface PlaybookCaptainShell extends Captain {
143
185
  exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
@@ -147,22 +189,45 @@ export interface PlaybookCaptainShell extends Captain {
147
189
  ): Promise<void>;
148
190
  }
149
191
 
150
- // Per-enabled-playbook binding the shell resolves at init from
151
- // `captain.options.playbooks`: each playbook binds its local roles to
152
- // `<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.
153
194
  interface Enablement {
154
195
  entry: PlaybookCaptainRegistryEntry;
155
196
  command: string;
156
- optionInput: unknown;
157
- boundPlayers: readonly RegistryPlayer[];
158
- hostPlayerId: (localRole: string) => string;
197
+ options: JsonValue;
198
+ roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
159
199
  }
160
200
 
161
201
  interface EffectivePlayerBinding {
162
- readonly hostPlayerId: string;
163
- 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;
164
214
  }
165
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
+
166
231
  interface EngagementFrame {
167
232
  entry: PlaybookCaptainRegistryEntry;
168
233
  enablement: Enablement;
@@ -171,7 +236,6 @@ interface EngagementFrame {
171
236
  rootSessionId: string;
172
237
  depth: number;
173
238
  playerBindings: ReadonlyMap<string, EffectivePlayerBinding>;
174
- playerResumeTokens: Map<string, string>;
175
239
  parent?: {
176
240
  frame: EngagementFrame;
177
241
  callId: string;
@@ -180,6 +244,7 @@ interface EngagementFrame {
180
244
  abortListener?: () => void;
181
245
  invocationSignal?: AbortSignal;
182
246
  inFlightHostCalls: Set<Promise<unknown>>;
247
+ playerCallScope?: object;
183
248
  // Set synchronously before this frame's runtime is asked to dispose, so a
184
249
  // telemetry payload emitted during disposal is never mistaken for evidence
185
250
  // about a live leaf. `disposePromise` cannot serve: it is assigned after
@@ -204,6 +269,26 @@ class VisibilityControlError extends Error {
204
269
  }
205
270
  }
206
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
+
207
292
  type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
208
293
 
209
294
  interface ControlLedger {
@@ -245,15 +330,19 @@ interface JournalRecord {
245
330
  type DurableCallKind = 'decision' | 'commandReply' | 'closingReply';
246
331
 
247
332
  /**
248
- * CAPTAIN-35: the three states the durable conversation can be in. Modeling
249
- * them explicitly keeps "this is the session's first call" (correctly
250
- * unseeded) distinct from "a reseed is owed" (must carry the journal digest)
251
- * one boolean cannot hold both, and conflating them left the turn after a
252
- * 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.
253
337
  */
254
338
  type DurableConversation =
255
339
  | { readonly kind: 'unopened' }
256
340
  | { readonly kind: 'pinned'; readonly token: string }
341
+ | {
342
+ readonly kind: 'needsCatchUp';
343
+ readonly resume: string | false;
344
+ readonly afterJournalSeq: number;
345
+ }
257
346
  | { readonly kind: 'needsSeeding' };
258
347
 
259
348
  /**
@@ -275,6 +364,8 @@ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
275
364
  const INTERNAL_CAPTAIN_ID = 'captain';
276
365
  const UUID_PATTERN =
277
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_-]*$/;
278
369
 
279
370
  interface TurnSummaryCounts {
280
371
  interruptions: number;
@@ -290,6 +381,8 @@ interface ActiveTurnSummary {
290
381
  /** The shell state of one Boss turn (DR-029). */
291
382
  interface ActiveTurn {
292
383
  readonly id: number;
384
+ /** Latest journal record represented in a successful Captain call. */
385
+ captainSyncedJournalSeq: number;
293
386
  /** The exact Boss text of the turn; never rewritten (CAPTAIN-31). */
294
387
  readonly bossText: string;
295
388
  /**
@@ -318,7 +411,9 @@ interface ActiveTurn {
318
411
  * with the Boss-appropriate failure reply instead of propagating, so the
319
412
  * Boss's next message settles normally.
320
413
  */
321
- controlFailure?: boolean;
414
+ readonly controlFailures: Set<unknown>;
415
+ /** Exact settings-preflight rejections that performed no provider work. */
416
+ readonly settingsPreflightFailures: Set<unknown>;
322
417
  /**
323
418
  * Every value that escaped an effect invocation this turn — a runtime
324
419
  * driven, an engagement constructed, a stack disposed, an advertised action
@@ -492,7 +587,16 @@ function pendingQuestionLines(pending: unknown): string[] {
492
587
  : typeof record.id === 'string'
493
588
  ? record.id
494
589
  : undefined;
495
- 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;
496
600
  const text =
497
601
  typeof record.question === 'string'
498
602
  ? record.question
@@ -504,9 +608,9 @@ function pendingQuestionLines(pending: unknown): string[] {
504
608
  // second time would cut the line at the seam's limit and drop whatever
505
609
  // the shell had already written after the long part.
506
610
  const asked =
507
- player === undefined
611
+ askerLabel === undefined
508
612
  ? digestLine`${quoteEvidence(text)}`
509
- : digestLine`${quoteEvidence(player)} asks: ${quoteEvidence(text)}`;
613
+ : digestLine`${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
510
614
  const marker = id === undefined ? '' : digestLine`(${quoteEvidence(id)}) `;
511
615
  lines.push(`- ${marker}${asked}`);
512
616
  }
@@ -548,7 +652,10 @@ function renderJournalPayload(payload: JsonValue): string {
548
652
  return raw ?? 'null';
549
653
  }
550
654
 
551
- function renderReseedDigest(records: readonly JournalRecord[]): string {
655
+ function renderJournalDigest(
656
+ records: readonly JournalRecord[],
657
+ heading: string,
658
+ ): string {
552
659
  const lines = records.map(
553
660
  (record) =>
554
661
  `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(
@@ -556,12 +663,26 @@ function renderReseedDigest(records: readonly JournalRecord[]): string {
556
663
  )}`,
557
664
  );
558
665
  return [
559
- 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
666
+ heading,
560
667
  'The labeled ControlView and catalog digest blocks outrank conversation memory.',
561
668
  ...(lines.length === 0 ? ['(no earlier turns)'] : lines),
562
669
  ].join('\n');
563
670
  }
564
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
+
565
686
  // DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
566
687
  // internal control vocabulary.
567
688
  const CONTROL_VOCABULARY: readonly RegExp[] = [
@@ -681,16 +802,32 @@ function proseRejection(
681
802
  return undefined;
682
803
  }
683
804
 
805
+ type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
806
+
684
807
  // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
685
- // Cligent's Codex adapter rejects any `allowedTools` value — including the
686
- // empty list that expresses tool-free — because the supported Codex SDK
687
- // cannot enforce one, so requesting it fails every control call before the
688
- // model is reached. Omitting the option is the only way such an adapter can
689
- // run a control call at all; its isolation then rests on the authored
690
- // hidden-judge envelope below rather than on provider enforcement.
691
- const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT: ReadonlySet<string> = new Set([
692
- 'codex',
693
- ]);
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
+ }
694
831
 
695
832
  // The tool half of a control call's options. An empty allowlist means "no
696
833
  // tools available" and is distinct from omission, which grants the adapter's
@@ -701,7 +838,7 @@ function controlCallToolOptions(
701
838
  ): { allowedTools?: readonly string[] } {
702
839
  if (
703
840
  captainAdapter !== undefined &&
704
- ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)
841
+ requiresPromptOnlyToolIsolation(captainAdapter)
705
842
  ) {
706
843
  return {};
707
844
  }
@@ -721,14 +858,6 @@ function forwardedToolOptions(
721
858
  return { allowedTools: requested };
722
859
  }
723
860
 
724
- function readCaptainAdapter(options: unknown): string | undefined {
725
- if (typeof options !== 'object' || options === null) return undefined;
726
- const adapter = (options as Record<string, unknown>).captainAdapter;
727
- return typeof adapter === 'string' && adapter.length > 0
728
- ? adapter
729
- : undefined;
730
- }
731
-
732
861
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
733
862
 
734
863
  interface OutcomeReport {
@@ -839,11 +968,39 @@ function isValidRegistryEntry(
839
968
  ): value is PlaybookCaptainRegistryEntry {
840
969
  if (typeof value !== 'object' || value === null) return false;
841
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
+ }
842
999
  return (
843
1000
  typeof e.id === 'string' &&
844
1001
  typeof e.command === 'string' &&
845
1002
  typeof e.intent === 'string' &&
846
- Array.isArray(e.requiredRoleIds) &&
1003
+ e.artifactSchema === 2 &&
847
1004
  typeof e.validateOptions === 'function' &&
848
1005
  typeof e.createRuntime === 'function'
849
1006
  );
@@ -925,8 +1082,207 @@ function snapshotUuid(value: JsonValue | undefined, path: string): string {
925
1082
  return id;
926
1083
  }
927
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
+
928
1284
  /** Validate, detach, and freeze one untrusted shell snapshot. */
929
- function assertPlaybookCaptainShellSnapshot(
1285
+ export function assertPlaybookCaptainShellSnapshot(
930
1286
  value: unknown,
931
1287
  ): PlaybookCaptainShellSnapshot {
932
1288
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
@@ -935,6 +1291,7 @@ function assertPlaybookCaptainShellSnapshot(
935
1291
  const commonKeys = [
936
1292
  'schemaVersion',
937
1293
  'captain',
1294
+ 'playerSessions',
938
1295
  'issuedSessionIds',
939
1296
  'sequences',
940
1297
  'journal',
@@ -950,7 +1307,6 @@ function assertPlaybookCaptainShellSnapshot(
950
1307
  [
951
1308
  ...commonKeys,
952
1309
  'frames',
953
- 'rootPlayerResumeTokens',
954
1310
  'pendingBossQuestions',
955
1311
  'lastError',
956
1312
  ],
@@ -961,9 +1317,9 @@ function assertPlaybookCaptainShellSnapshot(
961
1317
  'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
962
1318
  );
963
1319
  }
964
- if (snapshot.schemaVersion !== 1) {
1320
+ if (snapshot.schemaVersion !== 3) {
965
1321
  throw new TypeError(
966
- `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`,
1322
+ `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
967
1323
  );
968
1324
  }
969
1325
 
@@ -973,7 +1329,7 @@ function assertPlaybookCaptainShellSnapshot(
973
1329
  );
974
1330
  rejectSnapshotKeys(
975
1331
  captain,
976
- ['sessionId', 'runtime', 'conversation'],
1332
+ ['sessionId', 'runtime', 'agent', 'conversation'],
977
1333
  'Captain shell snapshot.captain',
978
1334
  );
979
1335
  const captainSessionId = snapshotUuid(
@@ -984,6 +1340,10 @@ function assertPlaybookCaptainShellSnapshot(
984
1340
  captain.runtime,
985
1341
  INTERNAL_CAPTAIN_ID,
986
1342
  );
1343
+ const captainAgent = snapshotFixedAgent(
1344
+ captain.agent,
1345
+ 'Captain shell snapshot.captain.agent',
1346
+ );
987
1347
  const conversation = snapshotRecord(
988
1348
  captain.conversation,
989
1349
  'Captain shell snapshot.captain.conversation',
@@ -1000,7 +1360,26 @@ function assertPlaybookCaptainShellSnapshot(
1000
1360
  token: snapshotString(
1001
1361
  conversation.token,
1002
1362
  'Captain shell snapshot.captain.conversation.token',
1003
- true,
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',
1004
1383
  ),
1005
1384
  };
1006
1385
  } else if (
@@ -1131,6 +1510,29 @@ function assertPlaybookCaptainShellSnapshot(
1131
1510
  'Captain shell snapshot sequences do not match the complete journal',
1132
1511
  );
1133
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
+ }
1134
1536
 
1135
1537
  let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
1136
1538
  if (snapshot.lastAction !== undefined) {
@@ -1162,13 +1564,19 @@ function assertPlaybookCaptainShellSnapshot(
1162
1564
  typeof lastSettlementStatus
1163
1565
  >;
1164
1566
  }
1567
+ const playerSessions = snapshotPlayerSessions(
1568
+ snapshot.playerSessions,
1569
+ 'Captain shell snapshot.playerSessions',
1570
+ );
1165
1571
  const common: PlaybookCaptainShellSnapshotFields = {
1166
- schemaVersion: 1,
1572
+ schemaVersion: 3,
1167
1573
  captain: {
1168
1574
  sessionId: captainSessionId,
1169
1575
  runtime: captainRuntime,
1576
+ agent: captainAgent,
1170
1577
  conversation: normalizedConversation,
1171
1578
  },
1579
+ playerSessions,
1172
1580
  issuedSessionIds: issued,
1173
1581
  sequences: { turn: turnSequence, journal: journalSequence },
1174
1582
  journal: normalizedJournal,
@@ -1177,6 +1585,23 @@ function assertPlaybookCaptainShellSnapshot(
1177
1585
  ? {}
1178
1586
  : { lastSettlementStatus }),
1179
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
+ }
1180
1605
  if (mode === 'chat') {
1181
1606
  return snapshotJsonValue(
1182
1607
  { ...common, mode },
@@ -1204,6 +1629,8 @@ function assertPlaybookCaptainShellSnapshot(
1204
1629
  'depth',
1205
1630
  'parentSessionId',
1206
1631
  'parentCallId',
1632
+ 'options',
1633
+ 'roleBindings',
1207
1634
  'runtime',
1208
1635
  ],
1209
1636
  `Captain shell snapshot.frames[${index}]`,
@@ -1243,6 +1670,11 @@ function assertPlaybookCaptainShellSnapshot(
1243
1670
  playbookId,
1244
1671
  { allowSuspendedCall: true },
1245
1672
  );
1673
+ const options = frame.options as JsonValue;
1674
+ const roleBindings = snapshotFrameRoleBindings(
1675
+ frame.roleBindings,
1676
+ `Captain shell snapshot.frames[${index}].roleBindings`,
1677
+ );
1246
1678
  normalizedFrames.push({
1247
1679
  playbookId,
1248
1680
  sessionId,
@@ -1250,23 +1682,11 @@ function assertPlaybookCaptainShellSnapshot(
1250
1682
  depth,
1251
1683
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
1252
1684
  ...(parentCallId === undefined ? {} : { parentCallId }),
1685
+ options,
1686
+ roleBindings,
1253
1687
  runtime,
1254
1688
  });
1255
1689
  }
1256
-
1257
- const rootTokens = snapshotRecord(
1258
- snapshot.rootPlayerResumeTokens,
1259
- 'Captain shell snapshot.rootPlayerResumeTokens',
1260
- );
1261
- const normalizedRootTokens = Object.fromEntries(
1262
- Object.entries(rootTokens).map(([playerId, token]) => [
1263
- playerId,
1264
- snapshotString(
1265
- token,
1266
- `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`,
1267
- ),
1268
- ]),
1269
- );
1270
1690
  let normalizedLastError:
1271
1691
  | { readonly name: string; readonly message: string }
1272
1692
  | undefined;
@@ -1293,12 +1713,123 @@ function assertPlaybookCaptainShellSnapshot(
1293
1713
  ),
1294
1714
  };
1295
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
+ }
1808
+ }
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
+ }
1296
1828
  return snapshotJsonValue(
1297
1829
  {
1298
1830
  ...common,
1299
1831
  mode,
1300
1832
  frames: normalizedFrames,
1301
- rootPlayerResumeTokens: normalizedRootTokens,
1302
1833
  ...(snapshot.pendingBossQuestions === undefined
1303
1834
  ? {}
1304
1835
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -1310,30 +1841,120 @@ function assertPlaybookCaptainShellSnapshot(
1310
1841
  ) as unknown as PlaybookCaptainShellSnapshot;
1311
1842
  }
1312
1843
 
1313
- function readPlaybooksConfig(
1314
- options: unknown,
1315
- ): Record<string, unknown> | undefined {
1316
- if (typeof options !== 'object' || options === null) return undefined;
1317
- const pb = (options as Record<string, unknown>).playbooks;
1318
- if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
1319
- return undefined;
1320
- }
1321
- return pb as Record<string, unknown>;
1322
- }
1323
-
1324
1844
  interface BuiltRegistry {
1325
1845
  entries: readonly PlaybookCaptainRegistryEntry[];
1326
1846
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
1327
1847
  byId: Map<string, PlaybookCaptainRegistryEntry>;
1328
1848
  enablementById: Map<string, Enablement>;
1849
+ captainAgent: SessionAgent;
1850
+ playerAgents: Map<string, SessionAgent>;
1329
1851
  }
1330
1852
 
1331
- // Resolve the active registry at init from `captain.options.playbooks`
1332
- // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
1333
- // module and bound to namespaced `<id>-<role>` host players.
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>;
1894
+ }
1895
+
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.
1334
1956
  async function buildEnablements(
1335
1957
  options: unknown,
1336
- players: readonly RegistryPlayer[],
1337
1958
  loadModule: (specifier: string) => Promise<unknown>,
1338
1959
  ): Promise<BuiltRegistry> {
1339
1960
  const entries: PlaybookCaptainRegistryEntry[] = [];
@@ -1341,10 +1962,63 @@ async function buildEnablements(
1341
1962
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
1342
1963
  const enablementById = new Map<string, Enablement>();
1343
1964
 
1344
- const config = readPlaybooksConfig(options);
1345
- 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
+ ) {
1346
1978
  throw new Error('captain.options.playbooks is required');
1347
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
+ }
1348
2022
 
1349
2023
  const ids = Object.keys(config);
1350
2024
  if (ids.length === 0) {
@@ -1362,7 +2036,12 @@ async function buildEnablements(
1362
2036
  if (typeof block !== 'object' || block === null || Array.isArray(block)) {
1363
2037
  throw new Error(`captain.options.playbooks.${id} must be an object`);
1364
2038
  }
1365
- 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
+ );
1366
2045
  const from = record.from;
1367
2046
  if (typeof from !== 'string' || from.length === 0) {
1368
2047
  throw new Error(
@@ -1409,26 +2088,86 @@ async function buildEnablements(
1409
2088
  `captain.options.playbooks has a duplicate effective command "${command}"`,
1410
2089
  );
1411
2090
  }
1412
- const boundPlayers = entry.requiredRoleIds.map((role) => {
1413
- const host = players.find((p) => p.id === `${entry.id}-${role}`);
1414
- return {
1415
- id: role,
1416
- ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
1417
- ...(host?.model !== undefined ? { model: host.model } : {}),
1418
- };
1419
- });
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
+ );
1420
2142
  entries.push(entry);
1421
2143
  byId.set(entry.id, entry);
1422
2144
  byCommand.set(command, entry);
1423
2145
  enablementById.set(entry.id, {
1424
2146
  entry,
1425
2147
  command,
1426
- optionInput: record.options,
1427
- boundPlayers,
1428
- hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
2148
+ options: validatedOptions,
2149
+ roleBindings,
1429
2150
  });
1430
2151
  }
1431
- 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
+ };
1432
2171
  }
1433
2172
 
1434
2173
  export function createPlaybookCaptainShell(
@@ -1441,10 +2180,11 @@ export function createPlaybookCaptainShell(
1441
2180
  const createCaptainRuntime: NonNullable<
1442
2181
  PlaybookCaptainDeps['createCaptainRuntime']
1443
2182
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
1444
- // DR-013 A1: the launcher passes the resolved captain adapter through
1445
- // `captain.options`; a raw `--config` launch leaves it undefined, which
1446
- // keeps the enforced empty allowlist and its fail-closed behavior.
1447
- 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>();
1448
2188
  let entries: readonly PlaybookCaptainRegistryEntry[] = [];
1449
2189
  let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
1450
2190
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
@@ -1460,7 +2200,6 @@ export function createPlaybookCaptainShell(
1460
2200
  | 'disposing'
1461
2201
  | 'closed' = 'fresh';
1462
2202
  let terminallyDisposed = false;
1463
- let players: readonly RegistryPlayer[] = [];
1464
2203
  let activeContext: CaptainContext | undefined;
1465
2204
  const frames: EngagementFrame[] = [];
1466
2205
  let mode: ShellMode = 'chat';
@@ -1512,15 +2251,19 @@ export function createPlaybookCaptainShell(
1512
2251
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
1513
2252
  let captainRuntime: PlaybookRuntime | undefined;
1514
2253
  let captainSessionId: string | undefined;
1515
- // CAPTAIN-35: the conversation is exactly one of unopened, pinned, or
1516
- // owed-a-reseed. There is no fourth state in which a non-first call starts a
1517
- // 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.
1518
2257
  let conversation: DurableConversation = { kind: 'unopened' };
1519
2258
  let shuttingDown = false;
1520
2259
  const journal: JournalRecord[] = [];
1521
2260
  let journalSeq = 0;
1522
2261
  let turnSequence = 0;
1523
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;
1524
2267
  // The durable call the runtime is about to make, taken from the paired
1525
2268
  // `captain.call.started` boundary the engine emits before the port call
1526
2269
  // (CAPTAIN-9): the shell never infers a call's kind from its prose.
@@ -1609,7 +2352,10 @@ export function createPlaybookCaptainShell(
1609
2352
  // (CAPTAIN-5/CAPTAIN-6).
1610
2353
  ...(captainRuntime
1611
2354
  ? {
1612
- durableConversation: conversation.kind === 'pinned',
2355
+ durableConversation:
2356
+ conversation.kind === 'pinned' ||
2357
+ (conversation.kind === 'needsCatchUp' &&
2358
+ conversation.resume !== false),
1613
2359
  sessionJournal: true,
1614
2360
  }
1615
2361
  : {}),
@@ -1826,42 +2572,146 @@ export function createPlaybookCaptainShell(
1826
2572
  ) => Promise<PlaybookCallStart>;
1827
2573
 
1828
2574
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
1829
- callPlayer: async (playerId, prompt, signal, options) => {
2575
+ callPlayer: async (roleId, prompt, signal, options) => {
1830
2576
  admitHostBoundary();
1831
- if (!activeContext) {
2577
+ if (!activeContext || !activeTurn || !frame.playerCallScope) {
1832
2578
  throw new Error('callPlayer invoked outside a Boss turn');
1833
2579
  }
1834
2580
  const context = activeContext;
2581
+ const admittedTurn = activeTurn;
2582
+ const scope = frame.playerCallScope;
1835
2583
  signal.throwIfAborted();
1836
- const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
1837
- const result = await trackHostCall(
1838
- frame,
1839
- context.callPlayer(hostPlayerId, prompt, {
1840
- resume: options.resume,
1841
- }),
1842
- );
1843
- // CaptainContext is turn-scoped and cannot accept a narrower XState
1844
- // invocation signal. Recheck after the host call so a sibling
1845
- // cancellation is still reported as aborted and cannot rotate a
1846
- // stopped branch's player token in the linked runtime.
1847
- signal.throwIfAborted();
1848
- // CAPTAIN-20: only a player call that actually produced work is an
1849
- // interruption the Boss was spared. A call that errored or aborted
1850
- // saved nothing, so it never feeds the saved-counts gate.
1851
- const summary = activeTurnSummary;
1852
- if (summary && summaryIncludes(frame) && result.status === 'ok') {
1853
- 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
+ );
1854
2590
  }
1855
- return {
1856
- status: result.status,
1857
- ...(result.resumeToken !== undefined
1858
- ? { resumeToken: result.resumeToken }
1859
- : {}),
1860
- ...(result.finalText !== undefined
1861
- ? { finalText: result.finalText }
1862
- : {}),
1863
- ...(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,
1864
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;
1865
2715
  },
1866
2716
  callCaptain: async (prompt, signal, options) => {
1867
2717
  admitHostBoundary();
@@ -1962,14 +2812,16 @@ export function createPlaybookCaptainShell(
1962
2812
  });
1963
2813
 
1964
2814
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
1965
- // visibility for that playbook's generated host players. A pane
2815
+ // visibility for that playbook's explicitly bound session players. A pane
1966
2816
  // reconciliation failure is display-only in tmux-play and does not
1967
2817
  // reject; the legacy path carries no generated set and skips this.
1968
2818
  const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
1969
2819
  const ids = [...new Set(
1970
- [...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId),
2820
+ [...frame.playerBindings.values()].map(({ playerId }) => playerId),
1971
2821
  )];
1972
- 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;
1973
2825
  try {
1974
2826
  await activeContext.setVisiblePlayers(ids);
1975
2827
  } catch (error) {
@@ -2009,32 +2861,8 @@ export function createPlaybookCaptainShell(
2009
2861
 
2010
2862
  const makePlayerBindings = (
2011
2863
  enablement: Enablement,
2012
- parent?: { frame: EngagementFrame; callId: string },
2013
2864
  ): ReadonlyMap<string, EffectivePlayerBinding> => {
2014
- const entry = enablement.entry;
2015
- const playerBindings = new Map<string, EffectivePlayerBinding>();
2016
- for (const role of entry.requiredRoleIds) {
2017
- let inherited: EffectivePlayerBinding | undefined;
2018
- for (
2019
- let ancestor = parent?.frame;
2020
- ancestor && inherited === undefined;
2021
- ancestor = ancestor.parent?.frame
2022
- ) {
2023
- inherited = ancestor.playerBindings.get(role);
2024
- }
2025
- if (inherited) {
2026
- playerBindings.set(role, inherited);
2027
- continue;
2028
- }
2029
- const configured = enablement.boundPlayers.find(
2030
- (player) => player.id === role,
2031
- ) ?? { id: role };
2032
- playerBindings.set(role, {
2033
- hostPlayerId: enablement.hostPlayerId(role),
2034
- player: configured,
2035
- });
2036
- }
2037
- return playerBindings;
2865
+ return new Map(enablement.roleBindings);
2038
2866
  };
2039
2867
 
2040
2868
  const makeFrame = (
@@ -2043,17 +2871,8 @@ export function createPlaybookCaptainShell(
2043
2871
  ): EngagementFrame => {
2044
2872
  const entry = enablement.entry;
2045
2873
  const sessionId = allocateSessionId();
2046
- const playerBindings = makePlayerBindings(enablement, parent);
2047
- const playerResumeTokens =
2048
- parent?.frame.playerResumeTokens ?? new Map<string, string>();
2049
- const runtime = entry.createRuntime({
2050
- captainOptions: enablement.optionInput,
2051
- players: [...playerBindings].map(([role, { player }]) => ({
2052
- id: role,
2053
- ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
2054
- ...(player.model === undefined ? {} : { model: player.model }),
2055
- })),
2056
- });
2874
+ const playerBindings = makePlayerBindings(enablement);
2875
+ const runtime = entry.createRuntime(enablement.options);
2057
2876
  return {
2058
2877
  entry,
2059
2878
  enablement,
@@ -2062,7 +2881,6 @@ export function createPlaybookCaptainShell(
2062
2881
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
2063
2882
  depth: parent ? parent.frame.depth + 1 : 0,
2064
2883
  playerBindings,
2065
- playerResumeTokens,
2066
2884
  ...(parent ? { parent } : {}),
2067
2885
  inFlightHostCalls: new Set(),
2068
2886
  };
@@ -2071,19 +2889,11 @@ export function createPlaybookCaptainShell(
2071
2889
  const makeRestoredFrame = (
2072
2890
  enablement: Enablement,
2073
2891
  snapshot: PlaybookCaptainFrameSnapshot,
2074
- rootPlayerResumeTokens: Map<string, string>,
2075
2892
  parent?: { frame: EngagementFrame; callId: string },
2076
2893
  ): EngagementFrame => {
2077
2894
  const entry = enablement.entry;
2078
- const playerBindings = makePlayerBindings(enablement, parent);
2079
- const runtime = entry.createRuntime({
2080
- captainOptions: enablement.optionInput,
2081
- players: [...playerBindings].map(([role, { player }]) => ({
2082
- id: role,
2083
- ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
2084
- ...(player.model === undefined ? {} : { model: player.model }),
2085
- })),
2086
- });
2895
+ const playerBindings = makePlayerBindings(enablement);
2896
+ const runtime = entry.createRuntime(enablement.options);
2087
2897
  return {
2088
2898
  entry,
2089
2899
  enablement,
@@ -2092,7 +2902,6 @@ export function createPlaybookCaptainShell(
2092
2902
  rootSessionId: snapshot.rootSessionId,
2093
2903
  depth: snapshot.depth,
2094
2904
  playerBindings,
2095
- playerResumeTokens: rootPlayerResumeTokens,
2096
2905
  ...(parent ? { parent } : {}),
2097
2906
  state: snapshot.runtime.state,
2098
2907
  inFlightHostCalls: new Set(),
@@ -2102,35 +2911,159 @@ export function createPlaybookCaptainShell(
2102
2911
  const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
2103
2912
  select(playerId) {
2104
2913
  const binding = bindingFor(frame, playerId);
2105
- return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
2914
+ return playerLedger.get(binding.playerId)?.resumeToken ?? false;
2106
2915
  },
2107
2916
  update(playerId, resumeToken) {
2108
2917
  const binding = bindingFor(frame, playerId);
2109
- if (resumeToken === undefined) {
2110
- frame.playerResumeTokens.delete(binding.hostPlayerId);
2111
- } else {
2112
- 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);
2113
2965
  }
2114
2966
  },
2115
2967
  snapshot() {
2116
2968
  const tokens: Record<string, string> = {};
2117
2969
  for (const [playerId, binding] of frame.playerBindings) {
2118
- const token = frame.playerResumeTokens.get(binding.hostPlayerId);
2970
+ const token = playerLedger.get(binding.playerId)?.resumeToken;
2119
2971
  if (token !== undefined) tokens[playerId] = token;
2120
2972
  }
2121
2973
  return tokens;
2122
2974
  },
2123
2975
  restore(tokens) {
2124
- for (const binding of frame.playerBindings.values()) {
2125
- 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
+ );
2126
2983
  }
2984
+ const byPlayer = new Map<string, string | undefined>();
2127
2985
  for (const [playerId, token] of Object.entries(tokens)) {
2128
2986
  const binding = bindingFor(frame, playerId);
2129
- 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;
2130
3007
  }
2131
3008
  },
2132
3009
  });
2133
3010
 
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
+
2134
3067
  const frameSession = (frame: EngagementFrame) => ({
2135
3068
  sessionId: frame.sessionId,
2136
3069
  playbookId: frame.entry.id,
@@ -2142,6 +3075,15 @@ export function createPlaybookCaptainShell(
2142
3075
  }
2143
3076
  : {}),
2144
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
+ ),
2145
3087
  playerSessions: playerSessionStore(frame),
2146
3088
  ports: createPorts(frame),
2147
3089
  });
@@ -2465,8 +3407,10 @@ export function createPlaybookCaptainShell(
2465
3407
  // exception filed against an effect that never ran.
2466
3408
  await requestVisibility(frame);
2467
3409
  await setMode('engaged.driving', 'submit');
2468
- const result = await runEffect(() =>
2469
- frame.runtime.handleBossInput({ text, signal }),
3410
+ const result = await runFrameOperation(frame, () =>
3411
+ runEffect(() =>
3412
+ frame.runtime.handleBossInput({ text, signal }),
3413
+ ),
2470
3414
  );
2471
3415
  frame.state = result.state;
2472
3416
  return result;
@@ -2520,12 +3464,14 @@ export function createPlaybookCaptainShell(
2520
3464
  }
2521
3465
  let result: PlaybookRunResult;
2522
3466
  try {
2523
- result = await runEffect(() =>
2524
- parent.runtime.resumePlaybookCall({
2525
- callId: parentLink.callId,
2526
- result: effectiveResult,
2527
- signal: context.signal,
2528
- }),
3467
+ result = await runFrameOperation(parent, () =>
3468
+ runEffect(() =>
3469
+ parent.runtime.resumePlaybookCall({
3470
+ callId: parentLink.callId,
3471
+ result: effectiveResult,
3472
+ signal: context.signal,
3473
+ }),
3474
+ ),
2529
3475
  );
2530
3476
  } catch (error) {
2531
3477
  if (disposing || invocationSignal?.aborted) return;
@@ -2949,7 +3895,9 @@ export function createPlaybookCaptainShell(
2949
3895
  const pending = view.pendingQuestions.map(
2950
3896
  (question) =>
2951
3897
  digestLine`- (${quoteEvidence(question.questionId)}) ${quoteEvidence(
2952
- question.player,
3898
+ question.asker.kind === 'captain'
3899
+ ? 'Captain'
3900
+ : question.asker.roleId,
2953
3901
  )} asks: ${quoteEvidence(question.question)}`,
2954
3902
  );
2955
3903
  lines.push(
@@ -3073,6 +4021,7 @@ export function createPlaybookCaptainShell(
3073
4021
  try {
3074
4022
  await trackTurnCall(settlement.context.emitReply(settlement.text));
3075
4023
  } catch (error) {
4024
+ conversation = { kind: 'needsSeeding' };
3076
4025
  const normalized = normalizeErrorCompact(error) ?? {
3077
4026
  name: 'Error',
3078
4027
  message: String(error),
@@ -3179,8 +4128,11 @@ export function createPlaybookCaptainShell(
3179
4128
  * text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
3180
4129
  * the Captain may need to repeat.
3181
4130
  */
3182
- const reseedDigest = (): string => {
3183
- 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) {
3184
4136
  if (
3185
4137
  record.kind === 'action' &&
3186
4138
  typeof record.payload === 'object' &&
@@ -3191,14 +4143,47 @@ export function createPlaybookCaptainShell(
3191
4143
  if (typeof actionId === 'string') recordSuppliedIdentifier(actionId);
3192
4144
  }
3193
4145
  }
3194
- return renderReseedDigest(journal);
4146
+ return render(records);
3195
4147
  };
3196
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
+
3197
4158
  const markControlFailure = <E>(error: E): E => {
3198
- if (activeTurn) activeTurn.controlFailure = true;
4159
+ activeTurn?.controlFailures.add(error);
3199
4160
  return error;
3200
4161
  };
3201
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
+
3202
4187
  /**
3203
4188
  * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
3204
4189
  * engagement constructed, a stack disposed, an advertised action applied.
@@ -3253,6 +4238,7 @@ export function createPlaybookCaptainShell(
3253
4238
  context: CaptainContext,
3254
4239
  prompt: string,
3255
4240
  resume: string | false,
4241
+ attempt: { providerBoundaryEntered: boolean },
3256
4242
  ): Promise<{
3257
4243
  status: string;
3258
4244
  finalText?: string;
@@ -3261,11 +4247,15 @@ export function createPlaybookCaptainShell(
3261
4247
  }> => {
3262
4248
  const queued = captainQueue.add(async () => {
3263
4249
  context.signal.throwIfAborted();
3264
- const result = await context.callCaptain(prompt, {
3265
- visibility: 'hidden',
3266
- resume,
3267
- ...controlCallToolOptions(captainAdapter),
3268
- });
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
+ );
3269
4259
  context.signal.throwIfAborted();
3270
4260
  return result;
3271
4261
  });
@@ -3286,8 +4276,17 @@ export function createPlaybookCaptainShell(
3286
4276
  context: CaptainContext,
3287
4277
  compose: (options: { reseedDigest?: string }) => string,
3288
4278
  ): Promise<DurableCallOutcome> => {
3289
- 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;
3290
4286
  const seedFirstCall = conversation.kind === 'needsSeeding';
4287
+ const catchUpFirstCall = conversation.kind === 'needsCatchUp';
4288
+ const representedJournalSeq = journalSeq;
4289
+ const firstAttempt = { providerBoundaryEntered: false };
3291
4290
  let result:
3292
4291
  | { status: string; finalText?: string; resumeToken?: string; error?: string }
3293
4292
  | undefined;
@@ -3296,14 +4295,42 @@ export function createPlaybookCaptainShell(
3296
4295
  result = await rawDurableCall(
3297
4296
  context,
3298
4297
  compose(
3299
- seedFirstCall ? { reseedDigest: reseedDigest() } : {},
4298
+ seedFirstCall
4299
+ ? { reseedDigest: reseedDigest() }
4300
+ : startingConversation.kind === 'needsCatchUp'
4301
+ ? {
4302
+ reseedDigest: catchUpDigest(
4303
+ startingConversation.afterJournalSeq,
4304
+ ),
4305
+ }
4306
+ : {},
3300
4307
  ),
3301
4308
  resume,
4309
+ firstAttempt,
3302
4310
  );
3303
4311
  } catch (error) {
3304
4312
  if (context.signal.aborted) {
3305
- conversation = { kind: 'needsSeeding' };
3306
- 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);
3307
4334
  }
3308
4335
  failure = error;
3309
4336
  }
@@ -3314,11 +4341,14 @@ export function createPlaybookCaptainShell(
3314
4341
  result.resumeToken === undefined;
3315
4342
  if (!unsynchronized) {
3316
4343
  conversation = { kind: 'pinned', token: result!.resumeToken! };
4344
+ if (activeTurn) {
4345
+ activeTurn.captainSyncedJournalSeq = representedJournalSeq;
4346
+ }
3317
4347
  return {
3318
4348
  ...(result!.finalText !== undefined
3319
4349
  ? { finalText: result!.finalText }
3320
4350
  : {}),
3321
- correctiveSpent: seedFirstCall,
4351
+ correctiveSpent: seedFirstCall || catchUpFirstCall,
3322
4352
  };
3323
4353
  }
3324
4354
  // Only the model-side conversation is replaced: the stack, player
@@ -3330,16 +4360,21 @@ export function createPlaybookCaptainShell(
3330
4360
  let reissued:
3331
4361
  | { status: string; finalText?: string; resumeToken?: string; error?: string }
3332
4362
  | undefined;
4363
+ const reissueAttempt = { providerBoundaryEntered: false };
3333
4364
  try {
3334
4365
  reissued = await rawDurableCall(
3335
4366
  context,
3336
4367
  compose({ reseedDigest: recap }),
3337
4368
  false,
4369
+ reissueAttempt,
3338
4370
  );
3339
4371
  } catch (error) {
3340
4372
  if (context.signal.aborted) {
3341
4373
  conversation = { kind: 'needsSeeding' };
3342
- throw error;
4374
+ throw context.signal.reason ?? error;
4375
+ }
4376
+ if (error instanceof AgentSettingsPreflightError) {
4377
+ throw rememberSettingsPreflight(error.rejection);
3343
4378
  }
3344
4379
  throw markControlFailure(new CaptainContinuityError(error));
3345
4380
  }
@@ -3352,6 +4387,7 @@ export function createPlaybookCaptainShell(
3352
4387
  );
3353
4388
  }
3354
4389
  conversation = { kind: 'pinned', token: reissued.resumeToken };
4390
+ if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
3355
4391
  return {
3356
4392
  ...(reissued.finalText !== undefined
3357
4393
  ? { finalText: reissued.finalText }
@@ -3758,7 +4794,7 @@ export function createPlaybookCaptainShell(
3758
4794
  message: String(error),
3759
4795
  };
3760
4796
  if (aborted) {
3761
- conversation = { kind: 'needsSeeding' };
4797
+ markConversationUnsynchronized();
3762
4798
  if (turn?.outcomePending) {
3763
4799
  turn.settlementFacts.push(
3764
4800
  `The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`,
@@ -4158,7 +5194,11 @@ export function createPlaybookCaptainShell(
4158
5194
  // receipt rather than acting twice.
4159
5195
  const key = `turn-${turn.id}-apply-${actionId}`;
4160
5196
  const outcome = await withCounting(leaf, async () =>
4161
- runEffect(() => leaf.runtime.apply!({ actionId, key, signal })),
5197
+ runFrameOperation(leaf, () =>
5198
+ runEffect(() =>
5199
+ leaf.runtime.apply!({ actionId, key, signal }),
5200
+ ),
5201
+ ),
4162
5202
  );
4163
5203
  if (outcome.error !== undefined) throw outcome.error;
4164
5204
  const receipt = outcome.result!;
@@ -4322,7 +5362,11 @@ export function createPlaybookCaptainShell(
4322
5362
  // The durable Captain conversation did not receive the shell-authored
4323
5363
  // fallback. Force its next call through the journal so it cannot interpret
4324
5364
  // the Boss's follow-up without the reply the Boss was given this turn.
4325
- conversation = { kind: 'needsSeeding' };
5365
+ if (activeTurn?.settingsPreflightFailures.has(error)) {
5366
+ markConversationCatchUp();
5367
+ } else {
5368
+ markConversationUnsynchronized();
5369
+ }
4326
5370
  // A rejected presentation may already have emitted bytes. It is therefore
4327
5371
  // final for this turn even though the Promise did not prove it was shown.
4328
5372
  if (activeTurn?.presentationAttempted === true) return;
@@ -4353,164 +5397,93 @@ export function createPlaybookCaptainShell(
4353
5397
  playbookId: INTERNAL_CAPTAIN_ID,
4354
5398
  rootSessionId: id,
4355
5399
  depth: 0,
5400
+ roleBindings: {},
4356
5401
  ports: captainPorts(),
4357
5402
  });
4358
5403
 
4359
- const tokenRecord = (
4360
- tokens: ReadonlyMap<string, string>,
4361
- ): Readonly<Record<string, string>> => Object.fromEntries(tokens);
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
+ );
4362
5424
 
4363
5425
  const assertSnapshotMatchesEnablements = (
4364
5426
  snapshot: PlaybookCaptainShellSnapshot,
4365
5427
  enabled: ReadonlyMap<string, Enablement>,
4366
5428
  ): void => {
4367
- const captain = snapshot.captain.runtime;
4368
5429
  if (
4369
- captain.schemaVersion !== 2 ||
4370
- captain.state.status !== 'active' ||
4371
- !captain.state.quiescent ||
4372
- !captain.state.tags.includes('playbook.parked') ||
4373
- captain.suspendedCall !== undefined ||
4374
- Object.keys(captain.playerResumeTokens).length > 0 ||
4375
- captain.pendingBossQuestions.length > 0
5430
+ captainAgent === undefined ||
5431
+ !isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))
4376
5432
  ) {
4377
5433
  throw new TypeError(
4378
- 'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
4379
- );
4380
- }
4381
- if (captain.sequences.turn !== snapshot.sequences.turn) {
4382
- throw new TypeError(
4383
- 'Captain shell snapshot Captain and shell turn sequences must match',
5434
+ 'Captain shell snapshot Captain agent is incompatible with current config',
4384
5435
  );
4385
5436
  }
4386
- const emptyHistory =
4387
- snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
4388
- if (
4389
- (snapshot.captain.conversation.kind === 'unopened') !== emptyHistory
4390
- ) {
5437
+ const configuredPlayerIds = [...playerAgents.keys()].sort();
5438
+ const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
5439
+ if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
4391
5440
  throw new TypeError(
4392
- 'Captain shell snapshot unopened conversation must exactly match an empty session history',
5441
+ 'Captain shell snapshot player ledger does not match current referenced players',
4393
5442
  );
4394
5443
  }
4395
- if (snapshot.mode === 'chat') return;
4396
-
4397
- const activePlaybooks = new Set<string>();
4398
- const activeSessionIds = new Set<string>([snapshot.captain.sessionId]);
4399
- const issuedIds = new Set(snapshot.issuedSessionIds);
4400
- const allowedHostPlayerIds = new Set<string>();
4401
- for (const enablement of enabled.values()) {
4402
- for (const role of enablement.entry.requiredRoleIds) {
4403
- allowedHostPlayerIds.add(enablement.hostPlayerId(role));
4404
- }
4405
- }
4406
- for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
4407
- if (!allowedHostPlayerIds.has(playerId)) {
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))) {
4408
5457
  throw new TypeError(
4409
- `Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`,
5458
+ `Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`,
4410
5459
  );
4411
5460
  }
4412
5461
  }
4413
-
4414
- const bindingMaps: Map<string, string>[] = [];
4415
- const rootSessionId = snapshot.frames[0]!.sessionId;
4416
- for (const [index, frame] of snapshot.frames.entries()) {
5462
+ if (snapshot.mode === 'chat') return;
5463
+ for (const frame of snapshot.frames) {
4417
5464
  const enablement = enabled.get(frame.playbookId);
4418
5465
  if (!enablement) {
4419
5466
  throw new TypeError(
4420
5467
  `Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
4421
5468
  );
4422
5469
  }
4423
- if (activePlaybooks.has(frame.playbookId)) {
4424
- throw new TypeError(
4425
- 'Captain shell snapshot engagement path must not contain a playbook cycle',
4426
- );
4427
- }
4428
- activePlaybooks.add(frame.playbookId);
4429
- if (activeSessionIds.has(frame.sessionId)) {
4430
- throw new TypeError(
4431
- 'Captain shell snapshot frame session ids must be unique',
4432
- );
4433
- }
4434
- activeSessionIds.add(frame.sessionId);
4435
- if (!issuedIds.has(frame.sessionId)) {
4436
- throw new TypeError(
4437
- 'Captain shell snapshot frame session id was not historically issued',
4438
- );
4439
- }
4440
- if (
4441
- frame.depth !== index ||
4442
- frame.rootSessionId !== rootSessionId ||
4443
- frame.runtime.state.status !== 'active' ||
4444
- !frame.runtime.state.quiescent
4445
- ) {
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)) {
4446
5477
  throw new TypeError(
4447
- 'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
5478
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`,
4448
5479
  );
4449
5480
  }
4450
- if (index === 0) {
4451
- if (
4452
- frame.sessionId !== frame.rootSessionId ||
4453
- frame.parentSessionId !== undefined ||
4454
- frame.parentCallId !== undefined
4455
- ) {
4456
- throw new TypeError(
4457
- 'Captain shell snapshot root frame has child-only identity fields',
4458
- );
4459
- }
4460
- } else {
4461
- const parent = snapshot.frames[index - 1]!;
4462
- if (
4463
- frame.parentSessionId !== parent.sessionId ||
4464
- frame.parentCallId === undefined
4465
- ) {
4466
- throw new TypeError(
4467
- 'Captain shell snapshot child frame does not identify its immediate parent',
4468
- );
4469
- }
4470
- const pending = parent.runtime.suspendedCall;
4471
- if (
4472
- !pending ||
4473
- pending.callId !== frame.parentCallId ||
4474
- pending.playbookId !== frame.playbookId ||
4475
- pending.childSessionId !== frame.sessionId
4476
- ) {
4477
- throw new TypeError(
4478
- 'Captain shell snapshot parent suspended call does not match its child edge',
4479
- );
4480
- }
4481
- }
4482
-
4483
- const roleBindings = new Map<string, string>();
4484
- for (const role of enablement.entry.requiredRoleIds) {
4485
- let inherited: string | undefined;
4486
- for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
4487
- inherited = bindingMaps[ancestor]?.get(role);
4488
- if (inherited !== undefined) break;
4489
- }
4490
- roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
4491
- }
4492
- bindingMaps.push(roleBindings);
4493
- const projectedTokens = Object.fromEntries(
4494
- [...roleBindings].flatMap(([role, hostPlayerId]) => {
4495
- const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
4496
- return token === undefined ? [] : [[role, token] as const];
4497
- }),
4498
- );
4499
- if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
5481
+ if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
4500
5482
  throw new TypeError(
4501
- `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`,
5483
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
4502
5484
  );
4503
5485
  }
4504
5486
  }
4505
- const leafRuntime = snapshot.frames.at(-1)!.runtime;
4506
- if (
4507
- leafRuntime.suspendedCall !== undefined ||
4508
- !leafRuntime.state.tags.includes('playbook.parked')
4509
- ) {
4510
- throw new TypeError(
4511
- 'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
4512
- );
4513
- }
4514
5487
  };
4515
5488
 
4516
5489
  const safeCapturePoint = (): boolean => {
@@ -4530,6 +5503,7 @@ export function createPlaybookCaptainShell(
4530
5503
  runFailureFacts !== undefined ||
4531
5504
  servingCall !== undefined ||
4532
5505
  decisionCall !== undefined ||
5506
+ playerTransactions.size !== 0 ||
4533
5507
  captainQueue.pending !== 0 ||
4534
5508
  captainQueue.size !== 0 ||
4535
5509
  (mode !== 'chat' && mode !== 'engaged.parked')
@@ -4575,7 +5549,12 @@ export function createPlaybookCaptainShell(
4575
5549
  };
4576
5550
 
4577
5551
  const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
4578
- if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
5552
+ if (
5553
+ !safeCapturePoint() ||
5554
+ !captainRuntime ||
5555
+ !captainSessionId ||
5556
+ !captainAgent
5557
+ ) {
4579
5558
  return undefined;
4580
5559
  }
4581
5560
  try {
@@ -4613,16 +5592,25 @@ export function createPlaybookCaptainShell(
4613
5592
  parentCallId: frame.parent.callId,
4614
5593
  }
4615
5594
  : {}),
5595
+ options: frame.enablement.options,
5596
+ roleBindings: Object.fromEntries(
5597
+ [...frame.playerBindings].map(([role, binding]) => [
5598
+ role,
5599
+ binding.playerId,
5600
+ ]),
5601
+ ),
4616
5602
  runtime,
4617
5603
  });
4618
5604
  }
4619
5605
  const common = {
4620
- schemaVersion: 1 as const,
5606
+ schemaVersion: 3 as const,
4621
5607
  captain: {
4622
5608
  sessionId: captainSessionId,
4623
5609
  runtime: captainSnapshot,
5610
+ agent: fixedAgent(captainAgent),
4624
5611
  conversation,
4625
5612
  },
5613
+ playerSessions: playerLedgerRecord(),
4626
5614
  issuedSessionIds: [...issuedSessionIds],
4627
5615
  sequences: { turn: turnSequence, journal: journalSeq },
4628
5616
  journal,
@@ -4638,9 +5626,6 @@ export function createPlaybookCaptainShell(
4638
5626
  ...common,
4639
5627
  mode: 'engaged.parked',
4640
5628
  frames: frameSnapshots,
4641
- rootPlayerResumeTokens: tokenRecord(
4642
- rootFrame()!.playerResumeTokens,
4643
- ),
4644
5629
  ...(pendingBossQuestions === undefined
4645
5630
  ? {}
4646
5631
  : { pendingBossQuestions: pendingBossQuestions as JsonValue }),
@@ -4673,7 +5658,7 @@ export function createPlaybookCaptainShell(
4673
5658
  );
4674
5659
  for (const key of [
4675
5660
  'state',
4676
- 'playerResumeTokens',
5661
+ 'roleResumeTokens',
4677
5662
  'sequences',
4678
5663
  'pendingBossQuestions',
4679
5664
  'suspendedCall',
@@ -4712,7 +5697,11 @@ export function createPlaybookCaptainShell(
4712
5697
  byCommand = new Map();
4713
5698
  byId = new Map();
4714
5699
  enablementById = new Map();
4715
- players = [];
5700
+ captainAgent = undefined;
5701
+ captainAdapter = undefined;
5702
+ playerAgents = new Map();
5703
+ playerLedger.clear();
5704
+ playerTransactions.clear();
4716
5705
  session = undefined;
4717
5706
  sessionEmissionsOpen = false;
4718
5707
  closedGateAttempted = false;
@@ -4749,22 +5738,31 @@ export function createPlaybookCaptainShell(
4749
5738
  lifecycle = 'restoring';
4750
5739
  try {
4751
5740
  const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
4752
- const built = await buildEnablements(
4753
- options,
4754
- initSession.players,
4755
- loadModule,
4756
- );
4757
- for (const enablement of built.enablementById.values()) {
4758
- enablement.entry.validateOptions(enablement.optionInput);
4759
- }
5741
+ const built = await buildEnablements(options, loadModule);
5742
+ captainAgent = built.captainAgent;
5743
+ captainAdapter = captainAgent.adapter;
5744
+ playerAgents = built.playerAgents;
4760
5745
  assertSnapshotMatchesEnablements(snapshot, built.enablementById);
4761
5746
 
4762
5747
  installSession(initSession, false);
4763
- players = initSession.players;
4764
5748
  entries = built.entries;
4765
5749
  byCommand = built.byCommand;
4766
5750
  byId = built.byId;
4767
5751
  enablementById = built.enablementById;
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
+ }
4768
5766
 
4769
5767
  captainRuntime = createCaptainRuntime({
4770
5768
  enabledPlaybooks: enabledCatalog(),
@@ -4775,15 +5773,11 @@ export function createPlaybookCaptainShell(
4775
5773
  }
4776
5774
 
4777
5775
  if (snapshot.mode === 'engaged.parked') {
4778
- const rootTokens = new Map(
4779
- Object.entries(snapshot.rootPlayerResumeTokens),
4780
- );
4781
5776
  for (const [index, frameSnapshot] of snapshot.frames.entries()) {
4782
5777
  const parentFrame = frames.at(-1);
4783
5778
  const frame = makeRestoredFrame(
4784
5779
  enablementById.get(frameSnapshot.playbookId)!,
4785
5780
  frameSnapshot,
4786
- rootTokens,
4787
5781
  index === 0
4788
5782
  ? undefined
4789
5783
  : {
@@ -4807,10 +5801,15 @@ export function createPlaybookCaptainShell(
4807
5801
  );
4808
5802
  if (snapshot.mode === 'engaged.parked') {
4809
5803
  for (const [index, frame] of frames.entries()) {
4810
- await frame.runtime.restore!(
4811
- frameSession(frame),
4812
- snapshot.frames[index]!.runtime,
4813
- );
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
+ }
4814
5813
  }
4815
5814
  }
4816
5815
  if (closedGateAttempted) {
@@ -4832,13 +5831,10 @@ export function createPlaybookCaptainShell(
4832
5831
  );
4833
5832
  }
4834
5833
  if (
4835
- !isDeepStrictEqual(
4836
- tokenRecord(rootFrame()!.playerResumeTokens),
4837
- snapshot.rootPlayerResumeTokens,
4838
- )
5834
+ !isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)
4839
5835
  ) {
4840
5836
  throw new Error(
4841
- 'restored root-owned player continuation changed during restore',
5837
+ 'restored Captain-session player continuation changed during restore',
4842
5838
  );
4843
5839
  }
4844
5840
  }
@@ -4887,14 +5883,16 @@ export function createPlaybookCaptainShell(
4887
5883
  lifecycle = 'initializing';
4888
5884
  try {
4889
5885
  installSession(initSession, true);
4890
- players = initSession.players;
4891
- const built = await buildEnablements(options, players, loadModule);
5886
+ const built = await buildEnablements(options, loadModule);
4892
5887
  entries = built.entries;
4893
5888
  byCommand = built.byCommand;
4894
5889
  byId = built.byId;
4895
5890
  enablementById = built.enablementById;
4896
- for (const enablement of enablementById.values()) {
4897
- enablement.entry.validateOptions(enablement.optionInput);
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));
4898
5896
  }
4899
5897
  await setMode('chat', 'init');
4900
5898
  // CAPTAIN-16: the session Captain exists from `init`, outside the
@@ -4942,6 +5940,7 @@ export function createPlaybookCaptainShell(
4942
5940
  const parsed = resolveCommandTurn(turn.prompt);
4943
5941
  activeTurn = {
4944
5942
  id: ++turnSequence,
5943
+ captainSyncedJournalSeq: journalSeq,
4945
5944
  bossText: turn.prompt,
4946
5945
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
4947
5946
  ...(parsed ? { resolution: parsed.resolution } : {}),
@@ -4949,6 +5948,8 @@ export function createPlaybookCaptainShell(
4949
5948
  presentationAttempted: false,
4950
5949
  settlementFacts: [],
4951
5950
  effectThrows: new Set<unknown>(),
5951
+ controlFailures: new Set<unknown>(),
5952
+ settingsPreflightFailures: new Set<unknown>(),
4952
5953
  suppliedIdentifiers: new Set<string>(),
4953
5954
  outcomeRecorded: false,
4954
5955
  };
@@ -4969,13 +5970,16 @@ export function createPlaybookCaptainShell(
4969
5970
  new Error('the session Captain turn failed at its boundary'),
4970
5971
  );
4971
5972
  } else if (result.outcome === 'aborted') {
4972
- conversation = { kind: 'needsSeeding' };
5973
+ markConversationUnsynchronized();
4973
5974
  if (activeTurn && !activeTurn.outcomeRecorded) {
4974
5975
  activeTurn.settlementFacts.push(
4975
5976
  'The Boss turn was aborted before it settled; no action was repeated automatically.',
4976
5977
  );
4977
5978
  journalOutcome([...activeTurn.settlementFacts]);
4978
5979
  }
5980
+ if (context.signal.aborted) {
5981
+ throw context.signal.reason;
5982
+ }
4979
5983
  } else if (
4980
5984
  result.outcome !== 'suspended' &&
4981
5985
  !context.signal.aborted &&
@@ -4994,10 +5998,10 @@ export function createPlaybookCaptainShell(
4994
5998
  }
4995
5999
  } catch (error) {
4996
6000
  if (context.signal.aborted) {
4997
- conversation = { kind: 'needsSeeding' };
6001
+ markConversationUnsynchronized();
4998
6002
  throw error;
4999
6003
  }
5000
- const controlFailure = activeTurn?.controlFailure === true;
6004
+ const controlFailure = activeTurn?.controlFailures.has(error) === true;
5001
6005
  await settleTurnFailure(context, error);
5002
6006
  if (activeTurn?.presentationError !== undefined) {
5003
6007
  throw activeTurn.presentationError;
@@ -5007,7 +6011,7 @@ export function createPlaybookCaptainShell(
5007
6011
  if (!controlFailure) throw error;
5008
6012
  } finally {
5009
6013
  if (context.signal.aborted) {
5010
- conversation = { kind: 'needsSeeding' };
6014
+ markConversationUnsynchronized();
5011
6015
  if (activeTurn && !activeTurn.outcomeRecorded) {
5012
6016
  activeTurn.settlementFacts.push(
5013
6017
  'The Boss turn was aborted before it settled; no action was repeated automatically.',
@@ -5064,6 +6068,9 @@ export function createPlaybookCaptainShell(
5064
6068
  failure ??= error;
5065
6069
  }
5066
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();
5067
6074
  lifecycle = 'closed';
5068
6075
  if (failure !== undefined) throw failure;
5069
6076
  }