@sublang/playbook 7.0.0 → 9.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 (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  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 +9 -6
  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 +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  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 +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  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 +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  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 +10 -10
  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 +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  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 +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  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 +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -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
@@ -466,6 +561,31 @@ function stateDigestLine(
466
561
  ].join('; ');
467
562
  }
468
563
 
564
+ // CAPTAIN-5's mirrored ledger member holds the runtime snapshot's
565
+ // pending-question projection — entries of `{ questionId, asker, question,
566
+ // sourceItem }` — never a raw telemetry payload: the linked runtime's state
567
+ // telemetry carries the singular full-context question, whose extra
568
+ // runtime-internal fields (`resumeStateId`) fail the durable snapshot's
569
+ // leaf-projection equality (CAPTAIN-41) and with it headless settlement.
570
+ function mirroredBossQuestions(value: unknown): unknown {
571
+ if (value === undefined || value === null) return undefined;
572
+ const entries = Array.isArray(value) ? value : [value];
573
+ return entries.map((entry) => {
574
+ if (typeof entry !== 'object' || entry === null) return entry;
575
+ const record = entry as Record<string, unknown>;
576
+ const projected: Record<string, unknown> = {};
577
+ if (record.questionId !== undefined) {
578
+ projected.questionId = record.questionId;
579
+ }
580
+ if (record.asker !== undefined) projected.asker = record.asker;
581
+ if (record.question !== undefined) projected.question = record.question;
582
+ if (record.sourceItem !== undefined) {
583
+ projected.sourceItem = record.sourceItem;
584
+ }
585
+ return projected;
586
+ });
587
+ }
588
+
469
589
  function pendingQuestionLines(pending: unknown): string[] {
470
590
  const list = Array.isArray(pending)
471
591
  ? pending
@@ -492,7 +612,16 @@ function pendingQuestionLines(pending: unknown): string[] {
492
612
  : typeof record.id === 'string'
493
613
  ? record.id
494
614
  : undefined;
495
- const player = typeof record.player === 'string' ? record.player : undefined;
615
+ const asker =
616
+ typeof record.asker === 'object' && record.asker !== null
617
+ ? (record.asker as Record<string, unknown>)
618
+ : undefined;
619
+ const askerLabel =
620
+ asker?.kind === 'captain'
621
+ ? 'Captain'
622
+ : asker?.kind === 'role' && typeof asker.roleId === 'string'
623
+ ? asker.roleId
624
+ : undefined;
496
625
  const text =
497
626
  typeof record.question === 'string'
498
627
  ? record.question
@@ -504,9 +633,9 @@ function pendingQuestionLines(pending: unknown): string[] {
504
633
  // second time would cut the line at the seam's limit and drop whatever
505
634
  // the shell had already written after the long part.
506
635
  const asked =
507
- player === undefined
636
+ askerLabel === undefined
508
637
  ? digestLine`${quoteEvidence(text)}`
509
- : digestLine`${quoteEvidence(player)} asks: ${quoteEvidence(text)}`;
638
+ : digestLine`${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
510
639
  const marker = id === undefined ? '' : digestLine`(${quoteEvidence(id)}) `;
511
640
  lines.push(`- ${marker}${asked}`);
512
641
  }
@@ -548,7 +677,10 @@ function renderJournalPayload(payload: JsonValue): string {
548
677
  return raw ?? 'null';
549
678
  }
550
679
 
551
- function renderReseedDigest(records: readonly JournalRecord[]): string {
680
+ function renderJournalDigest(
681
+ records: readonly JournalRecord[],
682
+ heading: string,
683
+ ): string {
552
684
  const lines = records.map(
553
685
  (record) =>
554
686
  `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(
@@ -556,12 +688,26 @@ function renderReseedDigest(records: readonly JournalRecord[]): string {
556
688
  )}`,
557
689
  );
558
690
  return [
559
- 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
691
+ heading,
560
692
  'The labeled ControlView and catalog digest blocks outrank conversation memory.',
561
693
  ...(lines.length === 0 ? ['(no earlier turns)'] : lines),
562
694
  ].join('\n');
563
695
  }
564
696
 
697
+ function renderReseedDigest(records: readonly JournalRecord[]): string {
698
+ return renderJournalDigest(
699
+ records,
700
+ 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
701
+ );
702
+ }
703
+
704
+ function renderCatchUpDigest(records: readonly JournalRecord[]): string {
705
+ return renderJournalDigest(
706
+ records,
707
+ 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.',
708
+ );
709
+ }
710
+
565
711
  // DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
566
712
  // internal control vocabulary.
567
713
  const CONTROL_VOCABULARY: readonly RegExp[] = [
@@ -681,16 +827,32 @@ function proseRejection(
681
827
  return undefined;
682
828
  }
683
829
 
830
+ type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
831
+
684
832
  // 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
- ]);
833
+ // Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
834
+ // value — including the empty list that expresses tool-free — because their
835
+ // supported provider surfaces cannot enforce one, so requesting it fails
836
+ // every control call before the model is reached. Omitting the option is the
837
+ // only way such an adapter can run a control call at all; its isolation then
838
+ // rests on the authored hidden-judge envelope below rather than on provider
839
+ // enforcement.
840
+ const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
841
+ claude: 'provider-enforced',
842
+ codex: 'prompt-only',
843
+ gemini: 'provider-enforced',
844
+ kimi: 'prompt-only',
845
+ opencode: 'prompt-only',
846
+ } as const satisfies Readonly<Record<PlayerAdapterName, CaptainToolIsolation>>;
847
+
848
+ function requiresPromptOnlyToolIsolation(captainAdapter: string): boolean {
849
+ return (
850
+ Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
851
+ CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[
852
+ captainAdapter as PlayerAdapterName
853
+ ] === 'prompt-only'
854
+ );
855
+ }
694
856
 
695
857
  // The tool half of a control call's options. An empty allowlist means "no
696
858
  // tools available" and is distinct from omission, which grants the adapter's
@@ -701,7 +863,7 @@ function controlCallToolOptions(
701
863
  ): { allowedTools?: readonly string[] } {
702
864
  if (
703
865
  captainAdapter !== undefined &&
704
- ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)
866
+ requiresPromptOnlyToolIsolation(captainAdapter)
705
867
  ) {
706
868
  return {};
707
869
  }
@@ -721,14 +883,6 @@ function forwardedToolOptions(
721
883
  return { allowedTools: requested };
722
884
  }
723
885
 
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
886
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
733
887
 
734
888
  interface OutcomeReport {
@@ -839,11 +993,39 @@ function isValidRegistryEntry(
839
993
  ): value is PlaybookCaptainRegistryEntry {
840
994
  if (typeof value !== 'object' || value === null) return false;
841
995
  const e = value as Record<string, unknown>;
996
+ if (
997
+ !Array.isArray(e.requiredRoleIds) ||
998
+ e.requiredRoleIds.some(
999
+ (role) =>
1000
+ typeof role !== 'string' ||
1001
+ !ROLE_ID_PATTERN.test(role) ||
1002
+ role === INTERNAL_CAPTAIN_ID,
1003
+ ) ||
1004
+ new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
1005
+ !Array.isArray(e.concurrentRoleSets)
1006
+ ) {
1007
+ return false;
1008
+ }
1009
+ const roles = new Set(e.requiredRoleIds);
1010
+ const concurrency = e.concurrentRoleSets as unknown[];
1011
+ if (
1012
+ concurrency.some(
1013
+ (set) =>
1014
+ !Array.isArray(set) ||
1015
+ set.length < 2 ||
1016
+ set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
1017
+ new Set(set).size !== set.length,
1018
+ ) ||
1019
+ new Set(concurrency.map((set) => JSON.stringify(set))).size !==
1020
+ concurrency.length
1021
+ ) {
1022
+ return false;
1023
+ }
842
1024
  return (
843
1025
  typeof e.id === 'string' &&
844
1026
  typeof e.command === 'string' &&
845
1027
  typeof e.intent === 'string' &&
846
- Array.isArray(e.requiredRoleIds) &&
1028
+ e.artifactSchema === 2 &&
847
1029
  typeof e.validateOptions === 'function' &&
848
1030
  typeof e.createRuntime === 'function'
849
1031
  );
@@ -925,8 +1107,207 @@ function snapshotUuid(value: JsonValue | undefined, path: string): string {
925
1107
  return id;
926
1108
  }
927
1109
 
1110
+ function snapshotPermissions(
1111
+ value: JsonValue | undefined,
1112
+ path: string,
1113
+ ): PermissionPolicy | undefined {
1114
+ if (value === undefined) return undefined;
1115
+ const record = snapshotRecord(value, path);
1116
+ rejectSnapshotKeys(
1117
+ record,
1118
+ ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'],
1119
+ path,
1120
+ );
1121
+ const normalized: PermissionPolicy = {};
1122
+ if (record.mode !== undefined) {
1123
+ if (record.mode !== 'auto' && record.mode !== 'bypass') {
1124
+ throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
1125
+ }
1126
+ normalized.mode = record.mode;
1127
+ }
1128
+ for (const key of [
1129
+ 'fileWrite',
1130
+ 'shellExecute',
1131
+ 'networkAccess',
1132
+ ] as const) {
1133
+ const level = record[key];
1134
+ if (level === undefined) continue;
1135
+ if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
1136
+ throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
1137
+ }
1138
+ normalized[key] = level;
1139
+ }
1140
+ if (record.writablePaths !== undefined) {
1141
+ if (
1142
+ !Array.isArray(record.writablePaths) ||
1143
+ record.writablePaths.some(
1144
+ (entry) => typeof entry !== 'string' || entry.length === 0,
1145
+ )
1146
+ ) {
1147
+ throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
1148
+ }
1149
+ normalized.writablePaths = [...record.writablePaths];
1150
+ }
1151
+ return normalized;
1152
+ }
1153
+
1154
+ function livePermissions(
1155
+ value: DeepReadonly<PermissionPolicy> | undefined,
1156
+ ): PermissionPolicy | undefined {
1157
+ if (value === undefined) return undefined;
1158
+ return {
1159
+ ...(value.mode === undefined ? {} : { mode: value.mode }),
1160
+ ...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
1161
+ ...(value.shellExecute === undefined
1162
+ ? {}
1163
+ : { shellExecute: value.shellExecute }),
1164
+ ...(value.networkAccess === undefined
1165
+ ? {}
1166
+ : { networkAccess: value.networkAccess }),
1167
+ ...(value.writablePaths === undefined
1168
+ ? {}
1169
+ : { writablePaths: [...value.writablePaths] }),
1170
+ };
1171
+ }
1172
+
1173
+ function snapshotFixedAgent(
1174
+ value: JsonValue | undefined,
1175
+ path: string,
1176
+ ): SnapshotAgentEnvelope {
1177
+ const record = snapshotRecord(value, path);
1178
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
1179
+ const adapter = snapshotString(record.adapter, `${path}.adapter`);
1180
+ const instruction =
1181
+ record.instruction === undefined
1182
+ ? undefined
1183
+ : snapshotString(record.instruction, `${path}.instruction`, true);
1184
+ const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
1185
+ return {
1186
+ adapter,
1187
+ ...(instruction === undefined ? {} : { instruction }),
1188
+ ...(permissions === undefined ? {} : { permissions }),
1189
+ };
1190
+ }
1191
+
1192
+ function snapshotPlayerSessions(
1193
+ value: JsonValue | undefined,
1194
+ path: string,
1195
+ ): Readonly<Record<string, PlayerLedgerSnapshotEntry>> {
1196
+ const sessions = snapshotRecord(value, path);
1197
+ return Object.fromEntries(
1198
+ Object.entries(sessions).map(([playerId, raw]) => {
1199
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1200
+ throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
1201
+ }
1202
+ const record = snapshotRecord(raw, `${path}.${playerId}`);
1203
+ rejectSnapshotKeys(
1204
+ record,
1205
+ ['adapter', 'instruction', 'permissions', 'resumeToken'],
1206
+ `${path}.${playerId}`,
1207
+ );
1208
+ const fixed = snapshotFixedAgent(
1209
+ Object.fromEntries(
1210
+ Object.entries(record).filter(([key]) => key !== 'resumeToken'),
1211
+ ) as JsonValue,
1212
+ `${path}.${playerId}`,
1213
+ );
1214
+ const resumeToken =
1215
+ record.resumeToken === undefined
1216
+ ? undefined
1217
+ : snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
1218
+ return [
1219
+ playerId,
1220
+ { ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
1221
+ ];
1222
+ }),
1223
+ );
1224
+ }
1225
+
1226
+ function snapshotFrameRoleBindings(
1227
+ value: JsonValue | undefined,
1228
+ path: string,
1229
+ ): Readonly<Record<string, string>> {
1230
+ const bindings = snapshotRecord(value, path);
1231
+ return Object.fromEntries(
1232
+ Object.entries(bindings).map(([roleId, raw]) => {
1233
+ if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
1234
+ throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
1235
+ }
1236
+ const playerId = snapshotString(raw, `${path}.${roleId}`);
1237
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1238
+ throw new TypeError(`${path}.${roleId} has invalid player id`);
1239
+ }
1240
+ return [roleId, playerId];
1241
+ }),
1242
+ );
1243
+ }
1244
+
1245
+ function normalizeHostPlayerResult(
1246
+ value: unknown,
1247
+ expectedPlayerId: string,
1248
+ ): PlayerResult {
1249
+ const path = 'tmux-play delegated-player result';
1250
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1251
+ throw new TypeError(`${path} must be an object`);
1252
+ }
1253
+ const prototype = Object.getPrototypeOf(value) as unknown;
1254
+ if (prototype !== Object.prototype && prototype !== null) {
1255
+ throw new TypeError(`${path} must be a plain JSON object`);
1256
+ }
1257
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1258
+ const allowedKeys = new Set([
1259
+ 'status',
1260
+ 'playerId',
1261
+ 'turnId',
1262
+ 'resumeToken',
1263
+ 'finalText',
1264
+ 'error',
1265
+ ]);
1266
+ const normalized: Record<string, unknown> = {};
1267
+ for (const key of Reflect.ownKeys(descriptors)) {
1268
+ if (typeof key === 'symbol') {
1269
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
1270
+ }
1271
+ const descriptor = descriptors[key];
1272
+ if (!allowedKeys.has(key)) {
1273
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
1274
+ }
1275
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
1276
+ throw new TypeError(`${path}.${key} must be an enumerable data property`);
1277
+ }
1278
+ // Cligent deliberately exposes optional result members as own
1279
+ // `undefined` data properties. Omit only those members before taking the
1280
+ // immutable JSON snapshot; every other value still passes the strict
1281
+ // JSON validator below.
1282
+ if (descriptor.value !== undefined) normalized[key] = descriptor.value;
1283
+ }
1284
+ const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
1285
+ rejectSnapshotKeys(
1286
+ record,
1287
+ ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'],
1288
+ path,
1289
+ );
1290
+ if (record.playerId !== expectedPlayerId) {
1291
+ throw new TypeError(`${path}.playerId does not match the requested player`);
1292
+ }
1293
+ snapshotInteger(record.turnId, `${path}.turnId`, 1);
1294
+ return validatePlayerResult(
1295
+ {
1296
+ status: record.status,
1297
+ ...(record.resumeToken === undefined
1298
+ ? {}
1299
+ : { resumeToken: record.resumeToken }),
1300
+ ...(record.finalText === undefined
1301
+ ? {}
1302
+ : { finalText: record.finalText }),
1303
+ ...(record.error === undefined ? {} : { error: record.error }),
1304
+ },
1305
+ path,
1306
+ );
1307
+ }
1308
+
928
1309
  /** Validate, detach, and freeze one untrusted shell snapshot. */
929
- function assertPlaybookCaptainShellSnapshot(
1310
+ export function assertPlaybookCaptainShellSnapshot(
930
1311
  value: unknown,
931
1312
  ): PlaybookCaptainShellSnapshot {
932
1313
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
@@ -935,6 +1316,7 @@ function assertPlaybookCaptainShellSnapshot(
935
1316
  const commonKeys = [
936
1317
  'schemaVersion',
937
1318
  'captain',
1319
+ 'playerSessions',
938
1320
  'issuedSessionIds',
939
1321
  'sequences',
940
1322
  'journal',
@@ -950,7 +1332,6 @@ function assertPlaybookCaptainShellSnapshot(
950
1332
  [
951
1333
  ...commonKeys,
952
1334
  'frames',
953
- 'rootPlayerResumeTokens',
954
1335
  'pendingBossQuestions',
955
1336
  'lastError',
956
1337
  ],
@@ -961,9 +1342,9 @@ function assertPlaybookCaptainShellSnapshot(
961
1342
  'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
962
1343
  );
963
1344
  }
964
- if (snapshot.schemaVersion !== 1) {
1345
+ if (snapshot.schemaVersion !== 3) {
965
1346
  throw new TypeError(
966
- `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`,
1347
+ `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
967
1348
  );
968
1349
  }
969
1350
 
@@ -973,7 +1354,7 @@ function assertPlaybookCaptainShellSnapshot(
973
1354
  );
974
1355
  rejectSnapshotKeys(
975
1356
  captain,
976
- ['sessionId', 'runtime', 'conversation'],
1357
+ ['sessionId', 'runtime', 'agent', 'conversation'],
977
1358
  'Captain shell snapshot.captain',
978
1359
  );
979
1360
  const captainSessionId = snapshotUuid(
@@ -984,6 +1365,10 @@ function assertPlaybookCaptainShellSnapshot(
984
1365
  captain.runtime,
985
1366
  INTERNAL_CAPTAIN_ID,
986
1367
  );
1368
+ const captainAgent = snapshotFixedAgent(
1369
+ captain.agent,
1370
+ 'Captain shell snapshot.captain.agent',
1371
+ );
987
1372
  const conversation = snapshotRecord(
988
1373
  captain.conversation,
989
1374
  'Captain shell snapshot.captain.conversation',
@@ -1000,7 +1385,26 @@ function assertPlaybookCaptainShellSnapshot(
1000
1385
  token: snapshotString(
1001
1386
  conversation.token,
1002
1387
  'Captain shell snapshot.captain.conversation.token',
1003
- true,
1388
+ ),
1389
+ };
1390
+ } else if (conversation.kind === 'needsCatchUp') {
1391
+ rejectSnapshotKeys(
1392
+ conversation,
1393
+ ['kind', 'resume', 'afterJournalSeq'],
1394
+ 'Captain shell snapshot.captain.conversation',
1395
+ );
1396
+ normalizedConversation = {
1397
+ kind: 'needsCatchUp',
1398
+ resume:
1399
+ conversation.resume === false
1400
+ ? false
1401
+ : snapshotString(
1402
+ conversation.resume,
1403
+ 'Captain shell snapshot.captain.conversation.resume',
1404
+ ),
1405
+ afterJournalSeq: snapshotInteger(
1406
+ conversation.afterJournalSeq,
1407
+ 'Captain shell snapshot.captain.conversation.afterJournalSeq',
1004
1408
  ),
1005
1409
  };
1006
1410
  } else if (
@@ -1131,6 +1535,29 @@ function assertPlaybookCaptainShellSnapshot(
1131
1535
  'Captain shell snapshot sequences do not match the complete journal',
1132
1536
  );
1133
1537
  }
1538
+ const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
1539
+ if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
1540
+ throw new TypeError(
1541
+ 'Captain shell snapshot history is empty exactly when its conversation is unopened',
1542
+ );
1543
+ }
1544
+ if (
1545
+ normalizedConversation.kind === 'needsCatchUp' &&
1546
+ normalizedConversation.afterJournalSeq >= journalSequence
1547
+ ) {
1548
+ throw new TypeError(
1549
+ 'Captain shell snapshot catch-up watermark must precede the current journal sequence',
1550
+ );
1551
+ }
1552
+ if (
1553
+ normalizedConversation.kind === 'needsCatchUp' &&
1554
+ ((normalizedConversation.resume === false) !==
1555
+ (normalizedConversation.afterJournalSeq === 0))
1556
+ ) {
1557
+ throw new TypeError(
1558
+ 'Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero',
1559
+ );
1560
+ }
1134
1561
 
1135
1562
  let lastAction: PlaybookCaptainShellSnapshotFields['lastAction'];
1136
1563
  if (snapshot.lastAction !== undefined) {
@@ -1162,13 +1589,19 @@ function assertPlaybookCaptainShellSnapshot(
1162
1589
  typeof lastSettlementStatus
1163
1590
  >;
1164
1591
  }
1592
+ const playerSessions = snapshotPlayerSessions(
1593
+ snapshot.playerSessions,
1594
+ 'Captain shell snapshot.playerSessions',
1595
+ );
1165
1596
  const common: PlaybookCaptainShellSnapshotFields = {
1166
- schemaVersion: 1,
1597
+ schemaVersion: 3,
1167
1598
  captain: {
1168
1599
  sessionId: captainSessionId,
1169
1600
  runtime: captainRuntime,
1601
+ agent: captainAgent,
1170
1602
  conversation: normalizedConversation,
1171
1603
  },
1604
+ playerSessions,
1172
1605
  issuedSessionIds: issued,
1173
1606
  sequences: { turn: turnSequence, journal: journalSequence },
1174
1607
  journal: normalizedJournal,
@@ -1177,6 +1610,23 @@ function assertPlaybookCaptainShellSnapshot(
1177
1610
  ? {}
1178
1611
  : { lastSettlementStatus }),
1179
1612
  };
1613
+ if (
1614
+ captainRuntime.state.status !== 'active' ||
1615
+ !captainRuntime.state.quiescent ||
1616
+ !captainRuntime.state.tags.includes('playbook.parked') ||
1617
+ captainRuntime.suspendedCall !== undefined ||
1618
+ Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
1619
+ captainRuntime.pendingBossQuestions.length > 0
1620
+ ) {
1621
+ throw new TypeError(
1622
+ 'Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended',
1623
+ );
1624
+ }
1625
+ if (captainRuntime.sequences.turn !== turnSequence) {
1626
+ throw new TypeError(
1627
+ 'Captain shell snapshot Captain and shell turn sequences must match',
1628
+ );
1629
+ }
1180
1630
  if (mode === 'chat') {
1181
1631
  return snapshotJsonValue(
1182
1632
  { ...common, mode },
@@ -1204,6 +1654,8 @@ function assertPlaybookCaptainShellSnapshot(
1204
1654
  'depth',
1205
1655
  'parentSessionId',
1206
1656
  'parentCallId',
1657
+ 'options',
1658
+ 'roleBindings',
1207
1659
  'runtime',
1208
1660
  ],
1209
1661
  `Captain shell snapshot.frames[${index}]`,
@@ -1243,6 +1695,11 @@ function assertPlaybookCaptainShellSnapshot(
1243
1695
  playbookId,
1244
1696
  { allowSuspendedCall: true },
1245
1697
  );
1698
+ const options = frame.options as JsonValue;
1699
+ const roleBindings = snapshotFrameRoleBindings(
1700
+ frame.roleBindings,
1701
+ `Captain shell snapshot.frames[${index}].roleBindings`,
1702
+ );
1246
1703
  normalizedFrames.push({
1247
1704
  playbookId,
1248
1705
  sessionId,
@@ -1250,23 +1707,11 @@ function assertPlaybookCaptainShellSnapshot(
1250
1707
  depth,
1251
1708
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
1252
1709
  ...(parentCallId === undefined ? {} : { parentCallId }),
1710
+ options,
1711
+ roleBindings,
1253
1712
  runtime,
1254
1713
  });
1255
1714
  }
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
1715
  let normalizedLastError:
1271
1716
  | { readonly name: string; readonly message: string }
1272
1717
  | undefined;
@@ -1293,12 +1738,123 @@ function assertPlaybookCaptainShellSnapshot(
1293
1738
  ),
1294
1739
  };
1295
1740
  }
1741
+ const activePlaybooks = new Set<string>();
1742
+ const activeSessionIds = new Set<string>([captainSessionId]);
1743
+ const issuedIds = new Set(issued);
1744
+ const rootSessionId = normalizedFrames[0]!.sessionId;
1745
+ for (const [index, frame] of normalizedFrames.entries()) {
1746
+ if (activePlaybooks.has(frame.playbookId)) {
1747
+ throw new TypeError(
1748
+ 'Captain shell snapshot engagement path must not contain a playbook cycle',
1749
+ );
1750
+ }
1751
+ activePlaybooks.add(frame.playbookId);
1752
+ if (activeSessionIds.has(frame.sessionId)) {
1753
+ throw new TypeError(
1754
+ 'Captain shell snapshot frame session ids must be unique',
1755
+ );
1756
+ }
1757
+ activeSessionIds.add(frame.sessionId);
1758
+ if (!issuedIds.has(frame.sessionId)) {
1759
+ throw new TypeError(
1760
+ 'Captain shell snapshot frame session id was not historically issued',
1761
+ );
1762
+ }
1763
+ if (
1764
+ frame.depth !== index ||
1765
+ frame.rootSessionId !== rootSessionId ||
1766
+ frame.runtime.state.status !== 'active' ||
1767
+ !frame.runtime.state.quiescent
1768
+ ) {
1769
+ throw new TypeError(
1770
+ 'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
1771
+ );
1772
+ }
1773
+ if (index === 0) {
1774
+ if (
1775
+ frame.sessionId !== frame.rootSessionId ||
1776
+ frame.parentSessionId !== undefined ||
1777
+ frame.parentCallId !== undefined
1778
+ ) {
1779
+ throw new TypeError(
1780
+ 'Captain shell snapshot root frame has child-only identity fields',
1781
+ );
1782
+ }
1783
+ } else {
1784
+ const parent = normalizedFrames[index - 1]!;
1785
+ const pending = parent.runtime.suspendedCall;
1786
+ if (
1787
+ frame.parentSessionId !== parent.sessionId ||
1788
+ frame.parentCallId === undefined
1789
+ ) {
1790
+ throw new TypeError(
1791
+ 'Captain shell snapshot child frame does not identify its immediate parent',
1792
+ );
1793
+ }
1794
+ if (
1795
+ !pending ||
1796
+ pending.callId !== frame.parentCallId ||
1797
+ pending.playbookId !== frame.playbookId ||
1798
+ pending.childSessionId !== frame.sessionId
1799
+ ) {
1800
+ throw new TypeError(
1801
+ 'Captain shell snapshot parent suspended call does not match its child edge',
1802
+ );
1803
+ }
1804
+ }
1805
+ for (const playerId of Object.values(frame.roleBindings)) {
1806
+ if (playerSessions[playerId] === undefined) {
1807
+ throw new TypeError(
1808
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`,
1809
+ );
1810
+ }
1811
+ }
1812
+ for (const question of frame.runtime.pendingBossQuestions) {
1813
+ if (
1814
+ question.asker.kind === 'role' &&
1815
+ frame.roleBindings[question.asker.roleId] === undefined
1816
+ ) {
1817
+ throw new TypeError(
1818
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`,
1819
+ );
1820
+ }
1821
+ }
1822
+ const projectedTokens = Object.fromEntries(
1823
+ Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
1824
+ const token = playerSessions[playerId]?.resumeToken;
1825
+ return token === undefined ? [] : [[role, token] as const];
1826
+ }),
1827
+ );
1828
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
1829
+ throw new TypeError(
1830
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`,
1831
+ );
1832
+ }
1833
+ }
1834
+ const leafRuntime = normalizedFrames.at(-1)!.runtime;
1835
+ if (
1836
+ leafRuntime.suspendedCall !== undefined ||
1837
+ !leafRuntime.state.tags.includes('playbook.parked')
1838
+ ) {
1839
+ throw new TypeError(
1840
+ 'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
1841
+ );
1842
+ }
1843
+ if (
1844
+ !isDeepStrictEqual(
1845
+ snapshot.pendingBossQuestions ?? [],
1846
+ leafRuntime.pendingBossQuestions,
1847
+ )
1848
+ ) {
1849
+ throw new TypeError(
1850
+ 'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
1851
+ );
1852
+ }
1296
1853
  return snapshotJsonValue(
1297
1854
  {
1298
1855
  ...common,
1299
1856
  mode,
1300
1857
  frames: normalizedFrames,
1301
- rootPlayerResumeTokens: normalizedRootTokens,
1302
1858
  ...(snapshot.pendingBossQuestions === undefined
1303
1859
  ? {}
1304
1860
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -1310,30 +1866,120 @@ function assertPlaybookCaptainShellSnapshot(
1310
1866
  ) as unknown as PlaybookCaptainShellSnapshot;
1311
1867
  }
1312
1868
 
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
1869
  interface BuiltRegistry {
1325
1870
  entries: readonly PlaybookCaptainRegistryEntry[];
1326
1871
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
1327
1872
  byId: Map<string, PlaybookCaptainRegistryEntry>;
1328
1873
  enablementById: Map<string, Enablement>;
1874
+ captainAgent: SessionAgent;
1875
+ playerAgents: Map<string, SessionAgent>;
1876
+ }
1877
+
1878
+ function snapshotTuningSelection(
1879
+ value: JsonValue | undefined,
1880
+ path: string,
1881
+ ): TuningSelection {
1882
+ const selection = snapshotRecord(value, path);
1883
+ if (selection.kind === 'provider-default') {
1884
+ rejectSnapshotKeys(selection, ['kind'], path);
1885
+ return { kind: 'provider-default' };
1886
+ }
1887
+ if (selection.kind === 'value') {
1888
+ rejectSnapshotKeys(selection, ['kind', 'value'], path);
1889
+ return {
1890
+ kind: 'value',
1891
+ value: snapshotString(selection.value, `${path}.value`),
1892
+ };
1893
+ }
1894
+ throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
1895
+ }
1896
+
1897
+ const EFFORT_VALUES: ReadonlySet<string> = new Set([
1898
+ 'on',
1899
+ 'minimal',
1900
+ 'low',
1901
+ 'medium',
1902
+ 'high',
1903
+ 'xhigh',
1904
+ 'max',
1905
+ 'ultra',
1906
+ 'ultracode',
1907
+ 'off',
1908
+ ]);
1909
+
1910
+ function snapshotEffortSelection(
1911
+ value: JsonValue | undefined,
1912
+ path: string,
1913
+ ): TuningSelection<Effort> {
1914
+ const selection = snapshotTuningSelection(value, path);
1915
+ if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
1916
+ throw new TypeError(`${path}.value is not a supported effort selection`);
1917
+ }
1918
+ return selection as TuningSelection<Effort>;
1919
+ }
1920
+
1921
+ function snapshotSessionAgent(
1922
+ value: JsonValue | undefined,
1923
+ path: string,
1924
+ ): SessionAgent {
1925
+ const agent = snapshotRecord(value, path);
1926
+ rejectSnapshotKeys(
1927
+ agent,
1928
+ ['adapter', 'model', 'effort', 'instruction', 'permissions'],
1929
+ path,
1930
+ );
1931
+ const fixed = snapshotFixedAgent(
1932
+ Object.fromEntries(
1933
+ Object.entries(agent).filter(
1934
+ ([key]) => key !== 'model' && key !== 'effort',
1935
+ ),
1936
+ ) as JsonValue,
1937
+ path,
1938
+ );
1939
+ return {
1940
+ adapter: fixed.adapter,
1941
+ ...(fixed.instruction === undefined
1942
+ ? {}
1943
+ : { instruction: fixed.instruction }),
1944
+ ...(fixed.permissions === undefined
1945
+ ? {}
1946
+ : { permissions: livePermissions(fixed.permissions) }),
1947
+ model: snapshotTuningSelection(agent.model, `${path}.model`),
1948
+ effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
1949
+ };
1329
1950
  }
1330
1951
 
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.
1952
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, 'model' | 'effort'> {
1953
+ return {
1954
+ adapter: agent.adapter,
1955
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1956
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1957
+ };
1958
+ }
1959
+
1960
+ function callSettings(
1961
+ agent: SessionAgent,
1962
+ tuning: Pick<SessionAgent, 'model' | 'effort'> = agent,
1963
+ ): AgentCallSettings {
1964
+ return {
1965
+ model: tuning.model,
1966
+ effort: tuning.effort,
1967
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1968
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1969
+ };
1970
+ }
1971
+
1972
+ function promptIdentity(binding: EffectivePlayerBinding): string {
1973
+ return binding.model.kind === 'value'
1974
+ ? binding.model.value
1975
+ : binding.agent.adapter;
1976
+ }
1977
+
1978
+ // Resolve the active registry at init from exact normalized role and session
1979
+ // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1980
+ // exists at this boundary.
1334
1981
  async function buildEnablements(
1335
1982
  options: unknown,
1336
- players: readonly RegistryPlayer[],
1337
1983
  loadModule: (specifier: string) => Promise<unknown>,
1338
1984
  ): Promise<BuiltRegistry> {
1339
1985
  const entries: PlaybookCaptainRegistryEntry[] = [];
@@ -1341,10 +1987,63 @@ async function buildEnablements(
1341
1987
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
1342
1988
  const enablementById = new Map<string, Enablement>();
1343
1989
 
1344
- const config = readPlaybooksConfig(options);
1345
- if (config === undefined) {
1990
+ const detached = snapshotJsonValue(options, 'captain.options');
1991
+ const top = snapshotRecord(detached, 'captain.options');
1992
+ rejectSnapshotKeys(
1993
+ top,
1994
+ ['playbooks', 'sessionAgents', 'captainAdapter'],
1995
+ 'captain.options',
1996
+ );
1997
+ const configValue = top.playbooks;
1998
+ if (
1999
+ typeof configValue !== 'object' ||
2000
+ configValue === null ||
2001
+ Array.isArray(configValue)
2002
+ ) {
1346
2003
  throw new Error('captain.options.playbooks is required');
1347
2004
  }
2005
+ const config = configValue as Record<string, JsonValue>;
2006
+
2007
+ const sessionAgents = snapshotRecord(
2008
+ top.sessionAgents,
2009
+ 'captain.options.sessionAgents',
2010
+ );
2011
+ rejectSnapshotKeys(
2012
+ sessionAgents,
2013
+ ['captain', 'players'],
2014
+ 'captain.options.sessionAgents',
2015
+ );
2016
+ const captainAgent = snapshotSessionAgent(
2017
+ sessionAgents.captain,
2018
+ 'captain.options.sessionAgents.captain',
2019
+ );
2020
+ if (
2021
+ top.captainAdapter !== undefined &&
2022
+ top.captainAdapter !== captainAgent.adapter
2023
+ ) {
2024
+ throw new Error(
2025
+ 'captain.options.captainAdapter must equal sessionAgents.captain.adapter',
2026
+ );
2027
+ }
2028
+ const playerAgentRecord = snapshotRecord(
2029
+ sessionAgents.players,
2030
+ 'captain.options.sessionAgents.players',
2031
+ );
2032
+ const playerAgents = new Map<string, SessionAgent>();
2033
+ for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
2034
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2035
+ throw new Error(
2036
+ `captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`,
2037
+ );
2038
+ }
2039
+ playerAgents.set(
2040
+ playerId,
2041
+ snapshotSessionAgent(
2042
+ agent,
2043
+ `captain.options.sessionAgents.players.${playerId}`,
2044
+ ),
2045
+ );
2046
+ }
1348
2047
 
1349
2048
  const ids = Object.keys(config);
1350
2049
  if (ids.length === 0) {
@@ -1362,7 +2061,12 @@ async function buildEnablements(
1362
2061
  if (typeof block !== 'object' || block === null || Array.isArray(block)) {
1363
2062
  throw new Error(`captain.options.playbooks.${id} must be an object`);
1364
2063
  }
1365
- const record = block as Record<string, unknown>;
2064
+ const record = block as Record<string, JsonValue>;
2065
+ rejectSnapshotKeys(
2066
+ record,
2067
+ ['from', 'command', 'roles', 'options'],
2068
+ `captain.options.playbooks.${id}`,
2069
+ );
1366
2070
  const from = record.from;
1367
2071
  if (typeof from !== 'string' || from.length === 0) {
1368
2072
  throw new Error(
@@ -1409,26 +2113,86 @@ async function buildEnablements(
1409
2113
  `captain.options.playbooks has a duplicate effective command "${command}"`,
1410
2114
  );
1411
2115
  }
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
- });
2116
+ const roleRecord = snapshotRecord(
2117
+ record.roles,
2118
+ `captain.options.playbooks.${id}.roles`,
2119
+ );
2120
+ const required = new Set(entry.requiredRoleIds);
2121
+ const configuredRoles = Object.keys(roleRecord);
2122
+ const missing = entry.requiredRoleIds.filter(
2123
+ (role) => !Object.hasOwn(roleRecord, role),
2124
+ );
2125
+ const extra = configuredRoles.filter((role) => !required.has(role));
2126
+ if (missing.length > 0 || extra.length > 0) {
2127
+ throw new Error(
2128
+ `captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`,
2129
+ );
2130
+ }
2131
+ const roleBindings = new Map<string, EffectivePlayerBinding>();
2132
+ for (const role of entry.requiredRoleIds) {
2133
+ const path = `captain.options.playbooks.${id}.roles.${role}`;
2134
+ const rawBinding = snapshotRecord(roleRecord[role], path);
2135
+ rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
2136
+ const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
2137
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2138
+ throw new Error(`${path}.playerId is not a canonical player id`);
2139
+ }
2140
+ const agent = playerAgents.get(playerId);
2141
+ if (!agent) {
2142
+ throw new Error(
2143
+ `${path}.playerId names absent session player ${JSON.stringify(playerId)}`,
2144
+ );
2145
+ }
2146
+ roleBindings.set(role, {
2147
+ playerId,
2148
+ model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
2149
+ effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
2150
+ agent,
2151
+ });
2152
+ }
2153
+ for (const concurrentRoles of entry.concurrentRoleSets) {
2154
+ const playerIds = concurrentRoles.map(
2155
+ (role) => roleBindings.get(role)!.playerId,
2156
+ );
2157
+ if (new Set(playerIds).size !== playerIds.length) {
2158
+ throw new Error(
2159
+ `captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`,
2160
+ );
2161
+ }
2162
+ }
2163
+ const validatedOptions = snapshotJsonValue(
2164
+ entry.validateOptions(record.options),
2165
+ `captain.options.playbooks.${id}.options`,
2166
+ );
1420
2167
  entries.push(entry);
1421
2168
  byId.set(entry.id, entry);
1422
2169
  byCommand.set(command, entry);
1423
2170
  enablementById.set(entry.id, {
1424
2171
  entry,
1425
2172
  command,
1426
- optionInput: record.options,
1427
- boundPlayers,
1428
- hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
2173
+ options: validatedOptions,
2174
+ roleBindings,
1429
2175
  });
1430
2176
  }
1431
- return { entries, byCommand, byId, enablementById };
2177
+ const referenced = new Set(
2178
+ [...enablementById.values()].flatMap((enablement) =>
2179
+ [...enablement.roleBindings.values()].map((binding) => binding.playerId),
2180
+ ),
2181
+ );
2182
+ const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
2183
+ if (unreferenced !== undefined) {
2184
+ throw new Error(
2185
+ `captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`,
2186
+ );
2187
+ }
2188
+ return {
2189
+ entries,
2190
+ byCommand,
2191
+ byId,
2192
+ enablementById,
2193
+ captainAgent,
2194
+ playerAgents,
2195
+ };
1432
2196
  }
1433
2197
 
1434
2198
  export function createPlaybookCaptainShell(
@@ -1441,10 +2205,11 @@ export function createPlaybookCaptainShell(
1441
2205
  const createCaptainRuntime: NonNullable<
1442
2206
  PlaybookCaptainDeps['createCaptainRuntime']
1443
2207
  > = 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);
2208
+ let captainAgent: SessionAgent | undefined;
2209
+ let captainAdapter: string | undefined;
2210
+ let playerAgents = new Map<string, SessionAgent>();
2211
+ const playerLedger = new Map<string, PlayerLedgerEntry>();
2212
+ const playerTransactions = new Map<string, PlayerTransaction>();
1448
2213
  let entries: readonly PlaybookCaptainRegistryEntry[] = [];
1449
2214
  let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
1450
2215
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
@@ -1460,7 +2225,6 @@ export function createPlaybookCaptainShell(
1460
2225
  | 'disposing'
1461
2226
  | 'closed' = 'fresh';
1462
2227
  let terminallyDisposed = false;
1463
- let players: readonly RegistryPlayer[] = [];
1464
2228
  let activeContext: CaptainContext | undefined;
1465
2229
  const frames: EngagementFrame[] = [];
1466
2230
  let mode: ShellMode = 'chat';
@@ -1512,15 +2276,19 @@ export function createPlaybookCaptainShell(
1512
2276
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
1513
2277
  let captainRuntime: PlaybookRuntime | undefined;
1514
2278
  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.
2279
+ // CAPTAIN-35: a preflight settings rejection retains proven continuity but
2280
+ // records the exact journal suffix still owed; other continuity failures
2281
+ // require a fresh, full reseed.
1518
2282
  let conversation: DurableConversation = { kind: 'unopened' };
1519
2283
  let shuttingDown = false;
1520
2284
  const journal: JournalRecord[] = [];
1521
2285
  let journalSeq = 0;
1522
2286
  let turnSequence = 0;
1523
2287
  let activeTurn: ActiveTurn | undefined;
2288
+ // `PlayerSessionStore.restore` is authoritative only while the shell is
2289
+ // awaiting the exact owning runtime's restore during a closed-gate shell
2290
+ // restoration. A runtime cannot use the store as a general ledger writer.
2291
+ let restoringPlayerSessionFrame: EngagementFrame | undefined;
1524
2292
  // The durable call the runtime is about to make, taken from the paired
1525
2293
  // `captain.call.started` boundary the engine emits before the port call
1526
2294
  // (CAPTAIN-9): the shell never infers a call's kind from its prose.
@@ -1609,7 +2377,10 @@ export function createPlaybookCaptainShell(
1609
2377
  // (CAPTAIN-5/CAPTAIN-6).
1610
2378
  ...(captainRuntime
1611
2379
  ? {
1612
- durableConversation: conversation.kind === 'pinned',
2380
+ durableConversation:
2381
+ conversation.kind === 'pinned' ||
2382
+ (conversation.kind === 'needsCatchUp' &&
2383
+ conversation.resume !== false),
1613
2384
  sessionJournal: true,
1614
2385
  }
1615
2386
  : {}),
@@ -1807,8 +2578,9 @@ export function createPlaybookCaptainShell(
1807
2578
  }
1808
2579
 
1809
2580
  if (leafFrame() === frame) {
1810
- pendingBossQuestions =
1811
- record.pendingBossQuestions ?? record.pendingBossQuestion;
2581
+ pendingBossQuestions = mirroredBossQuestions(
2582
+ record.pendingBossQuestions ?? record.pendingBossQuestion,
2583
+ );
1812
2584
  lastError = normalizeErrorCompact(record.lastError);
1813
2585
  if (state.quiescent && state.tags.includes('playbook.parked')) {
1814
2586
  await setMode(
@@ -1826,42 +2598,146 @@ export function createPlaybookCaptainShell(
1826
2598
  ) => Promise<PlaybookCallStart>;
1827
2599
 
1828
2600
  const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
1829
- callPlayer: async (playerId, prompt, signal, options) => {
2601
+ callPlayer: async (roleId, prompt, signal, options) => {
1830
2602
  admitHostBoundary();
1831
- if (!activeContext) {
2603
+ if (!activeContext || !activeTurn || !frame.playerCallScope) {
1832
2604
  throw new Error('callPlayer invoked outside a Boss turn');
1833
2605
  }
1834
2606
  const context = activeContext;
2607
+ const admittedTurn = activeTurn;
2608
+ const scope = frame.playerCallScope;
1835
2609
  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++;
2610
+ const binding = bindingFor(frame, roleId);
2611
+ const ledger = playerLedger.get(binding.playerId);
2612
+ if (!ledger) {
2613
+ throw new Error(
2614
+ `${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
2615
+ );
1854
2616
  }
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 } : {}),
2617
+ const expectedResume = ledger.resumeToken ?? false;
2618
+ if (options.resume !== expectedResume) {
2619
+ throw new Error(
2620
+ `${frameLabel(frame)} player continuation changed before dispatch`,
2621
+ );
2622
+ }
2623
+ if (playerTransactions.has(binding.playerId)) {
2624
+ throw new Error(
2625
+ `session player ${JSON.stringify(binding.playerId)} already has a call in flight`,
2626
+ );
2627
+ }
2628
+ const settings = callSettings(binding.agent, binding);
2629
+ const calling: PlayerTransaction = {
2630
+ phase: 'calling',
2631
+ frame,
2632
+ roleId,
2633
+ turnId: admittedTurn.id,
2634
+ signal,
2635
+ scope,
2636
+ abandoned: false,
1864
2637
  };
2638
+ playerTransactions.set(binding.playerId, calling);
2639
+ let result: PlayerResult;
2640
+ let hostResolved = false;
2641
+ try {
2642
+ let rawResult: unknown;
2643
+ try {
2644
+ rawResult = await trackHostCall(
2645
+ frame,
2646
+ classifySettingsCall(() =>
2647
+ context.callPlayer(binding.playerId, prompt, {
2648
+ resume: options.resume,
2649
+ settings,
2650
+ }),
2651
+ ),
2652
+ );
2653
+ hostResolved = true;
2654
+ } catch (error) {
2655
+ if (error instanceof AgentSettingsPreflightError) {
2656
+ if (
2657
+ playerTransactions.get(binding.playerId) !== calling ||
2658
+ calling.abandoned ||
2659
+ signal.aborted ||
2660
+ activeTurn !== admittedTurn ||
2661
+ frame.playerCallScope !== scope ||
2662
+ !frames.includes(frame)
2663
+ ) {
2664
+ if (playerTransactions.get(binding.playerId) === calling) {
2665
+ playerTransactions.delete(binding.playerId);
2666
+ }
2667
+ signal.throwIfAborted();
2668
+ throw new Error(
2669
+ `${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`,
2670
+ );
2671
+ }
2672
+ throw rememberSettingsPreflight(error.rejection);
2673
+ }
2674
+ throw error;
2675
+ }
2676
+ result = normalizeHostPlayerResult(rawResult, binding.playerId);
2677
+ const transitionRequired =
2678
+ result.resumeToken !== undefined || result.status === 'ok';
2679
+ if (
2680
+ playerTransactions.get(binding.playerId) !== calling ||
2681
+ calling.abandoned ||
2682
+ signal.aborted ||
2683
+ activeTurn !== admittedTurn ||
2684
+ frame.playerCallScope !== scope ||
2685
+ !frames.includes(frame)
2686
+ ) {
2687
+ if (playerTransactions.get(binding.playerId) === calling) {
2688
+ if (transitionRequired) {
2689
+ playerTransactions.set(binding.playerId, {
2690
+ phase: 'quarantined',
2691
+ frame,
2692
+ roleId,
2693
+ turnId: admittedTurn.id,
2694
+ signal,
2695
+ scope,
2696
+ reason:
2697
+ 'a transition-worthy result arrived after its runtime operation ended',
2698
+ });
2699
+ } else {
2700
+ playerTransactions.delete(binding.playerId);
2701
+ }
2702
+ }
2703
+ signal.throwIfAborted();
2704
+ throw new Error(
2705
+ `${frameLabel(frame)} player result arrived after its runtime operation ended`,
2706
+ );
2707
+ }
2708
+ if (transitionRequired) {
2709
+ playerTransactions.set(binding.playerId, {
2710
+ phase: 'awaitingCommit',
2711
+ frame,
2712
+ roleId,
2713
+ turnId: admittedTurn.id,
2714
+ signal,
2715
+ scope,
2716
+ status: result.status,
2717
+ expectedToken: result.resumeToken,
2718
+ });
2719
+ } else {
2720
+ playerTransactions.delete(binding.playerId);
2721
+ }
2722
+ } catch (error) {
2723
+ if (playerTransactions.get(binding.playerId) === calling) {
2724
+ if (hostResolved) {
2725
+ playerTransactions.set(binding.playerId, {
2726
+ phase: 'quarantined',
2727
+ frame,
2728
+ roleId,
2729
+ turnId: admittedTurn.id,
2730
+ signal,
2731
+ scope,
2732
+ reason: 'a late player result could not be validated',
2733
+ });
2734
+ } else {
2735
+ playerTransactions.delete(binding.playerId);
2736
+ }
2737
+ }
2738
+ throw error;
2739
+ }
2740
+ return result;
1865
2741
  },
1866
2742
  callCaptain: async (prompt, signal, options) => {
1867
2743
  admitHostBoundary();
@@ -1962,14 +2838,16 @@ export function createPlaybookCaptainShell(
1962
2838
  });
1963
2839
 
1964
2840
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
1965
- // visibility for that playbook's generated host players. A pane
2841
+ // visibility for that playbook's explicitly bound session players. A pane
1966
2842
  // reconciliation failure is display-only in tmux-play and does not
1967
2843
  // reject; the legacy path carries no generated set and skips this.
1968
2844
  const requestVisibility = async (frame: EngagementFrame): Promise<void> => {
1969
2845
  const ids = [...new Set(
1970
- [...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId),
2846
+ [...frame.playerBindings.values()].map(({ playerId }) => playerId),
1971
2847
  )];
1972
- if (!ids || ids.length === 0 || !activeContext) return;
2848
+ // A roleless frame does not ask a non-empty host roster to show `[]`:
2849
+ // tmux-play reserves that value for a genuinely empty configured roster.
2850
+ if (ids.length === 0 || !activeContext) return;
1973
2851
  try {
1974
2852
  await activeContext.setVisiblePlayers(ids);
1975
2853
  } catch (error) {
@@ -2009,32 +2887,8 @@ export function createPlaybookCaptainShell(
2009
2887
 
2010
2888
  const makePlayerBindings = (
2011
2889
  enablement: Enablement,
2012
- parent?: { frame: EngagementFrame; callId: string },
2013
2890
  ): 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;
2891
+ return new Map(enablement.roleBindings);
2038
2892
  };
2039
2893
 
2040
2894
  const makeFrame = (
@@ -2043,17 +2897,8 @@ export function createPlaybookCaptainShell(
2043
2897
  ): EngagementFrame => {
2044
2898
  const entry = enablement.entry;
2045
2899
  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
- });
2900
+ const playerBindings = makePlayerBindings(enablement);
2901
+ const runtime = entry.createRuntime(enablement.options);
2057
2902
  return {
2058
2903
  entry,
2059
2904
  enablement,
@@ -2062,7 +2907,6 @@ export function createPlaybookCaptainShell(
2062
2907
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
2063
2908
  depth: parent ? parent.frame.depth + 1 : 0,
2064
2909
  playerBindings,
2065
- playerResumeTokens,
2066
2910
  ...(parent ? { parent } : {}),
2067
2911
  inFlightHostCalls: new Set(),
2068
2912
  };
@@ -2071,19 +2915,11 @@ export function createPlaybookCaptainShell(
2071
2915
  const makeRestoredFrame = (
2072
2916
  enablement: Enablement,
2073
2917
  snapshot: PlaybookCaptainFrameSnapshot,
2074
- rootPlayerResumeTokens: Map<string, string>,
2075
2918
  parent?: { frame: EngagementFrame; callId: string },
2076
2919
  ): EngagementFrame => {
2077
2920
  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
- });
2921
+ const playerBindings = makePlayerBindings(enablement);
2922
+ const runtime = entry.createRuntime(enablement.options);
2087
2923
  return {
2088
2924
  entry,
2089
2925
  enablement,
@@ -2092,7 +2928,6 @@ export function createPlaybookCaptainShell(
2092
2928
  rootSessionId: snapshot.rootSessionId,
2093
2929
  depth: snapshot.depth,
2094
2930
  playerBindings,
2095
- playerResumeTokens: rootPlayerResumeTokens,
2096
2931
  ...(parent ? { parent } : {}),
2097
2932
  state: snapshot.runtime.state,
2098
2933
  inFlightHostCalls: new Set(),
@@ -2102,35 +2937,159 @@ export function createPlaybookCaptainShell(
2102
2937
  const playerSessionStore = (frame: EngagementFrame): PlayerSessionStore => ({
2103
2938
  select(playerId) {
2104
2939
  const binding = bindingFor(frame, playerId);
2105
- return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
2940
+ return playerLedger.get(binding.playerId)?.resumeToken ?? false;
2106
2941
  },
2107
2942
  update(playerId, resumeToken) {
2108
2943
  const binding = bindingFor(frame, playerId);
2109
- if (resumeToken === undefined) {
2110
- frame.playerResumeTokens.delete(binding.hostPlayerId);
2111
- } else {
2112
- frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
2944
+ const ledger = playerLedger.get(binding.playerId);
2945
+ if (!ledger) {
2946
+ throw new Error(
2947
+ `${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`,
2948
+ );
2949
+ }
2950
+ const pending = playerTransactions.get(binding.playerId);
2951
+ if (
2952
+ pending?.phase !== 'awaitingCommit' ||
2953
+ pending.frame !== frame ||
2954
+ pending.roleId !== playerId ||
2955
+ pending.scope !== frame.playerCallScope ||
2956
+ pending.expectedToken !== resumeToken
2957
+ ) {
2958
+ throw new Error(
2959
+ `${frameLabel(frame)} player update does not acknowledge a validated host result`,
2960
+ );
2961
+ }
2962
+ if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
2963
+ playerTransactions.set(binding.playerId, {
2964
+ phase: 'quarantined',
2965
+ frame: pending.frame,
2966
+ roleId: pending.roleId,
2967
+ turnId: pending.turnId,
2968
+ signal: pending.signal,
2969
+ scope: pending.scope,
2970
+ reason: 'the runtime aborted before committing a validated result',
2971
+ });
2972
+ throw new Error(
2973
+ `${frameLabel(frame)} rejected a late or aborted player continuation update`,
2974
+ );
2975
+ }
2976
+ try {
2977
+ if (resumeToken === undefined) delete ledger.resumeToken;
2978
+ else ledger.resumeToken = resumeToken;
2979
+ // CAPTAIN-20: a result counts only after the runtime validated it and
2980
+ // atomically published its authorized continuation transition.
2981
+ const summary = activeTurnSummary;
2982
+ if (
2983
+ pending.status === 'ok' &&
2984
+ summary &&
2985
+ summaryIncludes(frame)
2986
+ ) {
2987
+ summary.counts.interruptions++;
2988
+ }
2989
+ } finally {
2990
+ playerTransactions.delete(binding.playerId);
2113
2991
  }
2114
2992
  },
2115
2993
  snapshot() {
2116
2994
  const tokens: Record<string, string> = {};
2117
2995
  for (const [playerId, binding] of frame.playerBindings) {
2118
- const token = frame.playerResumeTokens.get(binding.hostPlayerId);
2996
+ const token = playerLedger.get(binding.playerId)?.resumeToken;
2119
2997
  if (token !== undefined) tokens[playerId] = token;
2120
2998
  }
2121
2999
  return tokens;
2122
3000
  },
2123
3001
  restore(tokens) {
2124
- for (const binding of frame.playerBindings.values()) {
2125
- frame.playerResumeTokens.delete(binding.hostPlayerId);
3002
+ if (
3003
+ lifecycle !== 'restoring' ||
3004
+ restoringPlayerSessionFrame !== frame
3005
+ ) {
3006
+ throw new Error(
3007
+ `${frameLabel(frame)} player-session restore is only available during shell restoration`,
3008
+ );
2126
3009
  }
3010
+ const byPlayer = new Map<string, string | undefined>();
2127
3011
  for (const [playerId, token] of Object.entries(tokens)) {
2128
3012
  const binding = bindingFor(frame, playerId);
2129
- frame.playerResumeTokens.set(binding.hostPlayerId, token);
3013
+ const previous = byPlayer.get(binding.playerId);
3014
+ if (previous !== undefined && previous !== token) {
3015
+ throw new Error(
3016
+ `${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`,
3017
+ );
3018
+ }
3019
+ byPlayer.set(binding.playerId, token);
3020
+ }
3021
+ for (const binding of frame.playerBindings.values()) {
3022
+ if (!byPlayer.has(binding.playerId)) byPlayer.set(binding.playerId, undefined);
3023
+ }
3024
+ for (const [playerId, token] of byPlayer) {
3025
+ const ledger = playerLedger.get(playerId);
3026
+ if (!ledger) {
3027
+ throw new Error(
3028
+ `${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`,
3029
+ );
3030
+ }
3031
+ if (token === undefined) delete ledger.resumeToken;
3032
+ else ledger.resumeToken = token;
2130
3033
  }
2131
3034
  },
2132
3035
  });
2133
3036
 
3037
+ const closePlayerCallScope = (
3038
+ frame: EngagementFrame,
3039
+ scope: object,
3040
+ ): Error | undefined => {
3041
+ if (frame.playerCallScope === scope) frame.playerCallScope = undefined;
3042
+ const missing: string[] = [];
3043
+ for (const [playerId, transaction] of playerTransactions) {
3044
+ if (transaction.frame !== frame || transaction.scope !== scope) continue;
3045
+ if (transaction.phase === 'calling') {
3046
+ transaction.abandoned = true;
3047
+ } else {
3048
+ playerTransactions.set(playerId, {
3049
+ phase: 'quarantined',
3050
+ frame: transaction.frame,
3051
+ roleId: transaction.roleId,
3052
+ turnId: transaction.turnId,
3053
+ signal: transaction.signal,
3054
+ scope: transaction.scope,
3055
+ reason:
3056
+ transaction.phase === 'awaitingCommit'
3057
+ ? 'the runtime returned without committing a validated result'
3058
+ : transaction.reason,
3059
+ });
3060
+ }
3061
+ if (!transaction.signal.aborted) missing.push(playerId);
3062
+ }
3063
+ return missing.length === 0
3064
+ ? undefined
3065
+ : new Error(
3066
+ `${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`,
3067
+ );
3068
+ };
3069
+
3070
+ const runFrameOperation = async <T>(
3071
+ frame: EngagementFrame,
3072
+ operation: () => Promise<T>,
3073
+ ): Promise<T> => {
3074
+ if (frame.playerCallScope !== undefined) {
3075
+ throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
3076
+ }
3077
+ const scope = {};
3078
+ frame.playerCallScope = scope;
3079
+ let outcome:
3080
+ | { readonly ok: true; readonly value: T }
3081
+ | { readonly ok: false; readonly error: unknown };
3082
+ try {
3083
+ outcome = { ok: true, value: await operation() };
3084
+ } catch (error) {
3085
+ outcome = { ok: false, error };
3086
+ }
3087
+ const cleanupError = closePlayerCallScope(frame, scope);
3088
+ if (!outcome.ok) throw outcome.error;
3089
+ if (cleanupError !== undefined) throw cleanupError;
3090
+ return outcome.value;
3091
+ };
3092
+
2134
3093
  const frameSession = (frame: EngagementFrame) => ({
2135
3094
  sessionId: frame.sessionId,
2136
3095
  playbookId: frame.entry.id,
@@ -2142,6 +3101,15 @@ export function createPlaybookCaptainShell(
2142
3101
  }
2143
3102
  : {}),
2144
3103
  depth: frame.depth,
3104
+ roleBindings: Object.fromEntries(
3105
+ [...frame.playerBindings].map(([roleId, binding]) => [
3106
+ roleId,
3107
+ {
3108
+ playerId: binding.playerId,
3109
+ promptIdentity: promptIdentity(binding),
3110
+ },
3111
+ ]),
3112
+ ),
2145
3113
  playerSessions: playerSessionStore(frame),
2146
3114
  ports: createPorts(frame),
2147
3115
  });
@@ -2465,8 +3433,10 @@ export function createPlaybookCaptainShell(
2465
3433
  // exception filed against an effect that never ran.
2466
3434
  await requestVisibility(frame);
2467
3435
  await setMode('engaged.driving', 'submit');
2468
- const result = await runEffect(() =>
2469
- frame.runtime.handleBossInput({ text, signal }),
3436
+ const result = await runFrameOperation(frame, () =>
3437
+ runEffect(() =>
3438
+ frame.runtime.handleBossInput({ text, signal }),
3439
+ ),
2470
3440
  );
2471
3441
  frame.state = result.state;
2472
3442
  return result;
@@ -2520,12 +3490,14 @@ export function createPlaybookCaptainShell(
2520
3490
  }
2521
3491
  let result: PlaybookRunResult;
2522
3492
  try {
2523
- result = await runEffect(() =>
2524
- parent.runtime.resumePlaybookCall({
2525
- callId: parentLink.callId,
2526
- result: effectiveResult,
2527
- signal: context.signal,
2528
- }),
3493
+ result = await runFrameOperation(parent, () =>
3494
+ runEffect(() =>
3495
+ parent.runtime.resumePlaybookCall({
3496
+ callId: parentLink.callId,
3497
+ result: effectiveResult,
3498
+ signal: context.signal,
3499
+ }),
3500
+ ),
2529
3501
  );
2530
3502
  } catch (error) {
2531
3503
  if (disposing || invocationSignal?.aborted) return;
@@ -2572,7 +3544,7 @@ export function createPlaybookCaptainShell(
2572
3544
  // runtime publishes before disposal removes the frame. The opaque run
2573
3545
  // output remains runtime-to-runtime data and never becomes Captain
2574
3546
  // evidence (CAPPLAY-10).
2575
- activeTurn?.settlementFacts.push(rootCompletionFact(frame));
3547
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
2576
3548
  await runEffect(() => disposeStack('final'));
2577
3549
  }
2578
3550
  return;
@@ -2949,7 +3921,9 @@ export function createPlaybookCaptainShell(
2949
3921
  const pending = view.pendingQuestions.map(
2950
3922
  (question) =>
2951
3923
  digestLine`- (${quoteEvidence(question.questionId)}) ${quoteEvidence(
2952
- question.player,
3924
+ question.asker.kind === 'captain'
3925
+ ? 'Captain'
3926
+ : question.asker.roleId,
2953
3927
  )} asks: ${quoteEvidence(question.question)}`,
2954
3928
  );
2955
3929
  lines.push(
@@ -3073,6 +4047,7 @@ export function createPlaybookCaptainShell(
3073
4047
  try {
3074
4048
  await trackTurnCall(settlement.context.emitReply(settlement.text));
3075
4049
  } catch (error) {
4050
+ conversation = { kind: 'needsSeeding' };
3076
4051
  const normalized = normalizeErrorCompact(error) ?? {
3077
4052
  name: 'Error',
3078
4053
  message: String(error),
@@ -3179,8 +4154,11 @@ export function createPlaybookCaptainShell(
3179
4154
  * text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
3180
4155
  * the Captain may need to repeat.
3181
4156
  */
3182
- const reseedDigest = (): string => {
3183
- for (const record of journal) {
4157
+ const conversationDigest = (
4158
+ records: readonly JournalRecord[],
4159
+ render: (records: readonly JournalRecord[]) => string,
4160
+ ): string => {
4161
+ for (const record of records) {
3184
4162
  if (
3185
4163
  record.kind === 'action' &&
3186
4164
  typeof record.payload === 'object' &&
@@ -3191,14 +4169,47 @@ export function createPlaybookCaptainShell(
3191
4169
  if (typeof actionId === 'string') recordSuppliedIdentifier(actionId);
3192
4170
  }
3193
4171
  }
3194
- return renderReseedDigest(journal);
4172
+ return render(records);
3195
4173
  };
3196
4174
 
4175
+ const reseedDigest = (): string =>
4176
+ conversationDigest(journal, renderReseedDigest);
4177
+
4178
+ const catchUpDigest = (afterJournalSeq: number): string =>
4179
+ conversationDigest(
4180
+ journal.filter((record) => record.seq > afterJournalSeq),
4181
+ renderCatchUpDigest,
4182
+ );
4183
+
3197
4184
  const markControlFailure = <E>(error: E): E => {
3198
- if (activeTurn) activeTurn.controlFailure = true;
4185
+ activeTurn?.controlFailures.add(error);
3199
4186
  return error;
3200
4187
  };
3201
4188
 
4189
+ const markSettingsRejection = <E>(error: E): E => markControlFailure(error);
4190
+
4191
+ const rememberSettingsPreflight = <E>(error: E): E => {
4192
+ activeTurn?.settingsPreflightFailures.add(error);
4193
+ return markSettingsRejection(error);
4194
+ };
4195
+
4196
+ const markConversationCatchUp = (): void => {
4197
+ if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
4198
+ return;
4199
+ }
4200
+ conversation = {
4201
+ kind: 'needsCatchUp',
4202
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
4203
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
4204
+ };
4205
+ };
4206
+
4207
+ const markConversationUnsynchronized = (): void => {
4208
+ if (conversation.kind !== 'needsCatchUp') {
4209
+ conversation = { kind: 'needsSeeding' };
4210
+ }
4211
+ };
4212
+
3202
4213
  /**
3203
4214
  * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
3204
4215
  * engagement constructed, a stack disposed, an advertised action applied.
@@ -3253,6 +4264,7 @@ export function createPlaybookCaptainShell(
3253
4264
  context: CaptainContext,
3254
4265
  prompt: string,
3255
4266
  resume: string | false,
4267
+ attempt: { providerBoundaryEntered: boolean },
3256
4268
  ): Promise<{
3257
4269
  status: string;
3258
4270
  finalText?: string;
@@ -3261,11 +4273,15 @@ export function createPlaybookCaptainShell(
3261
4273
  }> => {
3262
4274
  const queued = captainQueue.add(async () => {
3263
4275
  context.signal.throwIfAborted();
3264
- const result = await context.callCaptain(prompt, {
3265
- visibility: 'hidden',
3266
- resume,
3267
- ...controlCallToolOptions(captainAdapter),
3268
- });
4276
+ attempt.providerBoundaryEntered = true;
4277
+ const result = await classifySettingsCall(() =>
4278
+ context.callCaptain(prompt, {
4279
+ visibility: 'hidden',
4280
+ resume,
4281
+ ...controlCallToolOptions(captainAdapter),
4282
+ settings: callSettings(captainAgent!),
4283
+ }),
4284
+ );
3269
4285
  context.signal.throwIfAborted();
3270
4286
  return result;
3271
4287
  });
@@ -3286,8 +4302,17 @@ export function createPlaybookCaptainShell(
3286
4302
  context: CaptainContext,
3287
4303
  compose: (options: { reseedDigest?: string }) => string,
3288
4304
  ): Promise<DurableCallOutcome> => {
3289
- const resume = conversation.kind === 'pinned' ? conversation.token : false;
4305
+ const startingConversation = conversation;
4306
+ const resume =
4307
+ startingConversation.kind === 'pinned'
4308
+ ? startingConversation.token
4309
+ : startingConversation.kind === 'needsCatchUp'
4310
+ ? startingConversation.resume
4311
+ : false;
3290
4312
  const seedFirstCall = conversation.kind === 'needsSeeding';
4313
+ const catchUpFirstCall = conversation.kind === 'needsCatchUp';
4314
+ const representedJournalSeq = journalSeq;
4315
+ const firstAttempt = { providerBoundaryEntered: false };
3291
4316
  let result:
3292
4317
  | { status: string; finalText?: string; resumeToken?: string; error?: string }
3293
4318
  | undefined;
@@ -3296,14 +4321,42 @@ export function createPlaybookCaptainShell(
3296
4321
  result = await rawDurableCall(
3297
4322
  context,
3298
4323
  compose(
3299
- seedFirstCall ? { reseedDigest: reseedDigest() } : {},
4324
+ seedFirstCall
4325
+ ? { reseedDigest: reseedDigest() }
4326
+ : startingConversation.kind === 'needsCatchUp'
4327
+ ? {
4328
+ reseedDigest: catchUpDigest(
4329
+ startingConversation.afterJournalSeq,
4330
+ ),
4331
+ }
4332
+ : {},
3300
4333
  ),
3301
4334
  resume,
4335
+ firstAttempt,
3302
4336
  );
3303
4337
  } catch (error) {
3304
4338
  if (context.signal.aborted) {
3305
- conversation = { kind: 'needsSeeding' };
3306
- throw error;
4339
+ if (firstAttempt.providerBoundaryEntered) {
4340
+ conversation = { kind: 'needsSeeding' };
4341
+ } else {
4342
+ markConversationUnsynchronized();
4343
+ }
4344
+ throw context.signal.reason ?? error;
4345
+ }
4346
+ if (error instanceof AgentSettingsPreflightError) {
4347
+ if (
4348
+ conversation.kind !== 'needsCatchUp' &&
4349
+ conversation.kind !== 'needsSeeding'
4350
+ ) {
4351
+ conversation = {
4352
+ kind: 'needsCatchUp',
4353
+ resume:
4354
+ conversation.kind === 'pinned' ? conversation.token : false,
4355
+ afterJournalSeq:
4356
+ activeTurn?.captainSyncedJournalSeq ?? 0,
4357
+ };
4358
+ }
4359
+ throw rememberSettingsPreflight(error.rejection);
3307
4360
  }
3308
4361
  failure = error;
3309
4362
  }
@@ -3314,11 +4367,14 @@ export function createPlaybookCaptainShell(
3314
4367
  result.resumeToken === undefined;
3315
4368
  if (!unsynchronized) {
3316
4369
  conversation = { kind: 'pinned', token: result!.resumeToken! };
4370
+ if (activeTurn) {
4371
+ activeTurn.captainSyncedJournalSeq = representedJournalSeq;
4372
+ }
3317
4373
  return {
3318
4374
  ...(result!.finalText !== undefined
3319
4375
  ? { finalText: result!.finalText }
3320
4376
  : {}),
3321
- correctiveSpent: seedFirstCall,
4377
+ correctiveSpent: seedFirstCall || catchUpFirstCall,
3322
4378
  };
3323
4379
  }
3324
4380
  // Only the model-side conversation is replaced: the stack, player
@@ -3330,16 +4386,21 @@ export function createPlaybookCaptainShell(
3330
4386
  let reissued:
3331
4387
  | { status: string; finalText?: string; resumeToken?: string; error?: string }
3332
4388
  | undefined;
4389
+ const reissueAttempt = { providerBoundaryEntered: false };
3333
4390
  try {
3334
4391
  reissued = await rawDurableCall(
3335
4392
  context,
3336
4393
  compose({ reseedDigest: recap }),
3337
4394
  false,
4395
+ reissueAttempt,
3338
4396
  );
3339
4397
  } catch (error) {
3340
4398
  if (context.signal.aborted) {
3341
4399
  conversation = { kind: 'needsSeeding' };
3342
- throw error;
4400
+ throw context.signal.reason ?? error;
4401
+ }
4402
+ if (error instanceof AgentSettingsPreflightError) {
4403
+ throw rememberSettingsPreflight(error.rejection);
3343
4404
  }
3344
4405
  throw markControlFailure(new CaptainContinuityError(error));
3345
4406
  }
@@ -3352,6 +4413,7 @@ export function createPlaybookCaptainShell(
3352
4413
  );
3353
4414
  }
3354
4415
  conversation = { kind: 'pinned', token: reissued.resumeToken };
4416
+ if (activeTurn) activeTurn.captainSyncedJournalSeq = journalSeq;
3355
4417
  return {
3356
4418
  ...(reissued.finalText !== undefined
3357
4419
  ? { finalText: reissued.finalText }
@@ -3570,10 +4632,10 @@ export function createPlaybookCaptainShell(
3570
4632
  // The controller port (DR-029): host validation is the sole effector.
3571
4633
  // -------------------------------------------------------------------------
3572
4634
 
3573
- // The leaf's published state description, read from its control view the
3574
- // same way the digest reads it. A leaf without the pair or one whose view
3575
- // cannot be read at this moment publishes none, and the summary then says
3576
- // so instead of falling back to the state id.
4635
+ // The legacy state-description channel, read from the live control view the
4636
+ // same way the digest reads it. DR-037 makes the terminal result authoritative
4637
+ // for completion; this remains only for an older runtime that omits the new
4638
+ // optional member.
3577
4639
  const leafStateDescription = (
3578
4640
  frame: EngagementFrame,
3579
4641
  ): string | undefined => {
@@ -3585,10 +4647,21 @@ export function createPlaybookCaptainShell(
3585
4647
  }
3586
4648
  };
3587
4649
 
3588
- const rootCompletionFact = (frame: EngagementFrame): string => {
3589
- const published = leafStateDescription(frame);
4650
+ const rootCompletionFact = (
4651
+ frame: EngagementFrame,
4652
+ result: Extract<PlaybookRunResult, { outcome: 'terminal' }>,
4653
+ ): string => {
4654
+ const returned =
4655
+ result.stateDescription === undefined
4656
+ ? ''
4657
+ : compactEvidence(result.stateDescription);
4658
+ const legacy = returned === '' ? leafStateDescription(frame) : undefined;
3590
4659
  const description =
3591
- published === undefined ? '' : compactEvidence(published);
4660
+ returned !== ''
4661
+ ? returned
4662
+ : legacy === undefined
4663
+ ? ''
4664
+ : compactEvidence(legacy);
3592
4665
  return description === ''
3593
4666
  ? `${frameLabel(frame)} completed; its runtime published no result description.`
3594
4667
  : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
@@ -3758,7 +4831,7 @@ export function createPlaybookCaptainShell(
3758
4831
  message: String(error),
3759
4832
  };
3760
4833
  if (aborted) {
3761
- conversation = { kind: 'needsSeeding' };
4834
+ markConversationUnsynchronized();
3762
4835
  if (turn?.outcomePending) {
3763
4836
  turn.settlementFacts.push(
3764
4837
  `The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`,
@@ -4158,7 +5231,11 @@ export function createPlaybookCaptainShell(
4158
5231
  // receipt rather than acting twice.
4159
5232
  const key = `turn-${turn.id}-apply-${actionId}`;
4160
5233
  const outcome = await withCounting(leaf, async () =>
4161
- runEffect(() => leaf.runtime.apply!({ actionId, key, signal })),
5234
+ runFrameOperation(leaf, () =>
5235
+ runEffect(() =>
5236
+ leaf.runtime.apply!({ actionId, key, signal }),
5237
+ ),
5238
+ ),
4162
5239
  );
4163
5240
  if (outcome.error !== undefined) throw outcome.error;
4164
5241
  const receipt = outcome.result!;
@@ -4322,7 +5399,11 @@ export function createPlaybookCaptainShell(
4322
5399
  // The durable Captain conversation did not receive the shell-authored
4323
5400
  // fallback. Force its next call through the journal so it cannot interpret
4324
5401
  // the Boss's follow-up without the reply the Boss was given this turn.
4325
- conversation = { kind: 'needsSeeding' };
5402
+ if (activeTurn?.settingsPreflightFailures.has(error)) {
5403
+ markConversationCatchUp();
5404
+ } else {
5405
+ markConversationUnsynchronized();
5406
+ }
4326
5407
  // A rejected presentation may already have emitted bytes. It is therefore
4327
5408
  // final for this turn even though the Promise did not prove it was shown.
4328
5409
  if (activeTurn?.presentationAttempted === true) return;
@@ -4353,164 +5434,93 @@ export function createPlaybookCaptainShell(
4353
5434
  playbookId: INTERNAL_CAPTAIN_ID,
4354
5435
  rootSessionId: id,
4355
5436
  depth: 0,
5437
+ roleBindings: {},
4356
5438
  ports: captainPorts(),
4357
5439
  });
4358
5440
 
4359
- const tokenRecord = (
4360
- tokens: ReadonlyMap<string, string>,
4361
- ): Readonly<Record<string, string>> => Object.fromEntries(tokens);
5441
+ const playerLedgerRecord = (): Readonly<
5442
+ Record<string, PlayerLedgerSnapshotEntry>
5443
+ > =>
5444
+ Object.fromEntries(
5445
+ [...playerLedger].map(([playerId, entry]) => [
5446
+ playerId,
5447
+ {
5448
+ adapter: entry.adapter,
5449
+ ...(entry.instruction === undefined
5450
+ ? {}
5451
+ : { instruction: entry.instruction }),
5452
+ ...(entry.permissions === undefined
5453
+ ? {}
5454
+ : { permissions: entry.permissions }),
5455
+ ...(entry.resumeToken === undefined
5456
+ ? {}
5457
+ : { resumeToken: entry.resumeToken }),
5458
+ },
5459
+ ]),
5460
+ );
4362
5461
 
4363
5462
  const assertSnapshotMatchesEnablements = (
4364
5463
  snapshot: PlaybookCaptainShellSnapshot,
4365
5464
  enabled: ReadonlyMap<string, Enablement>,
4366
5465
  ): void => {
4367
- const captain = snapshot.captain.runtime;
4368
5466
  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
5467
+ captainAgent === undefined ||
5468
+ !isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))
4376
5469
  ) {
4377
5470
  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',
5471
+ 'Captain shell snapshot Captain agent is incompatible with current config',
4384
5472
  );
4385
5473
  }
4386
- const emptyHistory =
4387
- snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
4388
- if (
4389
- (snapshot.captain.conversation.kind === 'unopened') !== emptyHistory
4390
- ) {
5474
+ const configuredPlayerIds = [...playerAgents.keys()].sort();
5475
+ const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
5476
+ if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
4391
5477
  throw new TypeError(
4392
- 'Captain shell snapshot unopened conversation must exactly match an empty session history',
5478
+ 'Captain shell snapshot player ledger does not match current referenced players',
4393
5479
  );
4394
5480
  }
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)) {
5481
+ for (const playerId of configuredPlayerIds) {
5482
+ const saved = snapshot.playerSessions[playerId]!;
5483
+ const configured = playerAgents.get(playerId)!;
5484
+ const savedFixed = {
5485
+ adapter: saved.adapter,
5486
+ ...(saved.instruction === undefined
5487
+ ? {}
5488
+ : { instruction: saved.instruction }),
5489
+ ...(saved.permissions === undefined
5490
+ ? {}
5491
+ : { permissions: saved.permissions }),
5492
+ };
5493
+ if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
4408
5494
  throw new TypeError(
4409
- `Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`,
5495
+ `Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`,
4410
5496
  );
4411
5497
  }
4412
5498
  }
4413
-
4414
- const bindingMaps: Map<string, string>[] = [];
4415
- const rootSessionId = snapshot.frames[0]!.sessionId;
4416
- for (const [index, frame] of snapshot.frames.entries()) {
5499
+ if (snapshot.mode === 'chat') return;
5500
+ for (const frame of snapshot.frames) {
4417
5501
  const enablement = enabled.get(frame.playbookId);
4418
5502
  if (!enablement) {
4419
5503
  throw new TypeError(
4420
5504
  `Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`,
4421
5505
  );
4422
5506
  }
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
- ) {
5507
+ const configuredBindings = Object.fromEntries(
5508
+ [...enablement.roleBindings].map(([role, binding]) => [
5509
+ role,
5510
+ binding.playerId,
5511
+ ]),
5512
+ );
5513
+ if (!isDeepStrictEqual(frame.options, enablement.options)) {
4446
5514
  throw new TypeError(
4447
- 'Captain shell snapshot frame depth, root, or parked runtime state is inconsistent',
5515
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`,
4448
5516
  );
4449
5517
  }
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)) {
5518
+ if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
4500
5519
  throw new TypeError(
4501
- `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`,
5520
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
4502
5521
  );
4503
5522
  }
4504
5523
  }
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
5524
  };
4515
5525
 
4516
5526
  const safeCapturePoint = (): boolean => {
@@ -4530,6 +5540,7 @@ export function createPlaybookCaptainShell(
4530
5540
  runFailureFacts !== undefined ||
4531
5541
  servingCall !== undefined ||
4532
5542
  decisionCall !== undefined ||
5543
+ playerTransactions.size !== 0 ||
4533
5544
  captainQueue.pending !== 0 ||
4534
5545
  captainQueue.size !== 0 ||
4535
5546
  (mode !== 'chat' && mode !== 'engaged.parked')
@@ -4575,7 +5586,12 @@ export function createPlaybookCaptainShell(
4575
5586
  };
4576
5587
 
4577
5588
  const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
4578
- if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
5589
+ if (
5590
+ !safeCapturePoint() ||
5591
+ !captainRuntime ||
5592
+ !captainSessionId ||
5593
+ !captainAgent
5594
+ ) {
4579
5595
  return undefined;
4580
5596
  }
4581
5597
  try {
@@ -4613,16 +5629,25 @@ export function createPlaybookCaptainShell(
4613
5629
  parentCallId: frame.parent.callId,
4614
5630
  }
4615
5631
  : {}),
5632
+ options: frame.enablement.options,
5633
+ roleBindings: Object.fromEntries(
5634
+ [...frame.playerBindings].map(([role, binding]) => [
5635
+ role,
5636
+ binding.playerId,
5637
+ ]),
5638
+ ),
4616
5639
  runtime,
4617
5640
  });
4618
5641
  }
4619
5642
  const common = {
4620
- schemaVersion: 1 as const,
5643
+ schemaVersion: 3 as const,
4621
5644
  captain: {
4622
5645
  sessionId: captainSessionId,
4623
5646
  runtime: captainSnapshot,
5647
+ agent: fixedAgent(captainAgent),
4624
5648
  conversation,
4625
5649
  },
5650
+ playerSessions: playerLedgerRecord(),
4626
5651
  issuedSessionIds: [...issuedSessionIds],
4627
5652
  sequences: { turn: turnSequence, journal: journalSeq },
4628
5653
  journal,
@@ -4638,9 +5663,6 @@ export function createPlaybookCaptainShell(
4638
5663
  ...common,
4639
5664
  mode: 'engaged.parked',
4640
5665
  frames: frameSnapshots,
4641
- rootPlayerResumeTokens: tokenRecord(
4642
- rootFrame()!.playerResumeTokens,
4643
- ),
4644
5666
  ...(pendingBossQuestions === undefined
4645
5667
  ? {}
4646
5668
  : { pendingBossQuestions: pendingBossQuestions as JsonValue }),
@@ -4673,7 +5695,7 @@ export function createPlaybookCaptainShell(
4673
5695
  );
4674
5696
  for (const key of [
4675
5697
  'state',
4676
- 'playerResumeTokens',
5698
+ 'roleResumeTokens',
4677
5699
  'sequences',
4678
5700
  'pendingBossQuestions',
4679
5701
  'suspendedCall',
@@ -4712,7 +5734,11 @@ export function createPlaybookCaptainShell(
4712
5734
  byCommand = new Map();
4713
5735
  byId = new Map();
4714
5736
  enablementById = new Map();
4715
- players = [];
5737
+ captainAgent = undefined;
5738
+ captainAdapter = undefined;
5739
+ playerAgents = new Map();
5740
+ playerLedger.clear();
5741
+ playerTransactions.clear();
4716
5742
  session = undefined;
4717
5743
  sessionEmissionsOpen = false;
4718
5744
  closedGateAttempted = false;
@@ -4749,22 +5775,31 @@ export function createPlaybookCaptainShell(
4749
5775
  lifecycle = 'restoring';
4750
5776
  try {
4751
5777
  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
- }
5778
+ const built = await buildEnablements(options, loadModule);
5779
+ captainAgent = built.captainAgent;
5780
+ captainAdapter = captainAgent.adapter;
5781
+ playerAgents = built.playerAgents;
4760
5782
  assertSnapshotMatchesEnablements(snapshot, built.enablementById);
4761
5783
 
4762
5784
  installSession(initSession, false);
4763
- players = initSession.players;
4764
5785
  entries = built.entries;
4765
5786
  byCommand = built.byCommand;
4766
5787
  byId = built.byId;
4767
5788
  enablementById = built.enablementById;
5789
+ for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
5790
+ playerLedger.set(playerId, {
5791
+ adapter: saved.adapter,
5792
+ ...(saved.instruction === undefined
5793
+ ? {}
5794
+ : { instruction: saved.instruction }),
5795
+ ...(saved.permissions === undefined
5796
+ ? {}
5797
+ : { permissions: livePermissions(saved.permissions) }),
5798
+ ...(saved.resumeToken === undefined
5799
+ ? {}
5800
+ : { resumeToken: saved.resumeToken }),
5801
+ });
5802
+ }
4768
5803
 
4769
5804
  captainRuntime = createCaptainRuntime({
4770
5805
  enabledPlaybooks: enabledCatalog(),
@@ -4775,15 +5810,11 @@ export function createPlaybookCaptainShell(
4775
5810
  }
4776
5811
 
4777
5812
  if (snapshot.mode === 'engaged.parked') {
4778
- const rootTokens = new Map(
4779
- Object.entries(snapshot.rootPlayerResumeTokens),
4780
- );
4781
5813
  for (const [index, frameSnapshot] of snapshot.frames.entries()) {
4782
5814
  const parentFrame = frames.at(-1);
4783
5815
  const frame = makeRestoredFrame(
4784
5816
  enablementById.get(frameSnapshot.playbookId)!,
4785
5817
  frameSnapshot,
4786
- rootTokens,
4787
5818
  index === 0
4788
5819
  ? undefined
4789
5820
  : {
@@ -4807,10 +5838,15 @@ export function createPlaybookCaptainShell(
4807
5838
  );
4808
5839
  if (snapshot.mode === 'engaged.parked') {
4809
5840
  for (const [index, frame] of frames.entries()) {
4810
- await frame.runtime.restore!(
4811
- frameSession(frame),
4812
- snapshot.frames[index]!.runtime,
4813
- );
5841
+ restoringPlayerSessionFrame = frame;
5842
+ try {
5843
+ await frame.runtime.restore!(
5844
+ frameSession(frame),
5845
+ snapshot.frames[index]!.runtime,
5846
+ );
5847
+ } finally {
5848
+ restoringPlayerSessionFrame = undefined;
5849
+ }
4814
5850
  }
4815
5851
  }
4816
5852
  if (closedGateAttempted) {
@@ -4832,13 +5868,10 @@ export function createPlaybookCaptainShell(
4832
5868
  );
4833
5869
  }
4834
5870
  if (
4835
- !isDeepStrictEqual(
4836
- tokenRecord(rootFrame()!.playerResumeTokens),
4837
- snapshot.rootPlayerResumeTokens,
4838
- )
5871
+ !isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)
4839
5872
  ) {
4840
5873
  throw new Error(
4841
- 'restored root-owned player continuation changed during restore',
5874
+ 'restored Captain-session player continuation changed during restore',
4842
5875
  );
4843
5876
  }
4844
5877
  }
@@ -4887,14 +5920,16 @@ export function createPlaybookCaptainShell(
4887
5920
  lifecycle = 'initializing';
4888
5921
  try {
4889
5922
  installSession(initSession, true);
4890
- players = initSession.players;
4891
- const built = await buildEnablements(options, players, loadModule);
5923
+ const built = await buildEnablements(options, loadModule);
4892
5924
  entries = built.entries;
4893
5925
  byCommand = built.byCommand;
4894
5926
  byId = built.byId;
4895
5927
  enablementById = built.enablementById;
4896
- for (const enablement of enablementById.values()) {
4897
- enablement.entry.validateOptions(enablement.optionInput);
5928
+ captainAgent = built.captainAgent;
5929
+ captainAdapter = captainAgent.adapter;
5930
+ playerAgents = built.playerAgents;
5931
+ for (const [playerId, agent] of playerAgents) {
5932
+ playerLedger.set(playerId, fixedAgent(agent));
4898
5933
  }
4899
5934
  await setMode('chat', 'init');
4900
5935
  // CAPTAIN-16: the session Captain exists from `init`, outside the
@@ -4942,6 +5977,7 @@ export function createPlaybookCaptainShell(
4942
5977
  const parsed = resolveCommandTurn(turn.prompt);
4943
5978
  activeTurn = {
4944
5979
  id: ++turnSequence,
5980
+ captainSyncedJournalSeq: journalSeq,
4945
5981
  bossText: turn.prompt,
4946
5982
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
4947
5983
  ...(parsed ? { resolution: parsed.resolution } : {}),
@@ -4949,6 +5985,8 @@ export function createPlaybookCaptainShell(
4949
5985
  presentationAttempted: false,
4950
5986
  settlementFacts: [],
4951
5987
  effectThrows: new Set<unknown>(),
5988
+ controlFailures: new Set<unknown>(),
5989
+ settingsPreflightFailures: new Set<unknown>(),
4952
5990
  suppliedIdentifiers: new Set<string>(),
4953
5991
  outcomeRecorded: false,
4954
5992
  };
@@ -4969,13 +6007,16 @@ export function createPlaybookCaptainShell(
4969
6007
  new Error('the session Captain turn failed at its boundary'),
4970
6008
  );
4971
6009
  } else if (result.outcome === 'aborted') {
4972
- conversation = { kind: 'needsSeeding' };
6010
+ markConversationUnsynchronized();
4973
6011
  if (activeTurn && !activeTurn.outcomeRecorded) {
4974
6012
  activeTurn.settlementFacts.push(
4975
6013
  'The Boss turn was aborted before it settled; no action was repeated automatically.',
4976
6014
  );
4977
6015
  journalOutcome([...activeTurn.settlementFacts]);
4978
6016
  }
6017
+ if (context.signal.aborted) {
6018
+ throw context.signal.reason;
6019
+ }
4979
6020
  } else if (
4980
6021
  result.outcome !== 'suspended' &&
4981
6022
  !context.signal.aborted &&
@@ -4994,10 +6035,10 @@ export function createPlaybookCaptainShell(
4994
6035
  }
4995
6036
  } catch (error) {
4996
6037
  if (context.signal.aborted) {
4997
- conversation = { kind: 'needsSeeding' };
6038
+ markConversationUnsynchronized();
4998
6039
  throw error;
4999
6040
  }
5000
- const controlFailure = activeTurn?.controlFailure === true;
6041
+ const controlFailure = activeTurn?.controlFailures.has(error) === true;
5001
6042
  await settleTurnFailure(context, error);
5002
6043
  if (activeTurn?.presentationError !== undefined) {
5003
6044
  throw activeTurn.presentationError;
@@ -5007,7 +6048,7 @@ export function createPlaybookCaptainShell(
5007
6048
  if (!controlFailure) throw error;
5008
6049
  } finally {
5009
6050
  if (context.signal.aborted) {
5010
- conversation = { kind: 'needsSeeding' };
6051
+ markConversationUnsynchronized();
5011
6052
  if (activeTurn && !activeTurn.outcomeRecorded) {
5012
6053
  activeTurn.settlementFacts.push(
5013
6054
  'The Boss turn was aborted before it settled; no action was repeated automatically.',
@@ -5064,6 +6105,9 @@ export function createPlaybookCaptainShell(
5064
6105
  failure ??= error;
5065
6106
  }
5066
6107
  }
6108
+ // Quarantine is session-wide by design. Only terminal teardown may drop
6109
+ // its ownership after every frame host call and the Captain are drained.
6110
+ playerTransactions.clear();
5067
6111
  lifecycle = 'closed';
5068
6112
  if (failure !== undefined) throw failure;
5069
6113
  }