@sublang/playbook 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +17 -4
  2. package/docs/cli.md +74 -29
  3. package/docs/configuration.md +209 -112
  4. package/docs/embedding.md +71 -25
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  18. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  22. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  48. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +27 -23
  53. package/slc/link.md +113 -93
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +20 -16
  56. package/src/runtime.ts +19 -23
  57. package/src/xstate-playbook-runtime.d.ts +21 -17
  58. package/src/xstate-playbook-runtime.js +241 -149
  59. package/src/xstate-playbook-runtime.ts +331 -178
  60. package/src/xstate-runtime.js +63 -24
  61. package/src/xstate-runtime.ts +96 -28
@@ -3,7 +3,8 @@
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import PQueue from 'p-queue';
6
- import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
6
+ import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
7
+ import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
7
8
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
8
9
  class VisibilityControlError extends Error {
9
10
  constructor(cause) {
@@ -11,10 +12,33 @@ class VisibilityControlError extends Error {
11
12
  this.name = 'VisibilityControlError';
12
13
  }
13
14
  }
15
+ class AgentSettingsPreflightError extends Error {
16
+ rejection;
17
+ constructor(rejection) {
18
+ super('agent rejected supplied complete call settings', {
19
+ cause: rejection,
20
+ });
21
+ this.rejection = rejection;
22
+ this.name = 'AgentSettingsPreflightError';
23
+ }
24
+ }
25
+ async function classifySettingsCall(call) {
26
+ try {
27
+ return await call();
28
+ }
29
+ catch (error) {
30
+ if (isAgentCallSettingsError(error)) {
31
+ throw new AgentSettingsPreflightError(error);
32
+ }
33
+ throw error;
34
+ }
35
+ }
14
36
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
15
37
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
16
38
  const INTERNAL_CAPTAIN_ID = 'captain';
17
39
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
40
+ const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
41
+ const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
18
42
  function parseRegisteredCommand(prompt) {
19
43
  const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
20
44
  if (!match)
@@ -127,7 +151,14 @@ function pendingQuestionLines(pending) {
127
151
  : typeof record.id === 'string'
128
152
  ? record.id
129
153
  : undefined;
130
- const player = typeof record.player === 'string' ? record.player : undefined;
154
+ const asker = typeof record.asker === 'object' && record.asker !== null
155
+ ? record.asker
156
+ : undefined;
157
+ const askerLabel = asker?.kind === 'captain'
158
+ ? 'Captain'
159
+ : asker?.kind === 'role' && typeof asker.roleId === 'string'
160
+ ? asker.roleId
161
+ : undefined;
131
162
  const text = typeof record.question === 'string'
132
163
  ? record.question
133
164
  : typeof record.text === 'string'
@@ -137,9 +168,9 @@ function pendingQuestionLines(pending) {
137
168
  // fragment is never handed back to the tag as a value: bounding it a
138
169
  // second time would cut the line at the seam's limit and drop whatever
139
170
  // the shell had already written after the long part.
140
- const asked = player === undefined
171
+ const asked = askerLabel === undefined
141
172
  ? digestLine `${quoteEvidence(text)}`
142
- : digestLine `${quoteEvidence(player)} asks: ${quoteEvidence(text)}`;
173
+ : digestLine `${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
143
174
  const marker = id === undefined ? '' : digestLine `(${quoteEvidence(id)}) `;
144
175
  lines.push(`- ${marker}${asked}`);
145
176
  }
@@ -179,14 +210,20 @@ function renderJournalPayload(payload) {
179
210
  const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
180
211
  return raw ?? 'null';
181
212
  }
182
- function renderReseedDigest(records) {
213
+ function renderJournalDigest(records, heading) {
183
214
  const lines = records.map((record) => `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(record.payload)}`);
184
215
  return [
185
- 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
216
+ heading,
186
217
  'The labeled ControlView and catalog digest blocks outrank conversation memory.',
187
218
  ...(lines.length === 0 ? ['(no earlier turns)'] : lines),
188
219
  ].join('\n');
189
220
  }
221
+ function renderReseedDigest(records) {
222
+ return renderJournalDigest(records, 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.');
223
+ }
224
+ function renderCatchUpDigest(records) {
225
+ return renderJournalDigest(records, 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.');
226
+ }
190
227
  // DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
191
228
  // internal control vocabulary.
192
229
  const CONTROL_VOCABULARY = [
@@ -299,22 +336,31 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
299
336
  return undefined;
300
337
  }
301
338
  // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
302
- // Cligent's Codex adapter rejects any `allowedTools` value — including the
303
- // empty list that expresses tool-free — because the supported Codex SDK
304
- // cannot enforce one, so requesting it fails every control call before the
305
- // model is reached. Omitting the option is the only way such an adapter can
306
- // run a control call at all; its isolation then rests on the authored
307
- // hidden-judge envelope below rather than on provider enforcement.
308
- const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT = new Set([
309
- 'codex',
310
- ]);
339
+ // Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
340
+ // value — including the empty list that expresses tool-free — because their
341
+ // supported provider surfaces cannot enforce one, so requesting it fails
342
+ // every control call before the model is reached. Omitting the option is the
343
+ // only way such an adapter can run a control call at all; its isolation then
344
+ // rests on the authored hidden-judge envelope below rather than on provider
345
+ // enforcement.
346
+ const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
347
+ claude: 'provider-enforced',
348
+ codex: 'prompt-only',
349
+ gemini: 'provider-enforced',
350
+ kimi: 'prompt-only',
351
+ opencode: 'prompt-only',
352
+ };
353
+ function requiresPromptOnlyToolIsolation(captainAdapter) {
354
+ return (Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
355
+ CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[captainAdapter] === 'prompt-only');
356
+ }
311
357
  // The tool half of a control call's options. An empty allowlist means "no
312
358
  // tools available" and is distinct from omission, which grants the adapter's
313
359
  // full native tool surface — so omit only where the empty list would be
314
360
  // refused, and keep requesting enforcement whenever the adapter is unknown.
315
361
  function controlCallToolOptions(captainAdapter) {
316
362
  if (captainAdapter !== undefined &&
317
- ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)) {
363
+ requiresPromptOnlyToolIsolation(captainAdapter)) {
318
364
  return {};
319
365
  }
320
366
  return { allowedTools: [] };
@@ -330,14 +376,6 @@ function forwardedToolOptions(requested, captainAdapter) {
330
376
  return controlCallToolOptions(captainAdapter);
331
377
  return { allowedTools: requested };
332
378
  }
333
- function readCaptainAdapter(options) {
334
- if (typeof options !== 'object' || options === null)
335
- return undefined;
336
- const adapter = options.captainAdapter;
337
- return typeof adapter === 'string' && adapter.length > 0
338
- ? adapter
339
- : undefined;
340
- }
341
379
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
342
380
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
343
381
  // reply call's envelope — the settlement's outcome-report facts verbatim, the
@@ -408,10 +446,28 @@ function isValidRegistryEntry(value) {
408
446
  if (typeof value !== 'object' || value === null)
409
447
  return false;
410
448
  const e = value;
449
+ if (!Array.isArray(e.requiredRoleIds) ||
450
+ e.requiredRoleIds.some((role) => typeof role !== 'string' ||
451
+ !ROLE_ID_PATTERN.test(role) ||
452
+ role === INTERNAL_CAPTAIN_ID) ||
453
+ new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
454
+ !Array.isArray(e.concurrentRoleSets)) {
455
+ return false;
456
+ }
457
+ const roles = new Set(e.requiredRoleIds);
458
+ const concurrency = e.concurrentRoleSets;
459
+ if (concurrency.some((set) => !Array.isArray(set) ||
460
+ set.length < 2 ||
461
+ set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
462
+ new Set(set).size !== set.length) ||
463
+ new Set(concurrency.map((set) => JSON.stringify(set))).size !==
464
+ concurrency.length) {
465
+ return false;
466
+ }
411
467
  return (typeof e.id === 'string' &&
412
468
  typeof e.command === 'string' &&
413
469
  typeof e.intent === 'string' &&
414
- Array.isArray(e.requiredRoleIds) &&
470
+ e.artifactSchema === 2 &&
415
471
  typeof e.validateOptions === 'function' &&
416
472
  typeof e.createRuntime === 'function');
417
473
  }
@@ -468,14 +524,165 @@ function snapshotUuid(value, path) {
468
524
  }
469
525
  return id;
470
526
  }
527
+ function snapshotPermissions(value, path) {
528
+ if (value === undefined)
529
+ return undefined;
530
+ const record = snapshotRecord(value, path);
531
+ rejectSnapshotKeys(record, ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'], path);
532
+ const normalized = {};
533
+ if (record.mode !== undefined) {
534
+ if (record.mode !== 'auto' && record.mode !== 'bypass') {
535
+ throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
536
+ }
537
+ normalized.mode = record.mode;
538
+ }
539
+ for (const key of [
540
+ 'fileWrite',
541
+ 'shellExecute',
542
+ 'networkAccess',
543
+ ]) {
544
+ const level = record[key];
545
+ if (level === undefined)
546
+ continue;
547
+ if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
548
+ throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
549
+ }
550
+ normalized[key] = level;
551
+ }
552
+ if (record.writablePaths !== undefined) {
553
+ if (!Array.isArray(record.writablePaths) ||
554
+ record.writablePaths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
555
+ throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
556
+ }
557
+ normalized.writablePaths = [...record.writablePaths];
558
+ }
559
+ return normalized;
560
+ }
561
+ function livePermissions(value) {
562
+ if (value === undefined)
563
+ return undefined;
564
+ return {
565
+ ...(value.mode === undefined ? {} : { mode: value.mode }),
566
+ ...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
567
+ ...(value.shellExecute === undefined
568
+ ? {}
569
+ : { shellExecute: value.shellExecute }),
570
+ ...(value.networkAccess === undefined
571
+ ? {}
572
+ : { networkAccess: value.networkAccess }),
573
+ ...(value.writablePaths === undefined
574
+ ? {}
575
+ : { writablePaths: [...value.writablePaths] }),
576
+ };
577
+ }
578
+ function snapshotFixedAgent(value, path) {
579
+ const record = snapshotRecord(value, path);
580
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
581
+ const adapter = snapshotString(record.adapter, `${path}.adapter`);
582
+ const instruction = record.instruction === undefined
583
+ ? undefined
584
+ : snapshotString(record.instruction, `${path}.instruction`, true);
585
+ const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
586
+ return {
587
+ adapter,
588
+ ...(instruction === undefined ? {} : { instruction }),
589
+ ...(permissions === undefined ? {} : { permissions }),
590
+ };
591
+ }
592
+ function snapshotPlayerSessions(value, path) {
593
+ const sessions = snapshotRecord(value, path);
594
+ return Object.fromEntries(Object.entries(sessions).map(([playerId, raw]) => {
595
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
596
+ throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
597
+ }
598
+ const record = snapshotRecord(raw, `${path}.${playerId}`);
599
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions', 'resumeToken'], `${path}.${playerId}`);
600
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'resumeToken')), `${path}.${playerId}`);
601
+ const resumeToken = record.resumeToken === undefined
602
+ ? undefined
603
+ : snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
604
+ return [
605
+ playerId,
606
+ { ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
607
+ ];
608
+ }));
609
+ }
610
+ function snapshotFrameRoleBindings(value, path) {
611
+ const bindings = snapshotRecord(value, path);
612
+ return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
613
+ if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
614
+ throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
615
+ }
616
+ const playerId = snapshotString(raw, `${path}.${roleId}`);
617
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
618
+ throw new TypeError(`${path}.${roleId} has invalid player id`);
619
+ }
620
+ return [roleId, playerId];
621
+ }));
622
+ }
623
+ function normalizeHostPlayerResult(value, expectedPlayerId) {
624
+ const path = 'tmux-play delegated-player result';
625
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
626
+ throw new TypeError(`${path} must be an object`);
627
+ }
628
+ const prototype = Object.getPrototypeOf(value);
629
+ if (prototype !== Object.prototype && prototype !== null) {
630
+ throw new TypeError(`${path} must be a plain JSON object`);
631
+ }
632
+ const descriptors = Object.getOwnPropertyDescriptors(value);
633
+ const allowedKeys = new Set([
634
+ 'status',
635
+ 'playerId',
636
+ 'turnId',
637
+ 'resumeToken',
638
+ 'finalText',
639
+ 'error',
640
+ ]);
641
+ const normalized = {};
642
+ for (const key of Reflect.ownKeys(descriptors)) {
643
+ if (typeof key === 'symbol') {
644
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
645
+ }
646
+ const descriptor = descriptors[key];
647
+ if (!allowedKeys.has(key)) {
648
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
649
+ }
650
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
651
+ throw new TypeError(`${path}.${key} must be an enumerable data property`);
652
+ }
653
+ // Cligent deliberately exposes optional result members as own
654
+ // `undefined` data properties. Omit only those members before taking the
655
+ // immutable JSON snapshot; every other value still passes the strict
656
+ // JSON validator below.
657
+ if (descriptor.value !== undefined)
658
+ normalized[key] = descriptor.value;
659
+ }
660
+ const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
661
+ rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
662
+ if (record.playerId !== expectedPlayerId) {
663
+ throw new TypeError(`${path}.playerId does not match the requested player`);
664
+ }
665
+ snapshotInteger(record.turnId, `${path}.turnId`, 1);
666
+ return validatePlayerResult({
667
+ status: record.status,
668
+ ...(record.resumeToken === undefined
669
+ ? {}
670
+ : { resumeToken: record.resumeToken }),
671
+ ...(record.finalText === undefined
672
+ ? {}
673
+ : { finalText: record.finalText }),
674
+ ...(record.error === undefined ? {} : { error: record.error }),
675
+ }, path);
676
+ }
471
677
  /** Validate, detach, and freeze one untrusted shell snapshot. */
472
- function assertPlaybookCaptainShellSnapshot(value) {
678
+ export function assertPlaybookCaptainShellSnapshot(value) {
473
679
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
474
680
  const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
475
681
  const mode = snapshot.mode;
476
682
  const commonKeys = [
477
683
  'schemaVersion',
478
684
  'captain',
685
+ 'playerSessions',
479
686
  'issuedSessionIds',
480
687
  'sequences',
481
688
  'journal',
@@ -490,7 +697,6 @@ function assertPlaybookCaptainShellSnapshot(value) {
490
697
  rejectSnapshotKeys(snapshot, [
491
698
  ...commonKeys,
492
699
  'frames',
493
- 'rootPlayerResumeTokens',
494
700
  'pendingBossQuestions',
495
701
  'lastError',
496
702
  ], 'Captain shell snapshot');
@@ -498,20 +704,31 @@ function assertPlaybookCaptainShellSnapshot(value) {
498
704
  else {
499
705
  throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
500
706
  }
501
- if (snapshot.schemaVersion !== 1) {
502
- throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`);
707
+ if (snapshot.schemaVersion !== 3) {
708
+ throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
503
709
  }
504
710
  const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
505
- rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
711
+ rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
506
712
  const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
507
713
  const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
714
+ const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
508
715
  const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
509
716
  let normalizedConversation;
510
717
  if (conversation.kind === 'pinned') {
511
718
  rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
512
719
  normalizedConversation = {
513
720
  kind: 'pinned',
514
- token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token', true),
721
+ token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'),
722
+ };
723
+ }
724
+ else if (conversation.kind === 'needsCatchUp') {
725
+ rejectSnapshotKeys(conversation, ['kind', 'resume', 'afterJournalSeq'], 'Captain shell snapshot.captain.conversation');
726
+ normalizedConversation = {
727
+ kind: 'needsCatchUp',
728
+ resume: conversation.resume === false
729
+ ? false
730
+ : snapshotString(conversation.resume, 'Captain shell snapshot.captain.conversation.resume'),
731
+ afterJournalSeq: snapshotInteger(conversation.afterJournalSeq, 'Captain shell snapshot.captain.conversation.afterJournalSeq'),
515
732
  };
516
733
  }
517
734
  else if (conversation.kind === 'unopened' ||
@@ -579,6 +796,19 @@ function assertPlaybookCaptainShellSnapshot(value) {
579
796
  bossRecords !== turnSequence) {
580
797
  throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
581
798
  }
799
+ const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
800
+ if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
801
+ throw new TypeError('Captain shell snapshot history is empty exactly when its conversation is unopened');
802
+ }
803
+ if (normalizedConversation.kind === 'needsCatchUp' &&
804
+ normalizedConversation.afterJournalSeq >= journalSequence) {
805
+ throw new TypeError('Captain shell snapshot catch-up watermark must precede the current journal sequence');
806
+ }
807
+ if (normalizedConversation.kind === 'needsCatchUp' &&
808
+ ((normalizedConversation.resume === false) !==
809
+ (normalizedConversation.afterJournalSeq === 0))) {
810
+ throw new TypeError('Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero');
811
+ }
582
812
  let lastAction;
583
813
  if (snapshot.lastAction !== undefined) {
584
814
  if (typeof snapshot.lastAction !== 'string' ||
@@ -595,13 +825,16 @@ function assertPlaybookCaptainShellSnapshot(value) {
595
825
  }
596
826
  lastSettlementStatus = snapshot.lastSettlementStatus;
597
827
  }
828
+ const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
598
829
  const common = {
599
- schemaVersion: 1,
830
+ schemaVersion: 3,
600
831
  captain: {
601
832
  sessionId: captainSessionId,
602
833
  runtime: captainRuntime,
834
+ agent: captainAgent,
603
835
  conversation: normalizedConversation,
604
836
  },
837
+ playerSessions,
605
838
  issuedSessionIds: issued,
606
839
  sequences: { turn: turnSequence, journal: journalSequence },
607
840
  journal: normalizedJournal,
@@ -610,6 +843,17 @@ function assertPlaybookCaptainShellSnapshot(value) {
610
843
  ? {}
611
844
  : { lastSettlementStatus }),
612
845
  };
846
+ if (captainRuntime.state.status !== 'active' ||
847
+ !captainRuntime.state.quiescent ||
848
+ !captainRuntime.state.tags.includes('playbook.parked') ||
849
+ captainRuntime.suspendedCall !== undefined ||
850
+ Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
851
+ captainRuntime.pendingBossQuestions.length > 0) {
852
+ throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
853
+ }
854
+ if (captainRuntime.sequences.turn !== turnSequence) {
855
+ throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
856
+ }
613
857
  if (mode === 'chat') {
614
858
  return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
615
859
  }
@@ -626,6 +870,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
626
870
  'depth',
627
871
  'parentSessionId',
628
872
  'parentCallId',
873
+ 'options',
874
+ 'roleBindings',
629
875
  'runtime',
630
876
  ], `Captain shell snapshot.frames[${index}]`);
631
877
  const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
@@ -639,6 +885,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
639
885
  ? undefined
640
886
  : snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
641
887
  const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
888
+ const options = frame.options;
889
+ const roleBindings = snapshotFrameRoleBindings(frame.roleBindings, `Captain shell snapshot.frames[${index}].roleBindings`);
642
890
  normalizedFrames.push({
643
891
  playbookId,
644
892
  sessionId,
@@ -646,14 +894,11 @@ function assertPlaybookCaptainShellSnapshot(value) {
646
894
  depth,
647
895
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
648
896
  ...(parentCallId === undefined ? {} : { parentCallId }),
897
+ options,
898
+ roleBindings,
649
899
  runtime,
650
900
  });
651
901
  }
652
- const rootTokens = snapshotRecord(snapshot.rootPlayerResumeTokens, 'Captain shell snapshot.rootPlayerResumeTokens');
653
- const normalizedRootTokens = Object.fromEntries(Object.entries(rootTokens).map(([playerId, token]) => [
654
- playerId,
655
- snapshotString(token, `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`),
656
- ]));
657
902
  let normalizedLastError;
658
903
  if (snapshot.lastError !== undefined) {
659
904
  const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
@@ -663,11 +908,80 @@ function assertPlaybookCaptainShellSnapshot(value) {
663
908
  message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
664
909
  };
665
910
  }
911
+ const activePlaybooks = new Set();
912
+ const activeSessionIds = new Set([captainSessionId]);
913
+ const issuedIds = new Set(issued);
914
+ const rootSessionId = normalizedFrames[0].sessionId;
915
+ for (const [index, frame] of normalizedFrames.entries()) {
916
+ if (activePlaybooks.has(frame.playbookId)) {
917
+ throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
918
+ }
919
+ activePlaybooks.add(frame.playbookId);
920
+ if (activeSessionIds.has(frame.sessionId)) {
921
+ throw new TypeError('Captain shell snapshot frame session ids must be unique');
922
+ }
923
+ activeSessionIds.add(frame.sessionId);
924
+ if (!issuedIds.has(frame.sessionId)) {
925
+ throw new TypeError('Captain shell snapshot frame session id was not historically issued');
926
+ }
927
+ if (frame.depth !== index ||
928
+ frame.rootSessionId !== rootSessionId ||
929
+ frame.runtime.state.status !== 'active' ||
930
+ !frame.runtime.state.quiescent) {
931
+ throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
932
+ }
933
+ if (index === 0) {
934
+ if (frame.sessionId !== frame.rootSessionId ||
935
+ frame.parentSessionId !== undefined ||
936
+ frame.parentCallId !== undefined) {
937
+ throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
938
+ }
939
+ }
940
+ else {
941
+ const parent = normalizedFrames[index - 1];
942
+ const pending = parent.runtime.suspendedCall;
943
+ if (frame.parentSessionId !== parent.sessionId ||
944
+ frame.parentCallId === undefined) {
945
+ throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
946
+ }
947
+ if (!pending ||
948
+ pending.callId !== frame.parentCallId ||
949
+ pending.playbookId !== frame.playbookId ||
950
+ pending.childSessionId !== frame.sessionId) {
951
+ throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
952
+ }
953
+ }
954
+ for (const playerId of Object.values(frame.roleBindings)) {
955
+ if (playerSessions[playerId] === undefined) {
956
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`);
957
+ }
958
+ }
959
+ for (const question of frame.runtime.pendingBossQuestions) {
960
+ if (question.asker.kind === 'role' &&
961
+ frame.roleBindings[question.asker.roleId] === undefined) {
962
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`);
963
+ }
964
+ }
965
+ const projectedTokens = Object.fromEntries(Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
966
+ const token = playerSessions[playerId]?.resumeToken;
967
+ return token === undefined ? [] : [[role, token]];
968
+ }));
969
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
970
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`);
971
+ }
972
+ }
973
+ const leafRuntime = normalizedFrames.at(-1).runtime;
974
+ if (leafRuntime.suspendedCall !== undefined ||
975
+ !leafRuntime.state.tags.includes('playbook.parked')) {
976
+ throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
977
+ }
978
+ if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
979
+ throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
980
+ }
666
981
  return snapshotJsonValue({
667
982
  ...common,
668
983
  mode,
669
984
  frames: normalizedFrames,
670
- rootPlayerResumeTokens: normalizedRootTokens,
671
985
  ...(snapshot.pendingBossQuestions === undefined
672
986
  ? {}
673
987
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -676,27 +990,109 @@ function assertPlaybookCaptainShellSnapshot(value) {
676
990
  : { lastError: normalizedLastError }),
677
991
  }, 'Captain shell snapshot');
678
992
  }
679
- function readPlaybooksConfig(options) {
680
- if (typeof options !== 'object' || options === null)
681
- return undefined;
682
- const pb = options.playbooks;
683
- if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
684
- return undefined;
993
+ function snapshotTuningSelection(value, path) {
994
+ const selection = snapshotRecord(value, path);
995
+ if (selection.kind === 'provider-default') {
996
+ rejectSnapshotKeys(selection, ['kind'], path);
997
+ return { kind: 'provider-default' };
685
998
  }
686
- return pb;
999
+ if (selection.kind === 'value') {
1000
+ rejectSnapshotKeys(selection, ['kind', 'value'], path);
1001
+ return {
1002
+ kind: 'value',
1003
+ value: snapshotString(selection.value, `${path}.value`),
1004
+ };
1005
+ }
1006
+ throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
1007
+ }
1008
+ const EFFORT_VALUES = new Set([
1009
+ 'on',
1010
+ 'minimal',
1011
+ 'low',
1012
+ 'medium',
1013
+ 'high',
1014
+ 'xhigh',
1015
+ 'max',
1016
+ 'ultra',
1017
+ 'ultracode',
1018
+ 'off',
1019
+ ]);
1020
+ function snapshotEffortSelection(value, path) {
1021
+ const selection = snapshotTuningSelection(value, path);
1022
+ if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
1023
+ throw new TypeError(`${path}.value is not a supported effort selection`);
1024
+ }
1025
+ return selection;
1026
+ }
1027
+ function snapshotSessionAgent(value, path) {
1028
+ const agent = snapshotRecord(value, path);
1029
+ rejectSnapshotKeys(agent, ['adapter', 'model', 'effort', 'instruction', 'permissions'], path);
1030
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' && key !== 'effort')), path);
1031
+ return {
1032
+ adapter: fixed.adapter,
1033
+ ...(fixed.instruction === undefined
1034
+ ? {}
1035
+ : { instruction: fixed.instruction }),
1036
+ ...(fixed.permissions === undefined
1037
+ ? {}
1038
+ : { permissions: livePermissions(fixed.permissions) }),
1039
+ model: snapshotTuningSelection(agent.model, `${path}.model`),
1040
+ effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
1041
+ };
1042
+ }
1043
+ function fixedAgent(agent) {
1044
+ return {
1045
+ adapter: agent.adapter,
1046
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1047
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1048
+ };
1049
+ }
1050
+ function callSettings(agent, tuning = agent) {
1051
+ return {
1052
+ model: tuning.model,
1053
+ effort: tuning.effort,
1054
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1055
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1056
+ };
1057
+ }
1058
+ function promptIdentity(binding) {
1059
+ return binding.model.kind === 'value'
1060
+ ? binding.model.value
1061
+ : binding.agent.adapter;
687
1062
  }
688
- // Resolve the active registry at init from `captain.options.playbooks`
689
- // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
690
- // module and bound to namespaced `<id>-<role>` host players.
691
- async function buildEnablements(options, players, loadModule) {
1063
+ // Resolve the active registry at init from exact normalized role and session
1064
+ // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1065
+ // exists at this boundary.
1066
+ async function buildEnablements(options, loadModule) {
692
1067
  const entries = [];
693
1068
  const byCommand = new Map();
694
1069
  const byId = new Map();
695
1070
  const enablementById = new Map();
696
- const config = readPlaybooksConfig(options);
697
- if (config === undefined) {
1071
+ const detached = snapshotJsonValue(options, 'captain.options');
1072
+ const top = snapshotRecord(detached, 'captain.options');
1073
+ rejectSnapshotKeys(top, ['playbooks', 'sessionAgents', 'captainAdapter'], 'captain.options');
1074
+ const configValue = top.playbooks;
1075
+ if (typeof configValue !== 'object' ||
1076
+ configValue === null ||
1077
+ Array.isArray(configValue)) {
698
1078
  throw new Error('captain.options.playbooks is required');
699
1079
  }
1080
+ const config = configValue;
1081
+ const sessionAgents = snapshotRecord(top.sessionAgents, 'captain.options.sessionAgents');
1082
+ rejectSnapshotKeys(sessionAgents, ['captain', 'players'], 'captain.options.sessionAgents');
1083
+ const captainAgent = snapshotSessionAgent(sessionAgents.captain, 'captain.options.sessionAgents.captain');
1084
+ if (top.captainAdapter !== undefined &&
1085
+ top.captainAdapter !== captainAgent.adapter) {
1086
+ throw new Error('captain.options.captainAdapter must equal sessionAgents.captain.adapter');
1087
+ }
1088
+ const playerAgentRecord = snapshotRecord(sessionAgents.players, 'captain.options.sessionAgents.players');
1089
+ const playerAgents = new Map();
1090
+ for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
1091
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1092
+ throw new Error(`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`);
1093
+ }
1094
+ playerAgents.set(playerId, snapshotSessionAgent(agent, `captain.options.sessionAgents.players.${playerId}`));
1095
+ }
700
1096
  const ids = Object.keys(config);
701
1097
  if (ids.length === 0) {
702
1098
  throw new Error('captain.options.playbooks must enable at least one playbook');
@@ -710,6 +1106,7 @@ async function buildEnablements(options, players, loadModule) {
710
1106
  throw new Error(`captain.options.playbooks.${id} must be an object`);
711
1107
  }
712
1108
  const record = block;
1109
+ rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
713
1110
  const from = record.from;
714
1111
  if (typeof from !== 'string' || from.length === 0) {
715
1112
  throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
@@ -740,35 +1137,74 @@ async function buildEnablements(options, players, loadModule) {
740
1137
  if (byCommand.has(command)) {
741
1138
  throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
742
1139
  }
743
- const boundPlayers = entry.requiredRoleIds.map((role) => {
744
- const host = players.find((p) => p.id === `${entry.id}-${role}`);
745
- return {
746
- id: role,
747
- ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
748
- ...(host?.model !== undefined ? { model: host.model } : {}),
749
- };
750
- });
1140
+ const roleRecord = snapshotRecord(record.roles, `captain.options.playbooks.${id}.roles`);
1141
+ const required = new Set(entry.requiredRoleIds);
1142
+ const configuredRoles = Object.keys(roleRecord);
1143
+ const missing = entry.requiredRoleIds.filter((role) => !Object.hasOwn(roleRecord, role));
1144
+ const extra = configuredRoles.filter((role) => !required.has(role));
1145
+ if (missing.length > 0 || extra.length > 0) {
1146
+ throw new Error(`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`);
1147
+ }
1148
+ const roleBindings = new Map();
1149
+ for (const role of entry.requiredRoleIds) {
1150
+ const path = `captain.options.playbooks.${id}.roles.${role}`;
1151
+ const rawBinding = snapshotRecord(roleRecord[role], path);
1152
+ rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
1153
+ const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
1154
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1155
+ throw new Error(`${path}.playerId is not a canonical player id`);
1156
+ }
1157
+ const agent = playerAgents.get(playerId);
1158
+ if (!agent) {
1159
+ throw new Error(`${path}.playerId names absent session player ${JSON.stringify(playerId)}`);
1160
+ }
1161
+ roleBindings.set(role, {
1162
+ playerId,
1163
+ model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
1164
+ effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
1165
+ agent,
1166
+ });
1167
+ }
1168
+ for (const concurrentRoles of entry.concurrentRoleSets) {
1169
+ const playerIds = concurrentRoles.map((role) => roleBindings.get(role).playerId);
1170
+ if (new Set(playerIds).size !== playerIds.length) {
1171
+ throw new Error(`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`);
1172
+ }
1173
+ }
1174
+ const validatedOptions = snapshotJsonValue(entry.validateOptions(record.options), `captain.options.playbooks.${id}.options`);
751
1175
  entries.push(entry);
752
1176
  byId.set(entry.id, entry);
753
1177
  byCommand.set(command, entry);
754
1178
  enablementById.set(entry.id, {
755
1179
  entry,
756
1180
  command,
757
- optionInput: record.options,
758
- boundPlayers,
759
- hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
1181
+ options: validatedOptions,
1182
+ roleBindings,
760
1183
  });
761
1184
  }
762
- return { entries, byCommand, byId, enablementById };
1185
+ const referenced = new Set([...enablementById.values()].flatMap((enablement) => [...enablement.roleBindings.values()].map((binding) => binding.playerId)));
1186
+ const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
1187
+ if (unreferenced !== undefined) {
1188
+ throw new Error(`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`);
1189
+ }
1190
+ return {
1191
+ entries,
1192
+ byCommand,
1193
+ byId,
1194
+ enablementById,
1195
+ captainAgent,
1196
+ playerAgents,
1197
+ };
763
1198
  }
764
1199
  export function createPlaybookCaptainShell(options, deps = {}) {
765
1200
  const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
766
1201
  const createSessionId = deps.createSessionId ?? randomUUID;
767
1202
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
768
- // DR-013 A1: the launcher passes the resolved captain adapter through
769
- // `captain.options`; a raw `--config` launch leaves it undefined, which
770
- // keeps the enforced empty allowlist and its fail-closed behavior.
771
- const captainAdapter = readCaptainAdapter(options);
1203
+ let captainAgent;
1204
+ let captainAdapter;
1205
+ let playerAgents = new Map();
1206
+ const playerLedger = new Map();
1207
+ const playerTransactions = new Map();
772
1208
  let entries = [];
773
1209
  let byCommand = new Map();
774
1210
  let byId = new Map();
@@ -778,7 +1214,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
778
1214
  let closedGateAttempted = false;
779
1215
  let lifecycle = 'fresh';
780
1216
  let terminallyDisposed = false;
781
- let players = [];
782
1217
  let activeContext;
783
1218
  const frames = [];
784
1219
  let mode = 'chat';
@@ -828,15 +1263,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
828
1263
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
829
1264
  let captainRuntime;
830
1265
  let captainSessionId;
831
- // CAPTAIN-35: the conversation is exactly one of unopened, pinned, or
832
- // owed-a-reseed. There is no fourth state in which a non-first call starts a
833
- // bare conversation.
1266
+ // CAPTAIN-35: a preflight settings rejection retains proven continuity but
1267
+ // records the exact journal suffix still owed; other continuity failures
1268
+ // require a fresh, full reseed.
834
1269
  let conversation = { kind: 'unopened' };
835
1270
  let shuttingDown = false;
836
1271
  const journal = [];
837
1272
  let journalSeq = 0;
838
1273
  let turnSequence = 0;
839
1274
  let activeTurn;
1275
+ // `PlayerSessionStore.restore` is authoritative only while the shell is
1276
+ // awaiting the exact owning runtime's restore during a closed-gate shell
1277
+ // restoration. A runtime cannot use the store as a general ledger writer.
1278
+ let restoringPlayerSessionFrame;
840
1279
  // The durable call the runtime is about to make, taken from the paired
841
1280
  // `captain.call.started` boundary the engine emits before the port call
842
1281
  // (CAPTAIN-9): the shell never infers a call's kind from its prose.
@@ -904,7 +1343,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
904
1343
  // (CAPTAIN-5/CAPTAIN-6).
905
1344
  ...(captainRuntime
906
1345
  ? {
907
- durableConversation: conversation.kind === 'pinned',
1346
+ durableConversation: conversation.kind === 'pinned' ||
1347
+ (conversation.kind === 'needsCatchUp' &&
1348
+ conversation.resume !== false),
908
1349
  sessionJournal: true,
909
1350
  }
910
1351
  : {}),
@@ -1060,39 +1501,130 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1060
1501
  };
1061
1502
  let callNestedPlaybook;
1062
1503
  const createPorts = (frame) => ({
1063
- callPlayer: async (playerId, prompt, signal, options) => {
1504
+ callPlayer: async (roleId, prompt, signal, options) => {
1064
1505
  admitHostBoundary();
1065
- if (!activeContext) {
1506
+ if (!activeContext || !activeTurn || !frame.playerCallScope) {
1066
1507
  throw new Error('callPlayer invoked outside a Boss turn');
1067
1508
  }
1068
1509
  const context = activeContext;
1510
+ const admittedTurn = activeTurn;
1511
+ const scope = frame.playerCallScope;
1069
1512
  signal.throwIfAborted();
1070
- const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
1071
- const result = await trackHostCall(frame, context.callPlayer(hostPlayerId, prompt, {
1072
- resume: options.resume,
1073
- }));
1074
- // CaptainContext is turn-scoped and cannot accept a narrower XState
1075
- // invocation signal. Recheck after the host call so a sibling
1076
- // cancellation is still reported as aborted and cannot rotate a
1077
- // stopped branch's player token in the linked runtime.
1078
- signal.throwIfAborted();
1079
- // CAPTAIN-20: only a player call that actually produced work is an
1080
- // interruption the Boss was spared. A call that errored or aborted
1081
- // saved nothing, so it never feeds the saved-counts gate.
1082
- const summary = activeTurnSummary;
1083
- if (summary && summaryIncludes(frame) && result.status === 'ok') {
1084
- summary.counts.interruptions++;
1513
+ const binding = bindingFor(frame, roleId);
1514
+ const ledger = playerLedger.get(binding.playerId);
1515
+ if (!ledger) {
1516
+ throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
1085
1517
  }
1086
- return {
1087
- status: result.status,
1088
- ...(result.resumeToken !== undefined
1089
- ? { resumeToken: result.resumeToken }
1090
- : {}),
1091
- ...(result.finalText !== undefined
1092
- ? { finalText: result.finalText }
1093
- : {}),
1094
- ...(result.error !== undefined ? { error: result.error } : {}),
1518
+ const expectedResume = ledger.resumeToken ?? false;
1519
+ if (options.resume !== expectedResume) {
1520
+ throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
1521
+ }
1522
+ if (playerTransactions.has(binding.playerId)) {
1523
+ throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
1524
+ }
1525
+ const settings = callSettings(binding.agent, binding);
1526
+ const calling = {
1527
+ phase: 'calling',
1528
+ frame,
1529
+ roleId,
1530
+ turnId: admittedTurn.id,
1531
+ signal,
1532
+ scope,
1533
+ abandoned: false,
1095
1534
  };
1535
+ playerTransactions.set(binding.playerId, calling);
1536
+ let result;
1537
+ let hostResolved = false;
1538
+ try {
1539
+ let rawResult;
1540
+ try {
1541
+ rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
1542
+ resume: options.resume,
1543
+ settings,
1544
+ })));
1545
+ hostResolved = true;
1546
+ }
1547
+ catch (error) {
1548
+ if (error instanceof AgentSettingsPreflightError) {
1549
+ if (playerTransactions.get(binding.playerId) !== calling ||
1550
+ calling.abandoned ||
1551
+ signal.aborted ||
1552
+ activeTurn !== admittedTurn ||
1553
+ frame.playerCallScope !== scope ||
1554
+ !frames.includes(frame)) {
1555
+ if (playerTransactions.get(binding.playerId) === calling) {
1556
+ playerTransactions.delete(binding.playerId);
1557
+ }
1558
+ signal.throwIfAborted();
1559
+ throw new Error(`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`);
1560
+ }
1561
+ throw rememberSettingsPreflight(error.rejection);
1562
+ }
1563
+ throw error;
1564
+ }
1565
+ result = normalizeHostPlayerResult(rawResult, binding.playerId);
1566
+ const transitionRequired = result.resumeToken !== undefined || result.status === 'ok';
1567
+ if (playerTransactions.get(binding.playerId) !== calling ||
1568
+ calling.abandoned ||
1569
+ signal.aborted ||
1570
+ activeTurn !== admittedTurn ||
1571
+ frame.playerCallScope !== scope ||
1572
+ !frames.includes(frame)) {
1573
+ if (playerTransactions.get(binding.playerId) === calling) {
1574
+ if (transitionRequired) {
1575
+ playerTransactions.set(binding.playerId, {
1576
+ phase: 'quarantined',
1577
+ frame,
1578
+ roleId,
1579
+ turnId: admittedTurn.id,
1580
+ signal,
1581
+ scope,
1582
+ reason: 'a transition-worthy result arrived after its runtime operation ended',
1583
+ });
1584
+ }
1585
+ else {
1586
+ playerTransactions.delete(binding.playerId);
1587
+ }
1588
+ }
1589
+ signal.throwIfAborted();
1590
+ throw new Error(`${frameLabel(frame)} player result arrived after its runtime operation ended`);
1591
+ }
1592
+ if (transitionRequired) {
1593
+ playerTransactions.set(binding.playerId, {
1594
+ phase: 'awaitingCommit',
1595
+ frame,
1596
+ roleId,
1597
+ turnId: admittedTurn.id,
1598
+ signal,
1599
+ scope,
1600
+ status: result.status,
1601
+ expectedToken: result.resumeToken,
1602
+ });
1603
+ }
1604
+ else {
1605
+ playerTransactions.delete(binding.playerId);
1606
+ }
1607
+ }
1608
+ catch (error) {
1609
+ if (playerTransactions.get(binding.playerId) === calling) {
1610
+ if (hostResolved) {
1611
+ playerTransactions.set(binding.playerId, {
1612
+ phase: 'quarantined',
1613
+ frame,
1614
+ roleId,
1615
+ turnId: admittedTurn.id,
1616
+ signal,
1617
+ scope,
1618
+ reason: 'a late player result could not be validated',
1619
+ });
1620
+ }
1621
+ else {
1622
+ playerTransactions.delete(binding.playerId);
1623
+ }
1624
+ }
1625
+ throw error;
1626
+ }
1627
+ return result;
1096
1628
  },
1097
1629
  callCaptain: async (prompt, signal, options) => {
1098
1630
  admitHostBoundary();
@@ -1173,12 +1705,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1173
1705
  },
1174
1706
  });
1175
1707
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
1176
- // visibility for that playbook's generated host players. A pane
1708
+ // visibility for that playbook's explicitly bound session players. A pane
1177
1709
  // reconciliation failure is display-only in tmux-play and does not
1178
1710
  // reject; the legacy path carries no generated set and skips this.
1179
1711
  const requestVisibility = async (frame) => {
1180
- const ids = [...new Set([...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId))];
1181
- if (!ids || ids.length === 0 || !activeContext)
1712
+ const ids = [...new Set([...frame.playerBindings.values()].map(({ playerId }) => playerId))];
1713
+ // A roleless frame does not ask a non-empty host roster to show `[]`:
1714
+ // tmux-play reserves that value for a genuinely empty configured roster.
1715
+ if (ids.length === 0 || !activeContext)
1182
1716
  return;
1183
1717
  try {
1184
1718
  await activeContext.setVisiblePlayers(ids);
@@ -1210,39 +1744,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1210
1744
  : undefined;
1211
1745
  return typeof stack === 'string' ? { ...compact, stack } : compact;
1212
1746
  };
1213
- const makePlayerBindings = (enablement, parent) => {
1214
- const entry = enablement.entry;
1215
- const playerBindings = new Map();
1216
- for (const role of entry.requiredRoleIds) {
1217
- let inherited;
1218
- for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
1219
- inherited = ancestor.playerBindings.get(role);
1220
- }
1221
- if (inherited) {
1222
- playerBindings.set(role, inherited);
1223
- continue;
1224
- }
1225
- const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
1226
- playerBindings.set(role, {
1227
- hostPlayerId: enablement.hostPlayerId(role),
1228
- player: configured,
1229
- });
1230
- }
1231
- return playerBindings;
1747
+ const makePlayerBindings = (enablement) => {
1748
+ return new Map(enablement.roleBindings);
1232
1749
  };
1233
1750
  const makeFrame = (enablement, parent) => {
1234
1751
  const entry = enablement.entry;
1235
1752
  const sessionId = allocateSessionId();
1236
- const playerBindings = makePlayerBindings(enablement, parent);
1237
- const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
1238
- const runtime = entry.createRuntime({
1239
- captainOptions: enablement.optionInput,
1240
- players: [...playerBindings].map(([role, { player }]) => ({
1241
- id: role,
1242
- ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
1243
- ...(player.model === undefined ? {} : { model: player.model }),
1244
- })),
1245
- });
1753
+ const playerBindings = makePlayerBindings(enablement);
1754
+ const runtime = entry.createRuntime(enablement.options);
1246
1755
  return {
1247
1756
  entry,
1248
1757
  enablement,
@@ -1251,22 +1760,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1251
1760
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
1252
1761
  depth: parent ? parent.frame.depth + 1 : 0,
1253
1762
  playerBindings,
1254
- playerResumeTokens,
1255
1763
  ...(parent ? { parent } : {}),
1256
1764
  inFlightHostCalls: new Set(),
1257
1765
  };
1258
1766
  };
1259
- const makeRestoredFrame = (enablement, snapshot, rootPlayerResumeTokens, parent) => {
1767
+ const makeRestoredFrame = (enablement, snapshot, parent) => {
1260
1768
  const entry = enablement.entry;
1261
- const playerBindings = makePlayerBindings(enablement, parent);
1262
- const runtime = entry.createRuntime({
1263
- captainOptions: enablement.optionInput,
1264
- players: [...playerBindings].map(([role, { player }]) => ({
1265
- id: role,
1266
- ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
1267
- ...(player.model === undefined ? {} : { model: player.model }),
1268
- })),
1269
- });
1769
+ const playerBindings = makePlayerBindings(enablement);
1770
+ const runtime = entry.createRuntime(enablement.options);
1270
1771
  return {
1271
1772
  entry,
1272
1773
  enablement,
@@ -1275,7 +1776,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1275
1776
  rootSessionId: snapshot.rootSessionId,
1276
1777
  depth: snapshot.depth,
1277
1778
  playerBindings,
1278
- playerResumeTokens: rootPlayerResumeTokens,
1279
1779
  ...(parent ? { parent } : {}),
1280
1780
  state: snapshot.runtime.state,
1281
1781
  inFlightHostCalls: new Set(),
@@ -1284,36 +1784,141 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1284
1784
  const playerSessionStore = (frame) => ({
1285
1785
  select(playerId) {
1286
1786
  const binding = bindingFor(frame, playerId);
1287
- return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
1787
+ return playerLedger.get(binding.playerId)?.resumeToken ?? false;
1288
1788
  },
1289
1789
  update(playerId, resumeToken) {
1290
1790
  const binding = bindingFor(frame, playerId);
1291
- if (resumeToken === undefined) {
1292
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1791
+ const ledger = playerLedger.get(binding.playerId);
1792
+ if (!ledger) {
1793
+ throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
1293
1794
  }
1294
- else {
1295
- frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
1795
+ const pending = playerTransactions.get(binding.playerId);
1796
+ if (pending?.phase !== 'awaitingCommit' ||
1797
+ pending.frame !== frame ||
1798
+ pending.roleId !== playerId ||
1799
+ pending.scope !== frame.playerCallScope ||
1800
+ pending.expectedToken !== resumeToken) {
1801
+ throw new Error(`${frameLabel(frame)} player update does not acknowledge a validated host result`);
1802
+ }
1803
+ if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
1804
+ playerTransactions.set(binding.playerId, {
1805
+ phase: 'quarantined',
1806
+ frame: pending.frame,
1807
+ roleId: pending.roleId,
1808
+ turnId: pending.turnId,
1809
+ signal: pending.signal,
1810
+ scope: pending.scope,
1811
+ reason: 'the runtime aborted before committing a validated result',
1812
+ });
1813
+ throw new Error(`${frameLabel(frame)} rejected a late or aborted player continuation update`);
1814
+ }
1815
+ try {
1816
+ if (resumeToken === undefined)
1817
+ delete ledger.resumeToken;
1818
+ else
1819
+ ledger.resumeToken = resumeToken;
1820
+ // CAPTAIN-20: a result counts only after the runtime validated it and
1821
+ // atomically published its authorized continuation transition.
1822
+ const summary = activeTurnSummary;
1823
+ if (pending.status === 'ok' &&
1824
+ summary &&
1825
+ summaryIncludes(frame)) {
1826
+ summary.counts.interruptions++;
1827
+ }
1828
+ }
1829
+ finally {
1830
+ playerTransactions.delete(binding.playerId);
1296
1831
  }
1297
1832
  },
1298
1833
  snapshot() {
1299
1834
  const tokens = {};
1300
1835
  for (const [playerId, binding] of frame.playerBindings) {
1301
- const token = frame.playerResumeTokens.get(binding.hostPlayerId);
1836
+ const token = playerLedger.get(binding.playerId)?.resumeToken;
1302
1837
  if (token !== undefined)
1303
1838
  tokens[playerId] = token;
1304
1839
  }
1305
1840
  return tokens;
1306
1841
  },
1307
1842
  restore(tokens) {
1308
- for (const binding of frame.playerBindings.values()) {
1309
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1843
+ if (lifecycle !== 'restoring' ||
1844
+ restoringPlayerSessionFrame !== frame) {
1845
+ throw new Error(`${frameLabel(frame)} player-session restore is only available during shell restoration`);
1310
1846
  }
1847
+ const byPlayer = new Map();
1311
1848
  for (const [playerId, token] of Object.entries(tokens)) {
1312
1849
  const binding = bindingFor(frame, playerId);
1313
- frame.playerResumeTokens.set(binding.hostPlayerId, token);
1850
+ const previous = byPlayer.get(binding.playerId);
1851
+ if (previous !== undefined && previous !== token) {
1852
+ throw new Error(`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`);
1853
+ }
1854
+ byPlayer.set(binding.playerId, token);
1855
+ }
1856
+ for (const binding of frame.playerBindings.values()) {
1857
+ if (!byPlayer.has(binding.playerId))
1858
+ byPlayer.set(binding.playerId, undefined);
1859
+ }
1860
+ for (const [playerId, token] of byPlayer) {
1861
+ const ledger = playerLedger.get(playerId);
1862
+ if (!ledger) {
1863
+ throw new Error(`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`);
1864
+ }
1865
+ if (token === undefined)
1866
+ delete ledger.resumeToken;
1867
+ else
1868
+ ledger.resumeToken = token;
1314
1869
  }
1315
1870
  },
1316
1871
  });
1872
+ const closePlayerCallScope = (frame, scope) => {
1873
+ if (frame.playerCallScope === scope)
1874
+ frame.playerCallScope = undefined;
1875
+ const missing = [];
1876
+ for (const [playerId, transaction] of playerTransactions) {
1877
+ if (transaction.frame !== frame || transaction.scope !== scope)
1878
+ continue;
1879
+ if (transaction.phase === 'calling') {
1880
+ transaction.abandoned = true;
1881
+ }
1882
+ else {
1883
+ playerTransactions.set(playerId, {
1884
+ phase: 'quarantined',
1885
+ frame: transaction.frame,
1886
+ roleId: transaction.roleId,
1887
+ turnId: transaction.turnId,
1888
+ signal: transaction.signal,
1889
+ scope: transaction.scope,
1890
+ reason: transaction.phase === 'awaitingCommit'
1891
+ ? 'the runtime returned without committing a validated result'
1892
+ : transaction.reason,
1893
+ });
1894
+ }
1895
+ if (!transaction.signal.aborted)
1896
+ missing.push(playerId);
1897
+ }
1898
+ return missing.length === 0
1899
+ ? undefined
1900
+ : new Error(`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`);
1901
+ };
1902
+ const runFrameOperation = async (frame, operation) => {
1903
+ if (frame.playerCallScope !== undefined) {
1904
+ throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
1905
+ }
1906
+ const scope = {};
1907
+ frame.playerCallScope = scope;
1908
+ let outcome;
1909
+ try {
1910
+ outcome = { ok: true, value: await operation() };
1911
+ }
1912
+ catch (error) {
1913
+ outcome = { ok: false, error };
1914
+ }
1915
+ const cleanupError = closePlayerCallScope(frame, scope);
1916
+ if (!outcome.ok)
1917
+ throw outcome.error;
1918
+ if (cleanupError !== undefined)
1919
+ throw cleanupError;
1920
+ return outcome.value;
1921
+ };
1317
1922
  const frameSession = (frame) => ({
1318
1923
  sessionId: frame.sessionId,
1319
1924
  playbookId: frame.entry.id,
@@ -1325,6 +1930,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1325
1930
  }
1326
1931
  : {}),
1327
1932
  depth: frame.depth,
1933
+ roleBindings: Object.fromEntries([...frame.playerBindings].map(([roleId, binding]) => [
1934
+ roleId,
1935
+ {
1936
+ playerId: binding.playerId,
1937
+ promptIdentity: promptIdentity(binding),
1938
+ },
1939
+ ])),
1328
1940
  playerSessions: playerSessionStore(frame),
1329
1941
  ports: createPorts(frame),
1330
1942
  });
@@ -1609,7 +2221,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1609
2221
  // exception filed against an effect that never ran.
1610
2222
  await requestVisibility(frame);
1611
2223
  await setMode('engaged.driving', 'submit');
1612
- const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
2224
+ const result = await runFrameOperation(frame, () => runEffect(() => frame.runtime.handleBossInput({ text, signal })));
1613
2225
  frame.state = result.state;
1614
2226
  return result;
1615
2227
  };
@@ -1655,11 +2267,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1655
2267
  }
1656
2268
  let result;
1657
2269
  try {
1658
- result = await runEffect(() => parent.runtime.resumePlaybookCall({
2270
+ result = await runFrameOperation(parent, () => runEffect(() => parent.runtime.resumePlaybookCall({
1659
2271
  callId: parentLink.callId,
1660
2272
  result: effectiveResult,
1661
2273
  signal: context.signal,
1662
- }));
2274
+ })));
1663
2275
  }
1664
2276
  catch (error) {
1665
2277
  if (disposing || invocationSignal?.aborted)
@@ -2015,7 +2627,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2015
2627
  stateDigestLine(view.state, view.stateDescription),
2016
2628
  ].join(': '));
2017
2629
  lines.push(...leafContextLines(view.context));
2018
- const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.player)} asks: ${quoteEvidence(question.question)}`);
2630
+ const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.asker.kind === 'captain'
2631
+ ? 'Captain'
2632
+ : question.asker.roleId)} asks: ${quoteEvidence(question.question)}`);
2019
2633
  lines.push(pending.length === 0
2020
2634
  ? 'Pending Boss questions: none.'
2021
2635
  : ['Pending Boss questions:', ...pending].join('\n'));
@@ -2111,6 +2725,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2111
2725
  await trackTurnCall(settlement.context.emitReply(settlement.text));
2112
2726
  }
2113
2727
  catch (error) {
2728
+ conversation = { kind: 'needsSeeding' };
2114
2729
  const normalized = normalizeErrorCompact(error) ?? {
2115
2730
  name: 'Error',
2116
2731
  message: String(error),
@@ -2207,8 +2822,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2207
2822
  * text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
2208
2823
  * the Captain may need to repeat.
2209
2824
  */
2210
- const reseedDigest = () => {
2211
- for (const record of journal) {
2825
+ const conversationDigest = (records, render) => {
2826
+ for (const record of records) {
2212
2827
  if (record.kind === 'action' &&
2213
2828
  typeof record.payload === 'object' &&
2214
2829
  record.payload !== null &&
@@ -2218,13 +2833,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2218
2833
  recordSuppliedIdentifier(actionId);
2219
2834
  }
2220
2835
  }
2221
- return renderReseedDigest(journal);
2836
+ return render(records);
2222
2837
  };
2838
+ const reseedDigest = () => conversationDigest(journal, renderReseedDigest);
2839
+ const catchUpDigest = (afterJournalSeq) => conversationDigest(journal.filter((record) => record.seq > afterJournalSeq), renderCatchUpDigest);
2223
2840
  const markControlFailure = (error) => {
2224
- if (activeTurn)
2225
- activeTurn.controlFailure = true;
2841
+ activeTurn?.controlFailures.add(error);
2226
2842
  return error;
2227
2843
  };
2844
+ const markSettingsRejection = (error) => markControlFailure(error);
2845
+ const rememberSettingsPreflight = (error) => {
2846
+ activeTurn?.settingsPreflightFailures.add(error);
2847
+ return markSettingsRejection(error);
2848
+ };
2849
+ const markConversationCatchUp = () => {
2850
+ if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
2851
+ return;
2852
+ }
2853
+ conversation = {
2854
+ kind: 'needsCatchUp',
2855
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
2856
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
2857
+ };
2858
+ };
2859
+ const markConversationUnsynchronized = () => {
2860
+ if (conversation.kind !== 'needsCatchUp') {
2861
+ conversation = { kind: 'needsSeeding' };
2862
+ }
2863
+ };
2228
2864
  /**
2229
2865
  * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
2230
2866
  * engagement constructed, a stack disposed, an advertised action applied.
@@ -2270,14 +2906,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2270
2906
  this.name = 'CaptainProseError';
2271
2907
  }
2272
2908
  }
2273
- const rawDurableCall = async (context, prompt, resume) => {
2909
+ const rawDurableCall = async (context, prompt, resume, attempt) => {
2274
2910
  const queued = captainQueue.add(async () => {
2275
2911
  context.signal.throwIfAborted();
2276
- const result = await context.callCaptain(prompt, {
2912
+ attempt.providerBoundaryEntered = true;
2913
+ const result = await classifySettingsCall(() => context.callCaptain(prompt, {
2277
2914
  visibility: 'hidden',
2278
2915
  resume,
2279
2916
  ...controlCallToolOptions(captainAdapter),
2280
- });
2917
+ settings: callSettings(captainAgent),
2918
+ }));
2281
2919
  context.signal.throwIfAborted();
2282
2920
  return result;
2283
2921
  });
@@ -2289,17 +2927,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2289
2927
  // conversation that is owed a reseed carries the digest on its very next
2290
2928
  // call, so the turn after a failed reseed starts seeded rather than blank.
2291
2929
  const durableCall = async (context, compose) => {
2292
- const resume = conversation.kind === 'pinned' ? conversation.token : false;
2930
+ const startingConversation = conversation;
2931
+ const resume = startingConversation.kind === 'pinned'
2932
+ ? startingConversation.token
2933
+ : startingConversation.kind === 'needsCatchUp'
2934
+ ? startingConversation.resume
2935
+ : false;
2293
2936
  const seedFirstCall = conversation.kind === 'needsSeeding';
2937
+ const catchUpFirstCall = conversation.kind === 'needsCatchUp';
2938
+ const representedJournalSeq = journalSeq;
2939
+ const firstAttempt = { providerBoundaryEntered: false };
2294
2940
  let result;
2295
2941
  let failure;
2296
2942
  try {
2297
- result = await rawDurableCall(context, compose(seedFirstCall ? { reseedDigest: reseedDigest() } : {}), resume);
2943
+ result = await rawDurableCall(context, compose(seedFirstCall
2944
+ ? { reseedDigest: reseedDigest() }
2945
+ : startingConversation.kind === 'needsCatchUp'
2946
+ ? {
2947
+ reseedDigest: catchUpDigest(startingConversation.afterJournalSeq),
2948
+ }
2949
+ : {}), resume, firstAttempt);
2298
2950
  }
2299
2951
  catch (error) {
2300
2952
  if (context.signal.aborted) {
2301
- conversation = { kind: 'needsSeeding' };
2302
- throw error;
2953
+ if (firstAttempt.providerBoundaryEntered) {
2954
+ conversation = { kind: 'needsSeeding' };
2955
+ }
2956
+ else {
2957
+ markConversationUnsynchronized();
2958
+ }
2959
+ throw context.signal.reason ?? error;
2960
+ }
2961
+ if (error instanceof AgentSettingsPreflightError) {
2962
+ if (conversation.kind !== 'needsCatchUp' &&
2963
+ conversation.kind !== 'needsSeeding') {
2964
+ conversation = {
2965
+ kind: 'needsCatchUp',
2966
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
2967
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
2968
+ };
2969
+ }
2970
+ throw rememberSettingsPreflight(error.rejection);
2303
2971
  }
2304
2972
  failure = error;
2305
2973
  }
@@ -2309,11 +2977,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2309
2977
  result.resumeToken === undefined;
2310
2978
  if (!unsynchronized) {
2311
2979
  conversation = { kind: 'pinned', token: result.resumeToken };
2980
+ if (activeTurn) {
2981
+ activeTurn.captainSyncedJournalSeq = representedJournalSeq;
2982
+ }
2312
2983
  return {
2313
2984
  ...(result.finalText !== undefined
2314
2985
  ? { finalText: result.finalText }
2315
2986
  : {}),
2316
- correctiveSpent: seedFirstCall,
2987
+ correctiveSpent: seedFirstCall || catchUpFirstCall,
2317
2988
  };
2318
2989
  }
2319
2990
  // Only the model-side conversation is replaced: the stack, player
@@ -2323,13 +2994,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2323
2994
  conversation = { kind: 'needsSeeding' };
2324
2995
  const recap = reseedDigest();
2325
2996
  let reissued;
2997
+ const reissueAttempt = { providerBoundaryEntered: false };
2326
2998
  try {
2327
- reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false);
2999
+ reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false, reissueAttempt);
2328
3000
  }
2329
3001
  catch (error) {
2330
3002
  if (context.signal.aborted) {
2331
3003
  conversation = { kind: 'needsSeeding' };
2332
- throw error;
3004
+ throw context.signal.reason ?? error;
3005
+ }
3006
+ if (error instanceof AgentSettingsPreflightError) {
3007
+ throw rememberSettingsPreflight(error.rejection);
2333
3008
  }
2334
3009
  throw markControlFailure(new CaptainContinuityError(error));
2335
3010
  }
@@ -2338,6 +3013,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2338
3013
  `callCaptain status "${reissued.status}" without a resume token`));
2339
3014
  }
2340
3015
  conversation = { kind: 'pinned', token: reissued.resumeToken };
3016
+ if (activeTurn)
3017
+ activeTurn.captainSyncedJournalSeq = journalSeq;
2341
3018
  return {
2342
3019
  ...(reissued.finalText !== undefined
2343
3020
  ? { finalText: reissued.finalText }
@@ -2681,7 +3358,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2681
3358
  message: String(error),
2682
3359
  };
2683
3360
  if (aborted) {
2684
- conversation = { kind: 'needsSeeding' };
3361
+ markConversationUnsynchronized();
2685
3362
  if (turn?.outcomePending) {
2686
3363
  turn.settlementFacts.push(`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`);
2687
3364
  journalOutcome(journalOutcomeEvidence(turn.settlementFacts, 'failed', turn.report));
@@ -3011,7 +3688,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3011
3688
  // guard against re-execution — a repeated selection returns the recorded
3012
3689
  // receipt rather than acting twice.
3013
3690
  const key = `turn-${turn.id}-apply-${actionId}`;
3014
- const outcome = await withCounting(leaf, async () => runEffect(() => leaf.runtime.apply({ actionId, key, signal })));
3691
+ const outcome = await withCounting(leaf, async () => runFrameOperation(leaf, () => runEffect(() => leaf.runtime.apply({ actionId, key, signal }))));
3015
3692
  if (outcome.error !== undefined)
3016
3693
  throw outcome.error;
3017
3694
  const receipt = outcome.result;
@@ -3159,7 +3836,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3159
3836
  // The durable Captain conversation did not receive the shell-authored
3160
3837
  // fallback. Force its next call through the journal so it cannot interpret
3161
3838
  // the Boss's follow-up without the reply the Boss was given this turn.
3162
- conversation = { kind: 'needsSeeding' };
3839
+ if (activeTurn?.settingsPreflightFailures.has(error)) {
3840
+ markConversationCatchUp();
3841
+ }
3842
+ else {
3843
+ markConversationUnsynchronized();
3844
+ }
3163
3845
  // A rejected presentation may already have emitted bytes. It is therefore
3164
3846
  // final for this turn even though the Promise did not prove it was shown.
3165
3847
  if (activeTurn?.presentationAttempted === true)
@@ -3182,112 +3864,68 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3182
3864
  playbookId: INTERNAL_CAPTAIN_ID,
3183
3865
  rootSessionId: id,
3184
3866
  depth: 0,
3867
+ roleBindings: {},
3185
3868
  ports: captainPorts(),
3186
3869
  });
3187
- const tokenRecord = (tokens) => Object.fromEntries(tokens);
3870
+ const playerLedgerRecord = () => Object.fromEntries([...playerLedger].map(([playerId, entry]) => [
3871
+ playerId,
3872
+ {
3873
+ adapter: entry.adapter,
3874
+ ...(entry.instruction === undefined
3875
+ ? {}
3876
+ : { instruction: entry.instruction }),
3877
+ ...(entry.permissions === undefined
3878
+ ? {}
3879
+ : { permissions: entry.permissions }),
3880
+ ...(entry.resumeToken === undefined
3881
+ ? {}
3882
+ : { resumeToken: entry.resumeToken }),
3883
+ },
3884
+ ]));
3188
3885
  const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
3189
- const captain = snapshot.captain.runtime;
3190
- if (captain.schemaVersion !== 2 ||
3191
- captain.state.status !== 'active' ||
3192
- !captain.state.quiescent ||
3193
- !captain.state.tags.includes('playbook.parked') ||
3194
- captain.suspendedCall !== undefined ||
3195
- Object.keys(captain.playerResumeTokens).length > 0 ||
3196
- captain.pendingBossQuestions.length > 0) {
3197
- throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
3198
- }
3199
- if (captain.sequences.turn !== snapshot.sequences.turn) {
3200
- throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
3201
- }
3202
- const emptyHistory = snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
3203
- if ((snapshot.captain.conversation.kind === 'unopened') !== emptyHistory) {
3204
- throw new TypeError('Captain shell snapshot unopened conversation must exactly match an empty session history');
3886
+ if (captainAgent === undefined ||
3887
+ !isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))) {
3888
+ throw new TypeError('Captain shell snapshot Captain agent is incompatible with current config');
3889
+ }
3890
+ const configuredPlayerIds = [...playerAgents.keys()].sort();
3891
+ const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
3892
+ if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
3893
+ throw new TypeError('Captain shell snapshot player ledger does not match current referenced players');
3894
+ }
3895
+ for (const playerId of configuredPlayerIds) {
3896
+ const saved = snapshot.playerSessions[playerId];
3897
+ const configured = playerAgents.get(playerId);
3898
+ const savedFixed = {
3899
+ adapter: saved.adapter,
3900
+ ...(saved.instruction === undefined
3901
+ ? {}
3902
+ : { instruction: saved.instruction }),
3903
+ ...(saved.permissions === undefined
3904
+ ? {}
3905
+ : { permissions: saved.permissions }),
3906
+ };
3907
+ if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
3908
+ throw new TypeError(`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`);
3909
+ }
3205
3910
  }
3206
3911
  if (snapshot.mode === 'chat')
3207
3912
  return;
3208
- const activePlaybooks = new Set();
3209
- const activeSessionIds = new Set([snapshot.captain.sessionId]);
3210
- const issuedIds = new Set(snapshot.issuedSessionIds);
3211
- const allowedHostPlayerIds = new Set();
3212
- for (const enablement of enabled.values()) {
3213
- for (const role of enablement.entry.requiredRoleIds) {
3214
- allowedHostPlayerIds.add(enablement.hostPlayerId(role));
3215
- }
3216
- }
3217
- for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
3218
- if (!allowedHostPlayerIds.has(playerId)) {
3219
- throw new TypeError(`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`);
3220
- }
3221
- }
3222
- const bindingMaps = [];
3223
- const rootSessionId = snapshot.frames[0].sessionId;
3224
- for (const [index, frame] of snapshot.frames.entries()) {
3913
+ for (const frame of snapshot.frames) {
3225
3914
  const enablement = enabled.get(frame.playbookId);
3226
3915
  if (!enablement) {
3227
3916
  throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
3228
3917
  }
3229
- if (activePlaybooks.has(frame.playbookId)) {
3230
- throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
3231
- }
3232
- activePlaybooks.add(frame.playbookId);
3233
- if (activeSessionIds.has(frame.sessionId)) {
3234
- throw new TypeError('Captain shell snapshot frame session ids must be unique');
3235
- }
3236
- activeSessionIds.add(frame.sessionId);
3237
- if (!issuedIds.has(frame.sessionId)) {
3238
- throw new TypeError('Captain shell snapshot frame session id was not historically issued');
3239
- }
3240
- if (frame.depth !== index ||
3241
- frame.rootSessionId !== rootSessionId ||
3242
- frame.runtime.state.status !== 'active' ||
3243
- !frame.runtime.state.quiescent) {
3244
- throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
3245
- }
3246
- if (index === 0) {
3247
- if (frame.sessionId !== frame.rootSessionId ||
3248
- frame.parentSessionId !== undefined ||
3249
- frame.parentCallId !== undefined) {
3250
- throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
3251
- }
3252
- }
3253
- else {
3254
- const parent = snapshot.frames[index - 1];
3255
- if (frame.parentSessionId !== parent.sessionId ||
3256
- frame.parentCallId === undefined) {
3257
- throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
3258
- }
3259
- const pending = parent.runtime.suspendedCall;
3260
- if (!pending ||
3261
- pending.callId !== frame.parentCallId ||
3262
- pending.playbookId !== frame.playbookId ||
3263
- pending.childSessionId !== frame.sessionId) {
3264
- throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
3265
- }
3266
- }
3267
- const roleBindings = new Map();
3268
- for (const role of enablement.entry.requiredRoleIds) {
3269
- let inherited;
3270
- for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
3271
- inherited = bindingMaps[ancestor]?.get(role);
3272
- if (inherited !== undefined)
3273
- break;
3274
- }
3275
- roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
3918
+ const configuredBindings = Object.fromEntries([...enablement.roleBindings].map(([role, binding]) => [
3919
+ role,
3920
+ binding.playerId,
3921
+ ]));
3922
+ if (!isDeepStrictEqual(frame.options, enablement.options)) {
3923
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`);
3276
3924
  }
3277
- bindingMaps.push(roleBindings);
3278
- const projectedTokens = Object.fromEntries([...roleBindings].flatMap(([role, hostPlayerId]) => {
3279
- const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
3280
- return token === undefined ? [] : [[role, token]];
3281
- }));
3282
- if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
3283
- throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`);
3925
+ if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
3926
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
3284
3927
  }
3285
3928
  }
3286
- const leafRuntime = snapshot.frames.at(-1).runtime;
3287
- if (leafRuntime.suspendedCall !== undefined ||
3288
- !leafRuntime.state.tags.includes('playbook.parked')) {
3289
- throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
3290
- }
3291
3929
  };
3292
3930
  const safeCapturePoint = () => {
3293
3931
  if (lifecycle !== 'ready' ||
@@ -3305,6 +3943,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3305
3943
  runFailureFacts !== undefined ||
3306
3944
  servingCall !== undefined ||
3307
3945
  decisionCall !== undefined ||
3946
+ playerTransactions.size !== 0 ||
3308
3947
  captainQueue.pending !== 0 ||
3309
3948
  captainQueue.size !== 0 ||
3310
3949
  (mode !== 'chat' && mode !== 'engaged.parked')) {
@@ -3341,7 +3980,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3341
3980
  });
3342
3981
  };
3343
3982
  const exportShellSnapshot = () => {
3344
- if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
3983
+ if (!safeCapturePoint() ||
3984
+ !captainRuntime ||
3985
+ !captainSessionId ||
3986
+ !captainAgent) {
3345
3987
  return undefined;
3346
3988
  }
3347
3989
  try {
@@ -3374,16 +4016,23 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3374
4016
  parentCallId: frame.parent.callId,
3375
4017
  }
3376
4018
  : {}),
4019
+ options: frame.enablement.options,
4020
+ roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
4021
+ role,
4022
+ binding.playerId,
4023
+ ])),
3377
4024
  runtime,
3378
4025
  });
3379
4026
  }
3380
4027
  const common = {
3381
- schemaVersion: 1,
4028
+ schemaVersion: 3,
3382
4029
  captain: {
3383
4030
  sessionId: captainSessionId,
3384
4031
  runtime: captainSnapshot,
4032
+ agent: fixedAgent(captainAgent),
3385
4033
  conversation,
3386
4034
  },
4035
+ playerSessions: playerLedgerRecord(),
3387
4036
  issuedSessionIds: [...issuedSessionIds],
3388
4037
  sequences: { turn: turnSequence, journal: journalSeq },
3389
4038
  journal,
@@ -3398,7 +4047,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3398
4047
  ...common,
3399
4048
  mode: 'engaged.parked',
3400
4049
  frames: frameSnapshots,
3401
- rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
3402
4050
  ...(pendingBossQuestions === undefined
3403
4051
  ? {}
3404
4052
  : { pendingBossQuestions: pendingBossQuestions }),
@@ -3420,7 +4068,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3420
4068
  const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
3421
4069
  for (const key of [
3422
4070
  'state',
3423
- 'playerResumeTokens',
4071
+ 'roleResumeTokens',
3424
4072
  'sequences',
3425
4073
  'pendingBossQuestions',
3426
4074
  'suspendedCall',
@@ -3458,7 +4106,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3458
4106
  byCommand = new Map();
3459
4107
  byId = new Map();
3460
4108
  enablementById = new Map();
3461
- players = [];
4109
+ captainAgent = undefined;
4110
+ captainAdapter = undefined;
4111
+ playerAgents = new Map();
4112
+ playerLedger.clear();
4113
+ playerTransactions.clear();
3462
4114
  session = undefined;
3463
4115
  sessionEmissionsOpen = false;
3464
4116
  closedGateAttempted = false;
@@ -3492,17 +4144,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3492
4144
  lifecycle = 'restoring';
3493
4145
  try {
3494
4146
  const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
3495
- const built = await buildEnablements(options, initSession.players, loadModule);
3496
- for (const enablement of built.enablementById.values()) {
3497
- enablement.entry.validateOptions(enablement.optionInput);
3498
- }
4147
+ const built = await buildEnablements(options, loadModule);
4148
+ captainAgent = built.captainAgent;
4149
+ captainAdapter = captainAgent.adapter;
4150
+ playerAgents = built.playerAgents;
3499
4151
  assertSnapshotMatchesEnablements(snapshot, built.enablementById);
3500
4152
  installSession(initSession, false);
3501
- players = initSession.players;
3502
4153
  entries = built.entries;
3503
4154
  byCommand = built.byCommand;
3504
4155
  byId = built.byId;
3505
4156
  enablementById = built.enablementById;
4157
+ for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
4158
+ playerLedger.set(playerId, {
4159
+ adapter: saved.adapter,
4160
+ ...(saved.instruction === undefined
4161
+ ? {}
4162
+ : { instruction: saved.instruction }),
4163
+ ...(saved.permissions === undefined
4164
+ ? {}
4165
+ : { permissions: livePermissions(saved.permissions) }),
4166
+ ...(saved.resumeToken === undefined
4167
+ ? {}
4168
+ : { resumeToken: saved.resumeToken }),
4169
+ });
4170
+ }
3506
4171
  captainRuntime = createCaptainRuntime({
3507
4172
  enabledPlaybooks: enabledCatalog(),
3508
4173
  controller,
@@ -3511,10 +4176,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3511
4176
  throw new Error('session Captain runtime does not support restore');
3512
4177
  }
3513
4178
  if (snapshot.mode === 'engaged.parked') {
3514
- const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
3515
4179
  for (const [index, frameSnapshot] of snapshot.frames.entries()) {
3516
4180
  const parentFrame = frames.at(-1);
3517
- const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, rootTokens, index === 0
4181
+ const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, index === 0
3518
4182
  ? undefined
3519
4183
  : {
3520
4184
  frame: parentFrame,
@@ -3531,7 +4195,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3531
4195
  await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
3532
4196
  if (snapshot.mode === 'engaged.parked') {
3533
4197
  for (const [index, frame] of frames.entries()) {
3534
- await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
4198
+ restoringPlayerSessionFrame = frame;
4199
+ try {
4200
+ await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
4201
+ }
4202
+ finally {
4203
+ restoringPlayerSessionFrame = undefined;
4204
+ }
3535
4205
  }
3536
4206
  }
3537
4207
  if (closedGateAttempted) {
@@ -3542,8 +4212,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3542
4212
  for (const [index, frame] of frames.entries()) {
3543
4213
  verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
3544
4214
  }
3545
- if (!isDeepStrictEqual(tokenRecord(rootFrame().playerResumeTokens), snapshot.rootPlayerResumeTokens)) {
3546
- throw new Error('restored root-owned player continuation changed during restore');
4215
+ if (!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)) {
4216
+ throw new Error('restored Captain-session player continuation changed during restore');
3547
4217
  }
3548
4218
  }
3549
4219
  if (closedGateAttempted) {
@@ -3589,14 +4259,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3589
4259
  lifecycle = 'initializing';
3590
4260
  try {
3591
4261
  installSession(initSession, true);
3592
- players = initSession.players;
3593
- const built = await buildEnablements(options, players, loadModule);
4262
+ const built = await buildEnablements(options, loadModule);
3594
4263
  entries = built.entries;
3595
4264
  byCommand = built.byCommand;
3596
4265
  byId = built.byId;
3597
4266
  enablementById = built.enablementById;
3598
- for (const enablement of enablementById.values()) {
3599
- enablement.entry.validateOptions(enablement.optionInput);
4267
+ captainAgent = built.captainAgent;
4268
+ captainAdapter = captainAgent.adapter;
4269
+ playerAgents = built.playerAgents;
4270
+ for (const [playerId, agent] of playerAgents) {
4271
+ playerLedger.set(playerId, fixedAgent(agent));
3600
4272
  }
3601
4273
  await setMode('chat', 'init');
3602
4274
  // CAPTAIN-16: the session Captain exists from `init`, outside the
@@ -3638,6 +4310,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3638
4310
  const parsed = resolveCommandTurn(turn.prompt);
3639
4311
  activeTurn = {
3640
4312
  id: ++turnSequence,
4313
+ captainSyncedJournalSeq: journalSeq,
3641
4314
  bossText: turn.prompt,
3642
4315
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
3643
4316
  ...(parsed ? { resolution: parsed.resolution } : {}),
@@ -3645,6 +4318,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3645
4318
  presentationAttempted: false,
3646
4319
  settlementFacts: [],
3647
4320
  effectThrows: new Set(),
4321
+ controlFailures: new Set(),
4322
+ settingsPreflightFailures: new Set(),
3648
4323
  suppliedIdentifiers: new Set(),
3649
4324
  outcomeRecorded: false,
3650
4325
  };
@@ -3663,11 +4338,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3663
4338
  new Error('the session Captain turn failed at its boundary'));
3664
4339
  }
3665
4340
  else if (result.outcome === 'aborted') {
3666
- conversation = { kind: 'needsSeeding' };
4341
+ markConversationUnsynchronized();
3667
4342
  if (activeTurn && !activeTurn.outcomeRecorded) {
3668
4343
  activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
3669
4344
  journalOutcome([...activeTurn.settlementFacts]);
3670
4345
  }
4346
+ if (context.signal.aborted) {
4347
+ throw context.signal.reason;
4348
+ }
3671
4349
  }
3672
4350
  else if (result.outcome !== 'suspended' &&
3673
4351
  !context.signal.aborted &&
@@ -3681,10 +4359,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3681
4359
  }
3682
4360
  catch (error) {
3683
4361
  if (context.signal.aborted) {
3684
- conversation = { kind: 'needsSeeding' };
4362
+ markConversationUnsynchronized();
3685
4363
  throw error;
3686
4364
  }
3687
- const controlFailure = activeTurn?.controlFailure === true;
4365
+ const controlFailure = activeTurn?.controlFailures.has(error) === true;
3688
4366
  await settleTurnFailure(context, error);
3689
4367
  if (activeTurn?.presentationError !== undefined) {
3690
4368
  throw activeTurn.presentationError;
@@ -3696,7 +4374,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3696
4374
  }
3697
4375
  finally {
3698
4376
  if (context.signal.aborted) {
3699
- conversation = { kind: 'needsSeeding' };
4377
+ markConversationUnsynchronized();
3700
4378
  if (activeTurn && !activeTurn.outcomeRecorded) {
3701
4379
  activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
3702
4380
  journalOutcome([...activeTurn.settlementFacts]);
@@ -3750,6 +4428,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3750
4428
  failure ??= error;
3751
4429
  }
3752
4430
  }
4431
+ // Quarantine is session-wide by design. Only terminal teardown may drop
4432
+ // its ownership after every frame host call and the Captain are drained.
4433
+ playerTransactions.clear();
3753
4434
  lifecycle = 'closed';
3754
4435
  if (failure !== undefined)
3755
4436
  throw failure;