@sublang/playbook 7.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -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)
@@ -102,6 +126,34 @@ function stateDigestLine(state, description) {
102
126
  digestLine `status ${state.status}`,
103
127
  ].join('; ');
104
128
  }
129
+ // CAPTAIN-5's mirrored ledger member holds the runtime snapshot's
130
+ // pending-question projection — entries of `{ questionId, asker, question,
131
+ // sourceItem }` — never a raw telemetry payload: the linked runtime's state
132
+ // telemetry carries the singular full-context question, whose extra
133
+ // runtime-internal fields (`resumeStateId`) fail the durable snapshot's
134
+ // leaf-projection equality (CAPTAIN-41) and with it headless settlement.
135
+ function mirroredBossQuestions(value) {
136
+ if (value === undefined || value === null)
137
+ return undefined;
138
+ const entries = Array.isArray(value) ? value : [value];
139
+ return entries.map((entry) => {
140
+ if (typeof entry !== 'object' || entry === null)
141
+ return entry;
142
+ const record = entry;
143
+ const projected = {};
144
+ if (record.questionId !== undefined) {
145
+ projected.questionId = record.questionId;
146
+ }
147
+ if (record.asker !== undefined)
148
+ projected.asker = record.asker;
149
+ if (record.question !== undefined)
150
+ projected.question = record.question;
151
+ if (record.sourceItem !== undefined) {
152
+ projected.sourceItem = record.sourceItem;
153
+ }
154
+ return projected;
155
+ });
156
+ }
105
157
  function pendingQuestionLines(pending) {
106
158
  const list = Array.isArray(pending)
107
159
  ? pending
@@ -127,7 +179,14 @@ function pendingQuestionLines(pending) {
127
179
  : typeof record.id === 'string'
128
180
  ? record.id
129
181
  : undefined;
130
- const player = typeof record.player === 'string' ? record.player : undefined;
182
+ const asker = typeof record.asker === 'object' && record.asker !== null
183
+ ? record.asker
184
+ : undefined;
185
+ const askerLabel = asker?.kind === 'captain'
186
+ ? 'Captain'
187
+ : asker?.kind === 'role' && typeof asker.roleId === 'string'
188
+ ? asker.roleId
189
+ : undefined;
131
190
  const text = typeof record.question === 'string'
132
191
  ? record.question
133
192
  : typeof record.text === 'string'
@@ -137,9 +196,9 @@ function pendingQuestionLines(pending) {
137
196
  // fragment is never handed back to the tag as a value: bounding it a
138
197
  // second time would cut the line at the seam's limit and drop whatever
139
198
  // the shell had already written after the long part.
140
- const asked = player === undefined
199
+ const asked = askerLabel === undefined
141
200
  ? digestLine `${quoteEvidence(text)}`
142
- : digestLine `${quoteEvidence(player)} asks: ${quoteEvidence(text)}`;
201
+ : digestLine `${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
143
202
  const marker = id === undefined ? '' : digestLine `(${quoteEvidence(id)}) `;
144
203
  lines.push(`- ${marker}${asked}`);
145
204
  }
@@ -179,14 +238,20 @@ function renderJournalPayload(payload) {
179
238
  const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
180
239
  return raw ?? 'null';
181
240
  }
182
- function renderReseedDigest(records) {
241
+ function renderJournalDigest(records, heading) {
183
242
  const lines = records.map((record) => `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(record.payload)}`);
184
243
  return [
185
- 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
244
+ heading,
186
245
  'The labeled ControlView and catalog digest blocks outrank conversation memory.',
187
246
  ...(lines.length === 0 ? ['(no earlier turns)'] : lines),
188
247
  ].join('\n');
189
248
  }
249
+ function renderReseedDigest(records) {
250
+ 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.');
251
+ }
252
+ function renderCatchUpDigest(records) {
253
+ return renderJournalDigest(records, 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.');
254
+ }
190
255
  // DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
191
256
  // internal control vocabulary.
192
257
  const CONTROL_VOCABULARY = [
@@ -299,22 +364,31 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
299
364
  return undefined;
300
365
  }
301
366
  // 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
- ]);
367
+ // Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
368
+ // value — including the empty list that expresses tool-free — because their
369
+ // supported provider surfaces cannot enforce one, so requesting it fails
370
+ // every control call before the model is reached. Omitting the option is the
371
+ // only way such an adapter can run a control call at all; its isolation then
372
+ // rests on the authored hidden-judge envelope below rather than on provider
373
+ // enforcement.
374
+ const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
375
+ claude: 'provider-enforced',
376
+ codex: 'prompt-only',
377
+ gemini: 'provider-enforced',
378
+ kimi: 'prompt-only',
379
+ opencode: 'prompt-only',
380
+ };
381
+ function requiresPromptOnlyToolIsolation(captainAdapter) {
382
+ return (Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
383
+ CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[captainAdapter] === 'prompt-only');
384
+ }
311
385
  // The tool half of a control call's options. An empty allowlist means "no
312
386
  // tools available" and is distinct from omission, which grants the adapter's
313
387
  // full native tool surface — so omit only where the empty list would be
314
388
  // refused, and keep requesting enforcement whenever the adapter is unknown.
315
389
  function controlCallToolOptions(captainAdapter) {
316
390
  if (captainAdapter !== undefined &&
317
- ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)) {
391
+ requiresPromptOnlyToolIsolation(captainAdapter)) {
318
392
  return {};
319
393
  }
320
394
  return { allowedTools: [] };
@@ -330,14 +404,6 @@ function forwardedToolOptions(requested, captainAdapter) {
330
404
  return controlCallToolOptions(captainAdapter);
331
405
  return { allowedTools: requested };
332
406
  }
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
407
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
342
408
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
343
409
  // reply call's envelope — the settlement's outcome-report facts verbatim, the
@@ -408,10 +474,28 @@ function isValidRegistryEntry(value) {
408
474
  if (typeof value !== 'object' || value === null)
409
475
  return false;
410
476
  const e = value;
477
+ if (!Array.isArray(e.requiredRoleIds) ||
478
+ e.requiredRoleIds.some((role) => typeof role !== 'string' ||
479
+ !ROLE_ID_PATTERN.test(role) ||
480
+ role === INTERNAL_CAPTAIN_ID) ||
481
+ new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
482
+ !Array.isArray(e.concurrentRoleSets)) {
483
+ return false;
484
+ }
485
+ const roles = new Set(e.requiredRoleIds);
486
+ const concurrency = e.concurrentRoleSets;
487
+ if (concurrency.some((set) => !Array.isArray(set) ||
488
+ set.length < 2 ||
489
+ set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
490
+ new Set(set).size !== set.length) ||
491
+ new Set(concurrency.map((set) => JSON.stringify(set))).size !==
492
+ concurrency.length) {
493
+ return false;
494
+ }
411
495
  return (typeof e.id === 'string' &&
412
496
  typeof e.command === 'string' &&
413
497
  typeof e.intent === 'string' &&
414
- Array.isArray(e.requiredRoleIds) &&
498
+ e.artifactSchema === 2 &&
415
499
  typeof e.validateOptions === 'function' &&
416
500
  typeof e.createRuntime === 'function');
417
501
  }
@@ -468,14 +552,165 @@ function snapshotUuid(value, path) {
468
552
  }
469
553
  return id;
470
554
  }
555
+ function snapshotPermissions(value, path) {
556
+ if (value === undefined)
557
+ return undefined;
558
+ const record = snapshotRecord(value, path);
559
+ rejectSnapshotKeys(record, ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'], path);
560
+ const normalized = {};
561
+ if (record.mode !== undefined) {
562
+ if (record.mode !== 'auto' && record.mode !== 'bypass') {
563
+ throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
564
+ }
565
+ normalized.mode = record.mode;
566
+ }
567
+ for (const key of [
568
+ 'fileWrite',
569
+ 'shellExecute',
570
+ 'networkAccess',
571
+ ]) {
572
+ const level = record[key];
573
+ if (level === undefined)
574
+ continue;
575
+ if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
576
+ throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
577
+ }
578
+ normalized[key] = level;
579
+ }
580
+ if (record.writablePaths !== undefined) {
581
+ if (!Array.isArray(record.writablePaths) ||
582
+ record.writablePaths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
583
+ throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
584
+ }
585
+ normalized.writablePaths = [...record.writablePaths];
586
+ }
587
+ return normalized;
588
+ }
589
+ function livePermissions(value) {
590
+ if (value === undefined)
591
+ return undefined;
592
+ return {
593
+ ...(value.mode === undefined ? {} : { mode: value.mode }),
594
+ ...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
595
+ ...(value.shellExecute === undefined
596
+ ? {}
597
+ : { shellExecute: value.shellExecute }),
598
+ ...(value.networkAccess === undefined
599
+ ? {}
600
+ : { networkAccess: value.networkAccess }),
601
+ ...(value.writablePaths === undefined
602
+ ? {}
603
+ : { writablePaths: [...value.writablePaths] }),
604
+ };
605
+ }
606
+ function snapshotFixedAgent(value, path) {
607
+ const record = snapshotRecord(value, path);
608
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
609
+ const adapter = snapshotString(record.adapter, `${path}.adapter`);
610
+ const instruction = record.instruction === undefined
611
+ ? undefined
612
+ : snapshotString(record.instruction, `${path}.instruction`, true);
613
+ const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
614
+ return {
615
+ adapter,
616
+ ...(instruction === undefined ? {} : { instruction }),
617
+ ...(permissions === undefined ? {} : { permissions }),
618
+ };
619
+ }
620
+ function snapshotPlayerSessions(value, path) {
621
+ const sessions = snapshotRecord(value, path);
622
+ return Object.fromEntries(Object.entries(sessions).map(([playerId, raw]) => {
623
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
624
+ throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
625
+ }
626
+ const record = snapshotRecord(raw, `${path}.${playerId}`);
627
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions', 'resumeToken'], `${path}.${playerId}`);
628
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'resumeToken')), `${path}.${playerId}`);
629
+ const resumeToken = record.resumeToken === undefined
630
+ ? undefined
631
+ : snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
632
+ return [
633
+ playerId,
634
+ { ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
635
+ ];
636
+ }));
637
+ }
638
+ function snapshotFrameRoleBindings(value, path) {
639
+ const bindings = snapshotRecord(value, path);
640
+ return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
641
+ if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
642
+ throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
643
+ }
644
+ const playerId = snapshotString(raw, `${path}.${roleId}`);
645
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
646
+ throw new TypeError(`${path}.${roleId} has invalid player id`);
647
+ }
648
+ return [roleId, playerId];
649
+ }));
650
+ }
651
+ function normalizeHostPlayerResult(value, expectedPlayerId) {
652
+ const path = 'tmux-play delegated-player result';
653
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
654
+ throw new TypeError(`${path} must be an object`);
655
+ }
656
+ const prototype = Object.getPrototypeOf(value);
657
+ if (prototype !== Object.prototype && prototype !== null) {
658
+ throw new TypeError(`${path} must be a plain JSON object`);
659
+ }
660
+ const descriptors = Object.getOwnPropertyDescriptors(value);
661
+ const allowedKeys = new Set([
662
+ 'status',
663
+ 'playerId',
664
+ 'turnId',
665
+ 'resumeToken',
666
+ 'finalText',
667
+ 'error',
668
+ ]);
669
+ const normalized = {};
670
+ for (const key of Reflect.ownKeys(descriptors)) {
671
+ if (typeof key === 'symbol') {
672
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
673
+ }
674
+ const descriptor = descriptors[key];
675
+ if (!allowedKeys.has(key)) {
676
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
677
+ }
678
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
679
+ throw new TypeError(`${path}.${key} must be an enumerable data property`);
680
+ }
681
+ // Cligent deliberately exposes optional result members as own
682
+ // `undefined` data properties. Omit only those members before taking the
683
+ // immutable JSON snapshot; every other value still passes the strict
684
+ // JSON validator below.
685
+ if (descriptor.value !== undefined)
686
+ normalized[key] = descriptor.value;
687
+ }
688
+ const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
689
+ rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
690
+ if (record.playerId !== expectedPlayerId) {
691
+ throw new TypeError(`${path}.playerId does not match the requested player`);
692
+ }
693
+ snapshotInteger(record.turnId, `${path}.turnId`, 1);
694
+ return validatePlayerResult({
695
+ status: record.status,
696
+ ...(record.resumeToken === undefined
697
+ ? {}
698
+ : { resumeToken: record.resumeToken }),
699
+ ...(record.finalText === undefined
700
+ ? {}
701
+ : { finalText: record.finalText }),
702
+ ...(record.error === undefined ? {} : { error: record.error }),
703
+ }, path);
704
+ }
471
705
  /** Validate, detach, and freeze one untrusted shell snapshot. */
472
- function assertPlaybookCaptainShellSnapshot(value) {
706
+ export function assertPlaybookCaptainShellSnapshot(value) {
473
707
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
474
708
  const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
475
709
  const mode = snapshot.mode;
476
710
  const commonKeys = [
477
711
  'schemaVersion',
478
712
  'captain',
713
+ 'playerSessions',
479
714
  'issuedSessionIds',
480
715
  'sequences',
481
716
  'journal',
@@ -490,7 +725,6 @@ function assertPlaybookCaptainShellSnapshot(value) {
490
725
  rejectSnapshotKeys(snapshot, [
491
726
  ...commonKeys,
492
727
  'frames',
493
- 'rootPlayerResumeTokens',
494
728
  'pendingBossQuestions',
495
729
  'lastError',
496
730
  ], 'Captain shell snapshot');
@@ -498,20 +732,31 @@ function assertPlaybookCaptainShellSnapshot(value) {
498
732
  else {
499
733
  throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
500
734
  }
501
- if (snapshot.schemaVersion !== 1) {
502
- throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`);
735
+ if (snapshot.schemaVersion !== 3) {
736
+ throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
503
737
  }
504
738
  const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
505
- rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
739
+ rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
506
740
  const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
507
741
  const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
742
+ const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
508
743
  const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
509
744
  let normalizedConversation;
510
745
  if (conversation.kind === 'pinned') {
511
746
  rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
512
747
  normalizedConversation = {
513
748
  kind: 'pinned',
514
- token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token', true),
749
+ token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'),
750
+ };
751
+ }
752
+ else if (conversation.kind === 'needsCatchUp') {
753
+ rejectSnapshotKeys(conversation, ['kind', 'resume', 'afterJournalSeq'], 'Captain shell snapshot.captain.conversation');
754
+ normalizedConversation = {
755
+ kind: 'needsCatchUp',
756
+ resume: conversation.resume === false
757
+ ? false
758
+ : snapshotString(conversation.resume, 'Captain shell snapshot.captain.conversation.resume'),
759
+ afterJournalSeq: snapshotInteger(conversation.afterJournalSeq, 'Captain shell snapshot.captain.conversation.afterJournalSeq'),
515
760
  };
516
761
  }
517
762
  else if (conversation.kind === 'unopened' ||
@@ -579,6 +824,19 @@ function assertPlaybookCaptainShellSnapshot(value) {
579
824
  bossRecords !== turnSequence) {
580
825
  throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
581
826
  }
827
+ const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
828
+ if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
829
+ throw new TypeError('Captain shell snapshot history is empty exactly when its conversation is unopened');
830
+ }
831
+ if (normalizedConversation.kind === 'needsCatchUp' &&
832
+ normalizedConversation.afterJournalSeq >= journalSequence) {
833
+ throw new TypeError('Captain shell snapshot catch-up watermark must precede the current journal sequence');
834
+ }
835
+ if (normalizedConversation.kind === 'needsCatchUp' &&
836
+ ((normalizedConversation.resume === false) !==
837
+ (normalizedConversation.afterJournalSeq === 0))) {
838
+ throw new TypeError('Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero');
839
+ }
582
840
  let lastAction;
583
841
  if (snapshot.lastAction !== undefined) {
584
842
  if (typeof snapshot.lastAction !== 'string' ||
@@ -595,13 +853,16 @@ function assertPlaybookCaptainShellSnapshot(value) {
595
853
  }
596
854
  lastSettlementStatus = snapshot.lastSettlementStatus;
597
855
  }
856
+ const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
598
857
  const common = {
599
- schemaVersion: 1,
858
+ schemaVersion: 3,
600
859
  captain: {
601
860
  sessionId: captainSessionId,
602
861
  runtime: captainRuntime,
862
+ agent: captainAgent,
603
863
  conversation: normalizedConversation,
604
864
  },
865
+ playerSessions,
605
866
  issuedSessionIds: issued,
606
867
  sequences: { turn: turnSequence, journal: journalSequence },
607
868
  journal: normalizedJournal,
@@ -610,6 +871,17 @@ function assertPlaybookCaptainShellSnapshot(value) {
610
871
  ? {}
611
872
  : { lastSettlementStatus }),
612
873
  };
874
+ if (captainRuntime.state.status !== 'active' ||
875
+ !captainRuntime.state.quiescent ||
876
+ !captainRuntime.state.tags.includes('playbook.parked') ||
877
+ captainRuntime.suspendedCall !== undefined ||
878
+ Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
879
+ captainRuntime.pendingBossQuestions.length > 0) {
880
+ throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
881
+ }
882
+ if (captainRuntime.sequences.turn !== turnSequence) {
883
+ throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
884
+ }
613
885
  if (mode === 'chat') {
614
886
  return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
615
887
  }
@@ -626,6 +898,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
626
898
  'depth',
627
899
  'parentSessionId',
628
900
  'parentCallId',
901
+ 'options',
902
+ 'roleBindings',
629
903
  'runtime',
630
904
  ], `Captain shell snapshot.frames[${index}]`);
631
905
  const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
@@ -639,6 +913,8 @@ function assertPlaybookCaptainShellSnapshot(value) {
639
913
  ? undefined
640
914
  : snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
641
915
  const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
916
+ const options = frame.options;
917
+ const roleBindings = snapshotFrameRoleBindings(frame.roleBindings, `Captain shell snapshot.frames[${index}].roleBindings`);
642
918
  normalizedFrames.push({
643
919
  playbookId,
644
920
  sessionId,
@@ -646,14 +922,11 @@ function assertPlaybookCaptainShellSnapshot(value) {
646
922
  depth,
647
923
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
648
924
  ...(parentCallId === undefined ? {} : { parentCallId }),
925
+ options,
926
+ roleBindings,
649
927
  runtime,
650
928
  });
651
929
  }
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
930
  let normalizedLastError;
658
931
  if (snapshot.lastError !== undefined) {
659
932
  const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
@@ -663,11 +936,80 @@ function assertPlaybookCaptainShellSnapshot(value) {
663
936
  message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
664
937
  };
665
938
  }
939
+ const activePlaybooks = new Set();
940
+ const activeSessionIds = new Set([captainSessionId]);
941
+ const issuedIds = new Set(issued);
942
+ const rootSessionId = normalizedFrames[0].sessionId;
943
+ for (const [index, frame] of normalizedFrames.entries()) {
944
+ if (activePlaybooks.has(frame.playbookId)) {
945
+ throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
946
+ }
947
+ activePlaybooks.add(frame.playbookId);
948
+ if (activeSessionIds.has(frame.sessionId)) {
949
+ throw new TypeError('Captain shell snapshot frame session ids must be unique');
950
+ }
951
+ activeSessionIds.add(frame.sessionId);
952
+ if (!issuedIds.has(frame.sessionId)) {
953
+ throw new TypeError('Captain shell snapshot frame session id was not historically issued');
954
+ }
955
+ if (frame.depth !== index ||
956
+ frame.rootSessionId !== rootSessionId ||
957
+ frame.runtime.state.status !== 'active' ||
958
+ !frame.runtime.state.quiescent) {
959
+ throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
960
+ }
961
+ if (index === 0) {
962
+ if (frame.sessionId !== frame.rootSessionId ||
963
+ frame.parentSessionId !== undefined ||
964
+ frame.parentCallId !== undefined) {
965
+ throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
966
+ }
967
+ }
968
+ else {
969
+ const parent = normalizedFrames[index - 1];
970
+ const pending = parent.runtime.suspendedCall;
971
+ if (frame.parentSessionId !== parent.sessionId ||
972
+ frame.parentCallId === undefined) {
973
+ throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
974
+ }
975
+ if (!pending ||
976
+ pending.callId !== frame.parentCallId ||
977
+ pending.playbookId !== frame.playbookId ||
978
+ pending.childSessionId !== frame.sessionId) {
979
+ throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
980
+ }
981
+ }
982
+ for (const playerId of Object.values(frame.roleBindings)) {
983
+ if (playerSessions[playerId] === undefined) {
984
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`);
985
+ }
986
+ }
987
+ for (const question of frame.runtime.pendingBossQuestions) {
988
+ if (question.asker.kind === 'role' &&
989
+ frame.roleBindings[question.asker.roleId] === undefined) {
990
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`);
991
+ }
992
+ }
993
+ const projectedTokens = Object.fromEntries(Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
994
+ const token = playerSessions[playerId]?.resumeToken;
995
+ return token === undefined ? [] : [[role, token]];
996
+ }));
997
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
998
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`);
999
+ }
1000
+ }
1001
+ const leafRuntime = normalizedFrames.at(-1).runtime;
1002
+ if (leafRuntime.suspendedCall !== undefined ||
1003
+ !leafRuntime.state.tags.includes('playbook.parked')) {
1004
+ throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
1005
+ }
1006
+ if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
1007
+ throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
1008
+ }
666
1009
  return snapshotJsonValue({
667
1010
  ...common,
668
1011
  mode,
669
1012
  frames: normalizedFrames,
670
- rootPlayerResumeTokens: normalizedRootTokens,
671
1013
  ...(snapshot.pendingBossQuestions === undefined
672
1014
  ? {}
673
1015
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -676,27 +1018,109 @@ function assertPlaybookCaptainShellSnapshot(value) {
676
1018
  : { lastError: normalizedLastError }),
677
1019
  }, 'Captain shell snapshot');
678
1020
  }
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;
1021
+ function snapshotTuningSelection(value, path) {
1022
+ const selection = snapshotRecord(value, path);
1023
+ if (selection.kind === 'provider-default') {
1024
+ rejectSnapshotKeys(selection, ['kind'], path);
1025
+ return { kind: 'provider-default' };
685
1026
  }
686
- return pb;
1027
+ if (selection.kind === 'value') {
1028
+ rejectSnapshotKeys(selection, ['kind', 'value'], path);
1029
+ return {
1030
+ kind: 'value',
1031
+ value: snapshotString(selection.value, `${path}.value`),
1032
+ };
1033
+ }
1034
+ throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
1035
+ }
1036
+ const EFFORT_VALUES = new Set([
1037
+ 'on',
1038
+ 'minimal',
1039
+ 'low',
1040
+ 'medium',
1041
+ 'high',
1042
+ 'xhigh',
1043
+ 'max',
1044
+ 'ultra',
1045
+ 'ultracode',
1046
+ 'off',
1047
+ ]);
1048
+ function snapshotEffortSelection(value, path) {
1049
+ const selection = snapshotTuningSelection(value, path);
1050
+ if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
1051
+ throw new TypeError(`${path}.value is not a supported effort selection`);
1052
+ }
1053
+ return selection;
1054
+ }
1055
+ function snapshotSessionAgent(value, path) {
1056
+ const agent = snapshotRecord(value, path);
1057
+ rejectSnapshotKeys(agent, ['adapter', 'model', 'effort', 'instruction', 'permissions'], path);
1058
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' && key !== 'effort')), path);
1059
+ return {
1060
+ adapter: fixed.adapter,
1061
+ ...(fixed.instruction === undefined
1062
+ ? {}
1063
+ : { instruction: fixed.instruction }),
1064
+ ...(fixed.permissions === undefined
1065
+ ? {}
1066
+ : { permissions: livePermissions(fixed.permissions) }),
1067
+ model: snapshotTuningSelection(agent.model, `${path}.model`),
1068
+ effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
1069
+ };
1070
+ }
1071
+ function fixedAgent(agent) {
1072
+ return {
1073
+ adapter: agent.adapter,
1074
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1075
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1076
+ };
1077
+ }
1078
+ function callSettings(agent, tuning = agent) {
1079
+ return {
1080
+ model: tuning.model,
1081
+ effort: tuning.effort,
1082
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1083
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1084
+ };
1085
+ }
1086
+ function promptIdentity(binding) {
1087
+ return binding.model.kind === 'value'
1088
+ ? binding.model.value
1089
+ : binding.agent.adapter;
687
1090
  }
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) {
1091
+ // Resolve the active registry at init from exact normalized role and session
1092
+ // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1093
+ // exists at this boundary.
1094
+ async function buildEnablements(options, loadModule) {
692
1095
  const entries = [];
693
1096
  const byCommand = new Map();
694
1097
  const byId = new Map();
695
1098
  const enablementById = new Map();
696
- const config = readPlaybooksConfig(options);
697
- if (config === undefined) {
1099
+ const detached = snapshotJsonValue(options, 'captain.options');
1100
+ const top = snapshotRecord(detached, 'captain.options');
1101
+ rejectSnapshotKeys(top, ['playbooks', 'sessionAgents', 'captainAdapter'], 'captain.options');
1102
+ const configValue = top.playbooks;
1103
+ if (typeof configValue !== 'object' ||
1104
+ configValue === null ||
1105
+ Array.isArray(configValue)) {
698
1106
  throw new Error('captain.options.playbooks is required');
699
1107
  }
1108
+ const config = configValue;
1109
+ const sessionAgents = snapshotRecord(top.sessionAgents, 'captain.options.sessionAgents');
1110
+ rejectSnapshotKeys(sessionAgents, ['captain', 'players'], 'captain.options.sessionAgents');
1111
+ const captainAgent = snapshotSessionAgent(sessionAgents.captain, 'captain.options.sessionAgents.captain');
1112
+ if (top.captainAdapter !== undefined &&
1113
+ top.captainAdapter !== captainAgent.adapter) {
1114
+ throw new Error('captain.options.captainAdapter must equal sessionAgents.captain.adapter');
1115
+ }
1116
+ const playerAgentRecord = snapshotRecord(sessionAgents.players, 'captain.options.sessionAgents.players');
1117
+ const playerAgents = new Map();
1118
+ for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
1119
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1120
+ throw new Error(`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`);
1121
+ }
1122
+ playerAgents.set(playerId, snapshotSessionAgent(agent, `captain.options.sessionAgents.players.${playerId}`));
1123
+ }
700
1124
  const ids = Object.keys(config);
701
1125
  if (ids.length === 0) {
702
1126
  throw new Error('captain.options.playbooks must enable at least one playbook');
@@ -710,6 +1134,7 @@ async function buildEnablements(options, players, loadModule) {
710
1134
  throw new Error(`captain.options.playbooks.${id} must be an object`);
711
1135
  }
712
1136
  const record = block;
1137
+ rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
713
1138
  const from = record.from;
714
1139
  if (typeof from !== 'string' || from.length === 0) {
715
1140
  throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
@@ -740,35 +1165,74 @@ async function buildEnablements(options, players, loadModule) {
740
1165
  if (byCommand.has(command)) {
741
1166
  throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
742
1167
  }
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
- });
1168
+ const roleRecord = snapshotRecord(record.roles, `captain.options.playbooks.${id}.roles`);
1169
+ const required = new Set(entry.requiredRoleIds);
1170
+ const configuredRoles = Object.keys(roleRecord);
1171
+ const missing = entry.requiredRoleIds.filter((role) => !Object.hasOwn(roleRecord, role));
1172
+ const extra = configuredRoles.filter((role) => !required.has(role));
1173
+ if (missing.length > 0 || extra.length > 0) {
1174
+ throw new Error(`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`);
1175
+ }
1176
+ const roleBindings = new Map();
1177
+ for (const role of entry.requiredRoleIds) {
1178
+ const path = `captain.options.playbooks.${id}.roles.${role}`;
1179
+ const rawBinding = snapshotRecord(roleRecord[role], path);
1180
+ rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
1181
+ const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
1182
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1183
+ throw new Error(`${path}.playerId is not a canonical player id`);
1184
+ }
1185
+ const agent = playerAgents.get(playerId);
1186
+ if (!agent) {
1187
+ throw new Error(`${path}.playerId names absent session player ${JSON.stringify(playerId)}`);
1188
+ }
1189
+ roleBindings.set(role, {
1190
+ playerId,
1191
+ model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
1192
+ effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
1193
+ agent,
1194
+ });
1195
+ }
1196
+ for (const concurrentRoles of entry.concurrentRoleSets) {
1197
+ const playerIds = concurrentRoles.map((role) => roleBindings.get(role).playerId);
1198
+ if (new Set(playerIds).size !== playerIds.length) {
1199
+ throw new Error(`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`);
1200
+ }
1201
+ }
1202
+ const validatedOptions = snapshotJsonValue(entry.validateOptions(record.options), `captain.options.playbooks.${id}.options`);
751
1203
  entries.push(entry);
752
1204
  byId.set(entry.id, entry);
753
1205
  byCommand.set(command, entry);
754
1206
  enablementById.set(entry.id, {
755
1207
  entry,
756
1208
  command,
757
- optionInput: record.options,
758
- boundPlayers,
759
- hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
1209
+ options: validatedOptions,
1210
+ roleBindings,
760
1211
  });
761
1212
  }
762
- return { entries, byCommand, byId, enablementById };
1213
+ const referenced = new Set([...enablementById.values()].flatMap((enablement) => [...enablement.roleBindings.values()].map((binding) => binding.playerId)));
1214
+ const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
1215
+ if (unreferenced !== undefined) {
1216
+ throw new Error(`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`);
1217
+ }
1218
+ return {
1219
+ entries,
1220
+ byCommand,
1221
+ byId,
1222
+ enablementById,
1223
+ captainAgent,
1224
+ playerAgents,
1225
+ };
763
1226
  }
764
1227
  export function createPlaybookCaptainShell(options, deps = {}) {
765
1228
  const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
766
1229
  const createSessionId = deps.createSessionId ?? randomUUID;
767
1230
  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);
1231
+ let captainAgent;
1232
+ let captainAdapter;
1233
+ let playerAgents = new Map();
1234
+ const playerLedger = new Map();
1235
+ const playerTransactions = new Map();
772
1236
  let entries = [];
773
1237
  let byCommand = new Map();
774
1238
  let byId = new Map();
@@ -778,7 +1242,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
778
1242
  let closedGateAttempted = false;
779
1243
  let lifecycle = 'fresh';
780
1244
  let terminallyDisposed = false;
781
- let players = [];
782
1245
  let activeContext;
783
1246
  const frames = [];
784
1247
  let mode = 'chat';
@@ -828,15 +1291,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
828
1291
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
829
1292
  let captainRuntime;
830
1293
  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.
1294
+ // CAPTAIN-35: a preflight settings rejection retains proven continuity but
1295
+ // records the exact journal suffix still owed; other continuity failures
1296
+ // require a fresh, full reseed.
834
1297
  let conversation = { kind: 'unopened' };
835
1298
  let shuttingDown = false;
836
1299
  const journal = [];
837
1300
  let journalSeq = 0;
838
1301
  let turnSequence = 0;
839
1302
  let activeTurn;
1303
+ // `PlayerSessionStore.restore` is authoritative only while the shell is
1304
+ // awaiting the exact owning runtime's restore during a closed-gate shell
1305
+ // restoration. A runtime cannot use the store as a general ledger writer.
1306
+ let restoringPlayerSessionFrame;
840
1307
  // The durable call the runtime is about to make, taken from the paired
841
1308
  // `captain.call.started` boundary the engine emits before the port call
842
1309
  // (CAPTAIN-9): the shell never infers a call's kind from its prose.
@@ -904,7 +1371,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
904
1371
  // (CAPTAIN-5/CAPTAIN-6).
905
1372
  ...(captainRuntime
906
1373
  ? {
907
- durableConversation: conversation.kind === 'pinned',
1374
+ durableConversation: conversation.kind === 'pinned' ||
1375
+ (conversation.kind === 'needsCatchUp' &&
1376
+ conversation.resume !== false),
908
1377
  sessionJournal: true,
909
1378
  }
910
1379
  : {}),
@@ -1050,8 +1519,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1050
1519
  }
1051
1520
  }
1052
1521
  if (leafFrame() === frame) {
1053
- pendingBossQuestions =
1054
- record.pendingBossQuestions ?? record.pendingBossQuestion;
1522
+ pendingBossQuestions = mirroredBossQuestions(record.pendingBossQuestions ?? record.pendingBossQuestion);
1055
1523
  lastError = normalizeErrorCompact(record.lastError);
1056
1524
  if (state.quiescent && state.tags.includes('playbook.parked')) {
1057
1525
  await setMode('engaged.parked', `sub-runtime:${state.stateId ?? 'structured'}`);
@@ -1060,39 +1528,130 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1060
1528
  };
1061
1529
  let callNestedPlaybook;
1062
1530
  const createPorts = (frame) => ({
1063
- callPlayer: async (playerId, prompt, signal, options) => {
1531
+ callPlayer: async (roleId, prompt, signal, options) => {
1064
1532
  admitHostBoundary();
1065
- if (!activeContext) {
1533
+ if (!activeContext || !activeTurn || !frame.playerCallScope) {
1066
1534
  throw new Error('callPlayer invoked outside a Boss turn');
1067
1535
  }
1068
1536
  const context = activeContext;
1537
+ const admittedTurn = activeTurn;
1538
+ const scope = frame.playerCallScope;
1069
1539
  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++;
1540
+ const binding = bindingFor(frame, roleId);
1541
+ const ledger = playerLedger.get(binding.playerId);
1542
+ if (!ledger) {
1543
+ throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
1085
1544
  }
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 } : {}),
1545
+ const expectedResume = ledger.resumeToken ?? false;
1546
+ if (options.resume !== expectedResume) {
1547
+ throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
1548
+ }
1549
+ if (playerTransactions.has(binding.playerId)) {
1550
+ throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
1551
+ }
1552
+ const settings = callSettings(binding.agent, binding);
1553
+ const calling = {
1554
+ phase: 'calling',
1555
+ frame,
1556
+ roleId,
1557
+ turnId: admittedTurn.id,
1558
+ signal,
1559
+ scope,
1560
+ abandoned: false,
1095
1561
  };
1562
+ playerTransactions.set(binding.playerId, calling);
1563
+ let result;
1564
+ let hostResolved = false;
1565
+ try {
1566
+ let rawResult;
1567
+ try {
1568
+ rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
1569
+ resume: options.resume,
1570
+ settings,
1571
+ })));
1572
+ hostResolved = true;
1573
+ }
1574
+ catch (error) {
1575
+ if (error instanceof AgentSettingsPreflightError) {
1576
+ if (playerTransactions.get(binding.playerId) !== calling ||
1577
+ calling.abandoned ||
1578
+ signal.aborted ||
1579
+ activeTurn !== admittedTurn ||
1580
+ frame.playerCallScope !== scope ||
1581
+ !frames.includes(frame)) {
1582
+ if (playerTransactions.get(binding.playerId) === calling) {
1583
+ playerTransactions.delete(binding.playerId);
1584
+ }
1585
+ signal.throwIfAborted();
1586
+ throw new Error(`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`);
1587
+ }
1588
+ throw rememberSettingsPreflight(error.rejection);
1589
+ }
1590
+ throw error;
1591
+ }
1592
+ result = normalizeHostPlayerResult(rawResult, binding.playerId);
1593
+ const transitionRequired = result.resumeToken !== undefined || result.status === 'ok';
1594
+ if (playerTransactions.get(binding.playerId) !== calling ||
1595
+ calling.abandoned ||
1596
+ signal.aborted ||
1597
+ activeTurn !== admittedTurn ||
1598
+ frame.playerCallScope !== scope ||
1599
+ !frames.includes(frame)) {
1600
+ if (playerTransactions.get(binding.playerId) === calling) {
1601
+ if (transitionRequired) {
1602
+ playerTransactions.set(binding.playerId, {
1603
+ phase: 'quarantined',
1604
+ frame,
1605
+ roleId,
1606
+ turnId: admittedTurn.id,
1607
+ signal,
1608
+ scope,
1609
+ reason: 'a transition-worthy result arrived after its runtime operation ended',
1610
+ });
1611
+ }
1612
+ else {
1613
+ playerTransactions.delete(binding.playerId);
1614
+ }
1615
+ }
1616
+ signal.throwIfAborted();
1617
+ throw new Error(`${frameLabel(frame)} player result arrived after its runtime operation ended`);
1618
+ }
1619
+ if (transitionRequired) {
1620
+ playerTransactions.set(binding.playerId, {
1621
+ phase: 'awaitingCommit',
1622
+ frame,
1623
+ roleId,
1624
+ turnId: admittedTurn.id,
1625
+ signal,
1626
+ scope,
1627
+ status: result.status,
1628
+ expectedToken: result.resumeToken,
1629
+ });
1630
+ }
1631
+ else {
1632
+ playerTransactions.delete(binding.playerId);
1633
+ }
1634
+ }
1635
+ catch (error) {
1636
+ if (playerTransactions.get(binding.playerId) === calling) {
1637
+ if (hostResolved) {
1638
+ playerTransactions.set(binding.playerId, {
1639
+ phase: 'quarantined',
1640
+ frame,
1641
+ roleId,
1642
+ turnId: admittedTurn.id,
1643
+ signal,
1644
+ scope,
1645
+ reason: 'a late player result could not be validated',
1646
+ });
1647
+ }
1648
+ else {
1649
+ playerTransactions.delete(binding.playerId);
1650
+ }
1651
+ }
1652
+ throw error;
1653
+ }
1654
+ return result;
1096
1655
  },
1097
1656
  callCaptain: async (prompt, signal, options) => {
1098
1657
  admitHostBoundary();
@@ -1173,12 +1732,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1173
1732
  },
1174
1733
  });
1175
1734
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
1176
- // visibility for that playbook's generated host players. A pane
1735
+ // visibility for that playbook's explicitly bound session players. A pane
1177
1736
  // reconciliation failure is display-only in tmux-play and does not
1178
1737
  // reject; the legacy path carries no generated set and skips this.
1179
1738
  const requestVisibility = async (frame) => {
1180
- const ids = [...new Set([...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId))];
1181
- if (!ids || ids.length === 0 || !activeContext)
1739
+ const ids = [...new Set([...frame.playerBindings.values()].map(({ playerId }) => playerId))];
1740
+ // A roleless frame does not ask a non-empty host roster to show `[]`:
1741
+ // tmux-play reserves that value for a genuinely empty configured roster.
1742
+ if (ids.length === 0 || !activeContext)
1182
1743
  return;
1183
1744
  try {
1184
1745
  await activeContext.setVisiblePlayers(ids);
@@ -1210,39 +1771,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1210
1771
  : undefined;
1211
1772
  return typeof stack === 'string' ? { ...compact, stack } : compact;
1212
1773
  };
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;
1774
+ const makePlayerBindings = (enablement) => {
1775
+ return new Map(enablement.roleBindings);
1232
1776
  };
1233
1777
  const makeFrame = (enablement, parent) => {
1234
1778
  const entry = enablement.entry;
1235
1779
  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
- });
1780
+ const playerBindings = makePlayerBindings(enablement);
1781
+ const runtime = entry.createRuntime(enablement.options);
1246
1782
  return {
1247
1783
  entry,
1248
1784
  enablement,
@@ -1251,22 +1787,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1251
1787
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
1252
1788
  depth: parent ? parent.frame.depth + 1 : 0,
1253
1789
  playerBindings,
1254
- playerResumeTokens,
1255
1790
  ...(parent ? { parent } : {}),
1256
1791
  inFlightHostCalls: new Set(),
1257
1792
  };
1258
1793
  };
1259
- const makeRestoredFrame = (enablement, snapshot, rootPlayerResumeTokens, parent) => {
1794
+ const makeRestoredFrame = (enablement, snapshot, parent) => {
1260
1795
  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
- });
1796
+ const playerBindings = makePlayerBindings(enablement);
1797
+ const runtime = entry.createRuntime(enablement.options);
1270
1798
  return {
1271
1799
  entry,
1272
1800
  enablement,
@@ -1275,7 +1803,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1275
1803
  rootSessionId: snapshot.rootSessionId,
1276
1804
  depth: snapshot.depth,
1277
1805
  playerBindings,
1278
- playerResumeTokens: rootPlayerResumeTokens,
1279
1806
  ...(parent ? { parent } : {}),
1280
1807
  state: snapshot.runtime.state,
1281
1808
  inFlightHostCalls: new Set(),
@@ -1284,36 +1811,141 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1284
1811
  const playerSessionStore = (frame) => ({
1285
1812
  select(playerId) {
1286
1813
  const binding = bindingFor(frame, playerId);
1287
- return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
1814
+ return playerLedger.get(binding.playerId)?.resumeToken ?? false;
1288
1815
  },
1289
1816
  update(playerId, resumeToken) {
1290
1817
  const binding = bindingFor(frame, playerId);
1291
- if (resumeToken === undefined) {
1292
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1818
+ const ledger = playerLedger.get(binding.playerId);
1819
+ if (!ledger) {
1820
+ throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
1293
1821
  }
1294
- else {
1295
- frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
1822
+ const pending = playerTransactions.get(binding.playerId);
1823
+ if (pending?.phase !== 'awaitingCommit' ||
1824
+ pending.frame !== frame ||
1825
+ pending.roleId !== playerId ||
1826
+ pending.scope !== frame.playerCallScope ||
1827
+ pending.expectedToken !== resumeToken) {
1828
+ throw new Error(`${frameLabel(frame)} player update does not acknowledge a validated host result`);
1829
+ }
1830
+ if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
1831
+ playerTransactions.set(binding.playerId, {
1832
+ phase: 'quarantined',
1833
+ frame: pending.frame,
1834
+ roleId: pending.roleId,
1835
+ turnId: pending.turnId,
1836
+ signal: pending.signal,
1837
+ scope: pending.scope,
1838
+ reason: 'the runtime aborted before committing a validated result',
1839
+ });
1840
+ throw new Error(`${frameLabel(frame)} rejected a late or aborted player continuation update`);
1841
+ }
1842
+ try {
1843
+ if (resumeToken === undefined)
1844
+ delete ledger.resumeToken;
1845
+ else
1846
+ ledger.resumeToken = resumeToken;
1847
+ // CAPTAIN-20: a result counts only after the runtime validated it and
1848
+ // atomically published its authorized continuation transition.
1849
+ const summary = activeTurnSummary;
1850
+ if (pending.status === 'ok' &&
1851
+ summary &&
1852
+ summaryIncludes(frame)) {
1853
+ summary.counts.interruptions++;
1854
+ }
1855
+ }
1856
+ finally {
1857
+ playerTransactions.delete(binding.playerId);
1296
1858
  }
1297
1859
  },
1298
1860
  snapshot() {
1299
1861
  const tokens = {};
1300
1862
  for (const [playerId, binding] of frame.playerBindings) {
1301
- const token = frame.playerResumeTokens.get(binding.hostPlayerId);
1863
+ const token = playerLedger.get(binding.playerId)?.resumeToken;
1302
1864
  if (token !== undefined)
1303
1865
  tokens[playerId] = token;
1304
1866
  }
1305
1867
  return tokens;
1306
1868
  },
1307
1869
  restore(tokens) {
1308
- for (const binding of frame.playerBindings.values()) {
1309
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1870
+ if (lifecycle !== 'restoring' ||
1871
+ restoringPlayerSessionFrame !== frame) {
1872
+ throw new Error(`${frameLabel(frame)} player-session restore is only available during shell restoration`);
1310
1873
  }
1874
+ const byPlayer = new Map();
1311
1875
  for (const [playerId, token] of Object.entries(tokens)) {
1312
1876
  const binding = bindingFor(frame, playerId);
1313
- frame.playerResumeTokens.set(binding.hostPlayerId, token);
1877
+ const previous = byPlayer.get(binding.playerId);
1878
+ if (previous !== undefined && previous !== token) {
1879
+ throw new Error(`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`);
1880
+ }
1881
+ byPlayer.set(binding.playerId, token);
1882
+ }
1883
+ for (const binding of frame.playerBindings.values()) {
1884
+ if (!byPlayer.has(binding.playerId))
1885
+ byPlayer.set(binding.playerId, undefined);
1886
+ }
1887
+ for (const [playerId, token] of byPlayer) {
1888
+ const ledger = playerLedger.get(playerId);
1889
+ if (!ledger) {
1890
+ throw new Error(`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`);
1891
+ }
1892
+ if (token === undefined)
1893
+ delete ledger.resumeToken;
1894
+ else
1895
+ ledger.resumeToken = token;
1314
1896
  }
1315
1897
  },
1316
1898
  });
1899
+ const closePlayerCallScope = (frame, scope) => {
1900
+ if (frame.playerCallScope === scope)
1901
+ frame.playerCallScope = undefined;
1902
+ const missing = [];
1903
+ for (const [playerId, transaction] of playerTransactions) {
1904
+ if (transaction.frame !== frame || transaction.scope !== scope)
1905
+ continue;
1906
+ if (transaction.phase === 'calling') {
1907
+ transaction.abandoned = true;
1908
+ }
1909
+ else {
1910
+ playerTransactions.set(playerId, {
1911
+ phase: 'quarantined',
1912
+ frame: transaction.frame,
1913
+ roleId: transaction.roleId,
1914
+ turnId: transaction.turnId,
1915
+ signal: transaction.signal,
1916
+ scope: transaction.scope,
1917
+ reason: transaction.phase === 'awaitingCommit'
1918
+ ? 'the runtime returned without committing a validated result'
1919
+ : transaction.reason,
1920
+ });
1921
+ }
1922
+ if (!transaction.signal.aborted)
1923
+ missing.push(playerId);
1924
+ }
1925
+ return missing.length === 0
1926
+ ? undefined
1927
+ : new Error(`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`);
1928
+ };
1929
+ const runFrameOperation = async (frame, operation) => {
1930
+ if (frame.playerCallScope !== undefined) {
1931
+ throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
1932
+ }
1933
+ const scope = {};
1934
+ frame.playerCallScope = scope;
1935
+ let outcome;
1936
+ try {
1937
+ outcome = { ok: true, value: await operation() };
1938
+ }
1939
+ catch (error) {
1940
+ outcome = { ok: false, error };
1941
+ }
1942
+ const cleanupError = closePlayerCallScope(frame, scope);
1943
+ if (!outcome.ok)
1944
+ throw outcome.error;
1945
+ if (cleanupError !== undefined)
1946
+ throw cleanupError;
1947
+ return outcome.value;
1948
+ };
1317
1949
  const frameSession = (frame) => ({
1318
1950
  sessionId: frame.sessionId,
1319
1951
  playbookId: frame.entry.id,
@@ -1325,6 +1957,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1325
1957
  }
1326
1958
  : {}),
1327
1959
  depth: frame.depth,
1960
+ roleBindings: Object.fromEntries([...frame.playerBindings].map(([roleId, binding]) => [
1961
+ roleId,
1962
+ {
1963
+ playerId: binding.playerId,
1964
+ promptIdentity: promptIdentity(binding),
1965
+ },
1966
+ ])),
1328
1967
  playerSessions: playerSessionStore(frame),
1329
1968
  ports: createPorts(frame),
1330
1969
  });
@@ -1609,7 +2248,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1609
2248
  // exception filed against an effect that never ran.
1610
2249
  await requestVisibility(frame);
1611
2250
  await setMode('engaged.driving', 'submit');
1612
- const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
2251
+ const result = await runFrameOperation(frame, () => runEffect(() => frame.runtime.handleBossInput({ text, signal })));
1613
2252
  frame.state = result.state;
1614
2253
  return result;
1615
2254
  };
@@ -1655,11 +2294,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1655
2294
  }
1656
2295
  let result;
1657
2296
  try {
1658
- result = await runEffect(() => parent.runtime.resumePlaybookCall({
2297
+ result = await runFrameOperation(parent, () => runEffect(() => parent.runtime.resumePlaybookCall({
1659
2298
  callId: parentLink.callId,
1660
2299
  result: effectiveResult,
1661
2300
  signal: context.signal,
1662
- }));
2301
+ })));
1663
2302
  }
1664
2303
  catch (error) {
1665
2304
  if (disposing || invocationSignal?.aborted)
@@ -1696,7 +2335,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1696
2335
  // runtime publishes before disposal removes the frame. The opaque run
1697
2336
  // output remains runtime-to-runtime data and never becomes Captain
1698
2337
  // evidence (CAPPLAY-10).
1699
- activeTurn?.settlementFacts.push(rootCompletionFact(frame));
2338
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
1700
2339
  await runEffect(() => disposeStack('final'));
1701
2340
  }
1702
2341
  return;
@@ -2015,7 +2654,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2015
2654
  stateDigestLine(view.state, view.stateDescription),
2016
2655
  ].join(': '));
2017
2656
  lines.push(...leafContextLines(view.context));
2018
- const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.player)} asks: ${quoteEvidence(question.question)}`);
2657
+ const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.asker.kind === 'captain'
2658
+ ? 'Captain'
2659
+ : question.asker.roleId)} asks: ${quoteEvidence(question.question)}`);
2019
2660
  lines.push(pending.length === 0
2020
2661
  ? 'Pending Boss questions: none.'
2021
2662
  : ['Pending Boss questions:', ...pending].join('\n'));
@@ -2111,6 +2752,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2111
2752
  await trackTurnCall(settlement.context.emitReply(settlement.text));
2112
2753
  }
2113
2754
  catch (error) {
2755
+ conversation = { kind: 'needsSeeding' };
2114
2756
  const normalized = normalizeErrorCompact(error) ?? {
2115
2757
  name: 'Error',
2116
2758
  message: String(error),
@@ -2207,8 +2849,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2207
2849
  * text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
2208
2850
  * the Captain may need to repeat.
2209
2851
  */
2210
- const reseedDigest = () => {
2211
- for (const record of journal) {
2852
+ const conversationDigest = (records, render) => {
2853
+ for (const record of records) {
2212
2854
  if (record.kind === 'action' &&
2213
2855
  typeof record.payload === 'object' &&
2214
2856
  record.payload !== null &&
@@ -2218,13 +2860,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2218
2860
  recordSuppliedIdentifier(actionId);
2219
2861
  }
2220
2862
  }
2221
- return renderReseedDigest(journal);
2863
+ return render(records);
2222
2864
  };
2865
+ const reseedDigest = () => conversationDigest(journal, renderReseedDigest);
2866
+ const catchUpDigest = (afterJournalSeq) => conversationDigest(journal.filter((record) => record.seq > afterJournalSeq), renderCatchUpDigest);
2223
2867
  const markControlFailure = (error) => {
2224
- if (activeTurn)
2225
- activeTurn.controlFailure = true;
2868
+ activeTurn?.controlFailures.add(error);
2226
2869
  return error;
2227
2870
  };
2871
+ const markSettingsRejection = (error) => markControlFailure(error);
2872
+ const rememberSettingsPreflight = (error) => {
2873
+ activeTurn?.settingsPreflightFailures.add(error);
2874
+ return markSettingsRejection(error);
2875
+ };
2876
+ const markConversationCatchUp = () => {
2877
+ if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
2878
+ return;
2879
+ }
2880
+ conversation = {
2881
+ kind: 'needsCatchUp',
2882
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
2883
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
2884
+ };
2885
+ };
2886
+ const markConversationUnsynchronized = () => {
2887
+ if (conversation.kind !== 'needsCatchUp') {
2888
+ conversation = { kind: 'needsSeeding' };
2889
+ }
2890
+ };
2228
2891
  /**
2229
2892
  * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
2230
2893
  * engagement constructed, a stack disposed, an advertised action applied.
@@ -2270,14 +2933,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2270
2933
  this.name = 'CaptainProseError';
2271
2934
  }
2272
2935
  }
2273
- const rawDurableCall = async (context, prompt, resume) => {
2936
+ const rawDurableCall = async (context, prompt, resume, attempt) => {
2274
2937
  const queued = captainQueue.add(async () => {
2275
2938
  context.signal.throwIfAborted();
2276
- const result = await context.callCaptain(prompt, {
2939
+ attempt.providerBoundaryEntered = true;
2940
+ const result = await classifySettingsCall(() => context.callCaptain(prompt, {
2277
2941
  visibility: 'hidden',
2278
2942
  resume,
2279
2943
  ...controlCallToolOptions(captainAdapter),
2280
- });
2944
+ settings: callSettings(captainAgent),
2945
+ }));
2281
2946
  context.signal.throwIfAborted();
2282
2947
  return result;
2283
2948
  });
@@ -2289,17 +2954,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2289
2954
  // conversation that is owed a reseed carries the digest on its very next
2290
2955
  // call, so the turn after a failed reseed starts seeded rather than blank.
2291
2956
  const durableCall = async (context, compose) => {
2292
- const resume = conversation.kind === 'pinned' ? conversation.token : false;
2957
+ const startingConversation = conversation;
2958
+ const resume = startingConversation.kind === 'pinned'
2959
+ ? startingConversation.token
2960
+ : startingConversation.kind === 'needsCatchUp'
2961
+ ? startingConversation.resume
2962
+ : false;
2293
2963
  const seedFirstCall = conversation.kind === 'needsSeeding';
2964
+ const catchUpFirstCall = conversation.kind === 'needsCatchUp';
2965
+ const representedJournalSeq = journalSeq;
2966
+ const firstAttempt = { providerBoundaryEntered: false };
2294
2967
  let result;
2295
2968
  let failure;
2296
2969
  try {
2297
- result = await rawDurableCall(context, compose(seedFirstCall ? { reseedDigest: reseedDigest() } : {}), resume);
2970
+ result = await rawDurableCall(context, compose(seedFirstCall
2971
+ ? { reseedDigest: reseedDigest() }
2972
+ : startingConversation.kind === 'needsCatchUp'
2973
+ ? {
2974
+ reseedDigest: catchUpDigest(startingConversation.afterJournalSeq),
2975
+ }
2976
+ : {}), resume, firstAttempt);
2298
2977
  }
2299
2978
  catch (error) {
2300
2979
  if (context.signal.aborted) {
2301
- conversation = { kind: 'needsSeeding' };
2302
- throw error;
2980
+ if (firstAttempt.providerBoundaryEntered) {
2981
+ conversation = { kind: 'needsSeeding' };
2982
+ }
2983
+ else {
2984
+ markConversationUnsynchronized();
2985
+ }
2986
+ throw context.signal.reason ?? error;
2987
+ }
2988
+ if (error instanceof AgentSettingsPreflightError) {
2989
+ if (conversation.kind !== 'needsCatchUp' &&
2990
+ conversation.kind !== 'needsSeeding') {
2991
+ conversation = {
2992
+ kind: 'needsCatchUp',
2993
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
2994
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
2995
+ };
2996
+ }
2997
+ throw rememberSettingsPreflight(error.rejection);
2303
2998
  }
2304
2999
  failure = error;
2305
3000
  }
@@ -2309,11 +3004,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2309
3004
  result.resumeToken === undefined;
2310
3005
  if (!unsynchronized) {
2311
3006
  conversation = { kind: 'pinned', token: result.resumeToken };
3007
+ if (activeTurn) {
3008
+ activeTurn.captainSyncedJournalSeq = representedJournalSeq;
3009
+ }
2312
3010
  return {
2313
3011
  ...(result.finalText !== undefined
2314
3012
  ? { finalText: result.finalText }
2315
3013
  : {}),
2316
- correctiveSpent: seedFirstCall,
3014
+ correctiveSpent: seedFirstCall || catchUpFirstCall,
2317
3015
  };
2318
3016
  }
2319
3017
  // Only the model-side conversation is replaced: the stack, player
@@ -2323,13 +3021,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2323
3021
  conversation = { kind: 'needsSeeding' };
2324
3022
  const recap = reseedDigest();
2325
3023
  let reissued;
3024
+ const reissueAttempt = { providerBoundaryEntered: false };
2326
3025
  try {
2327
- reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false);
3026
+ reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false, reissueAttempt);
2328
3027
  }
2329
3028
  catch (error) {
2330
3029
  if (context.signal.aborted) {
2331
3030
  conversation = { kind: 'needsSeeding' };
2332
- throw error;
3031
+ throw context.signal.reason ?? error;
3032
+ }
3033
+ if (error instanceof AgentSettingsPreflightError) {
3034
+ throw rememberSettingsPreflight(error.rejection);
2333
3035
  }
2334
3036
  throw markControlFailure(new CaptainContinuityError(error));
2335
3037
  }
@@ -2338,6 +3040,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2338
3040
  `callCaptain status "${reissued.status}" without a resume token`));
2339
3041
  }
2340
3042
  conversation = { kind: 'pinned', token: reissued.resumeToken };
3043
+ if (activeTurn)
3044
+ activeTurn.captainSyncedJournalSeq = journalSeq;
2341
3045
  return {
2342
3046
  ...(reissued.finalText !== undefined
2343
3047
  ? { finalText: reissued.finalText }
@@ -2523,10 +3227,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2523
3227
  // -------------------------------------------------------------------------
2524
3228
  // The controller port (DR-029): host validation is the sole effector.
2525
3229
  // -------------------------------------------------------------------------
2526
- // The leaf's published state description, read from its control view the
2527
- // same way the digest reads it. A leaf without the pair or one whose view
2528
- // cannot be read at this moment publishes none, and the summary then says
2529
- // so instead of falling back to the state id.
3230
+ // The legacy state-description channel, read from the live control view the
3231
+ // same way the digest reads it. DR-037 makes the terminal result authoritative
3232
+ // for completion; this remains only for an older runtime that omits the new
3233
+ // optional member.
2530
3234
  const leafStateDescription = (frame) => {
2531
3235
  if (typeof frame.runtime.describe !== 'function')
2532
3236
  return undefined;
@@ -2537,9 +3241,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2537
3241
  return undefined;
2538
3242
  }
2539
3243
  };
2540
- const rootCompletionFact = (frame) => {
2541
- const published = leafStateDescription(frame);
2542
- const description = published === undefined ? '' : compactEvidence(published);
3244
+ const rootCompletionFact = (frame, result) => {
3245
+ const returned = result.stateDescription === undefined
3246
+ ? ''
3247
+ : compactEvidence(result.stateDescription);
3248
+ const legacy = returned === '' ? leafStateDescription(frame) : undefined;
3249
+ const description = returned !== ''
3250
+ ? returned
3251
+ : legacy === undefined
3252
+ ? ''
3253
+ : compactEvidence(legacy);
2543
3254
  return description === ''
2544
3255
  ? `${frameLabel(frame)} completed; its runtime published no result description.`
2545
3256
  : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
@@ -2681,7 +3392,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2681
3392
  message: String(error),
2682
3393
  };
2683
3394
  if (aborted) {
2684
- conversation = { kind: 'needsSeeding' };
3395
+ markConversationUnsynchronized();
2685
3396
  if (turn?.outcomePending) {
2686
3397
  turn.settlementFacts.push(`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`);
2687
3398
  journalOutcome(journalOutcomeEvidence(turn.settlementFacts, 'failed', turn.report));
@@ -3011,7 +3722,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3011
3722
  // guard against re-execution — a repeated selection returns the recorded
3012
3723
  // receipt rather than acting twice.
3013
3724
  const key = `turn-${turn.id}-apply-${actionId}`;
3014
- const outcome = await withCounting(leaf, async () => runEffect(() => leaf.runtime.apply({ actionId, key, signal })));
3725
+ const outcome = await withCounting(leaf, async () => runFrameOperation(leaf, () => runEffect(() => leaf.runtime.apply({ actionId, key, signal }))));
3015
3726
  if (outcome.error !== undefined)
3016
3727
  throw outcome.error;
3017
3728
  const receipt = outcome.result;
@@ -3159,7 +3870,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3159
3870
  // The durable Captain conversation did not receive the shell-authored
3160
3871
  // fallback. Force its next call through the journal so it cannot interpret
3161
3872
  // the Boss's follow-up without the reply the Boss was given this turn.
3162
- conversation = { kind: 'needsSeeding' };
3873
+ if (activeTurn?.settingsPreflightFailures.has(error)) {
3874
+ markConversationCatchUp();
3875
+ }
3876
+ else {
3877
+ markConversationUnsynchronized();
3878
+ }
3163
3879
  // A rejected presentation may already have emitted bytes. It is therefore
3164
3880
  // final for this turn even though the Promise did not prove it was shown.
3165
3881
  if (activeTurn?.presentationAttempted === true)
@@ -3182,112 +3898,68 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3182
3898
  playbookId: INTERNAL_CAPTAIN_ID,
3183
3899
  rootSessionId: id,
3184
3900
  depth: 0,
3901
+ roleBindings: {},
3185
3902
  ports: captainPorts(),
3186
3903
  });
3187
- const tokenRecord = (tokens) => Object.fromEntries(tokens);
3904
+ const playerLedgerRecord = () => Object.fromEntries([...playerLedger].map(([playerId, entry]) => [
3905
+ playerId,
3906
+ {
3907
+ adapter: entry.adapter,
3908
+ ...(entry.instruction === undefined
3909
+ ? {}
3910
+ : { instruction: entry.instruction }),
3911
+ ...(entry.permissions === undefined
3912
+ ? {}
3913
+ : { permissions: entry.permissions }),
3914
+ ...(entry.resumeToken === undefined
3915
+ ? {}
3916
+ : { resumeToken: entry.resumeToken }),
3917
+ },
3918
+ ]));
3188
3919
  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');
3920
+ if (captainAgent === undefined ||
3921
+ !isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))) {
3922
+ throw new TypeError('Captain shell snapshot Captain agent is incompatible with current config');
3923
+ }
3924
+ const configuredPlayerIds = [...playerAgents.keys()].sort();
3925
+ const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
3926
+ if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
3927
+ throw new TypeError('Captain shell snapshot player ledger does not match current referenced players');
3928
+ }
3929
+ for (const playerId of configuredPlayerIds) {
3930
+ const saved = snapshot.playerSessions[playerId];
3931
+ const configured = playerAgents.get(playerId);
3932
+ const savedFixed = {
3933
+ adapter: saved.adapter,
3934
+ ...(saved.instruction === undefined
3935
+ ? {}
3936
+ : { instruction: saved.instruction }),
3937
+ ...(saved.permissions === undefined
3938
+ ? {}
3939
+ : { permissions: saved.permissions }),
3940
+ };
3941
+ if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
3942
+ throw new TypeError(`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`);
3943
+ }
3205
3944
  }
3206
3945
  if (snapshot.mode === 'chat')
3207
3946
  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()) {
3947
+ for (const frame of snapshot.frames) {
3225
3948
  const enablement = enabled.get(frame.playbookId);
3226
3949
  if (!enablement) {
3227
3950
  throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
3228
3951
  }
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));
3952
+ const configuredBindings = Object.fromEntries([...enablement.roleBindings].map(([role, binding]) => [
3953
+ role,
3954
+ binding.playerId,
3955
+ ]));
3956
+ if (!isDeepStrictEqual(frame.options, enablement.options)) {
3957
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`);
3276
3958
  }
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`);
3959
+ if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
3960
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
3284
3961
  }
3285
3962
  }
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
3963
  };
3292
3964
  const safeCapturePoint = () => {
3293
3965
  if (lifecycle !== 'ready' ||
@@ -3305,6 +3977,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3305
3977
  runFailureFacts !== undefined ||
3306
3978
  servingCall !== undefined ||
3307
3979
  decisionCall !== undefined ||
3980
+ playerTransactions.size !== 0 ||
3308
3981
  captainQueue.pending !== 0 ||
3309
3982
  captainQueue.size !== 0 ||
3310
3983
  (mode !== 'chat' && mode !== 'engaged.parked')) {
@@ -3341,7 +4014,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3341
4014
  });
3342
4015
  };
3343
4016
  const exportShellSnapshot = () => {
3344
- if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
4017
+ if (!safeCapturePoint() ||
4018
+ !captainRuntime ||
4019
+ !captainSessionId ||
4020
+ !captainAgent) {
3345
4021
  return undefined;
3346
4022
  }
3347
4023
  try {
@@ -3374,16 +4050,23 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3374
4050
  parentCallId: frame.parent.callId,
3375
4051
  }
3376
4052
  : {}),
4053
+ options: frame.enablement.options,
4054
+ roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
4055
+ role,
4056
+ binding.playerId,
4057
+ ])),
3377
4058
  runtime,
3378
4059
  });
3379
4060
  }
3380
4061
  const common = {
3381
- schemaVersion: 1,
4062
+ schemaVersion: 3,
3382
4063
  captain: {
3383
4064
  sessionId: captainSessionId,
3384
4065
  runtime: captainSnapshot,
4066
+ agent: fixedAgent(captainAgent),
3385
4067
  conversation,
3386
4068
  },
4069
+ playerSessions: playerLedgerRecord(),
3387
4070
  issuedSessionIds: [...issuedSessionIds],
3388
4071
  sequences: { turn: turnSequence, journal: journalSeq },
3389
4072
  journal,
@@ -3398,7 +4081,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3398
4081
  ...common,
3399
4082
  mode: 'engaged.parked',
3400
4083
  frames: frameSnapshots,
3401
- rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
3402
4084
  ...(pendingBossQuestions === undefined
3403
4085
  ? {}
3404
4086
  : { pendingBossQuestions: pendingBossQuestions }),
@@ -3420,7 +4102,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3420
4102
  const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
3421
4103
  for (const key of [
3422
4104
  'state',
3423
- 'playerResumeTokens',
4105
+ 'roleResumeTokens',
3424
4106
  'sequences',
3425
4107
  'pendingBossQuestions',
3426
4108
  'suspendedCall',
@@ -3458,7 +4140,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3458
4140
  byCommand = new Map();
3459
4141
  byId = new Map();
3460
4142
  enablementById = new Map();
3461
- players = [];
4143
+ captainAgent = undefined;
4144
+ captainAdapter = undefined;
4145
+ playerAgents = new Map();
4146
+ playerLedger.clear();
4147
+ playerTransactions.clear();
3462
4148
  session = undefined;
3463
4149
  sessionEmissionsOpen = false;
3464
4150
  closedGateAttempted = false;
@@ -3492,17 +4178,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3492
4178
  lifecycle = 'restoring';
3493
4179
  try {
3494
4180
  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
- }
4181
+ const built = await buildEnablements(options, loadModule);
4182
+ captainAgent = built.captainAgent;
4183
+ captainAdapter = captainAgent.adapter;
4184
+ playerAgents = built.playerAgents;
3499
4185
  assertSnapshotMatchesEnablements(snapshot, built.enablementById);
3500
4186
  installSession(initSession, false);
3501
- players = initSession.players;
3502
4187
  entries = built.entries;
3503
4188
  byCommand = built.byCommand;
3504
4189
  byId = built.byId;
3505
4190
  enablementById = built.enablementById;
4191
+ for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
4192
+ playerLedger.set(playerId, {
4193
+ adapter: saved.adapter,
4194
+ ...(saved.instruction === undefined
4195
+ ? {}
4196
+ : { instruction: saved.instruction }),
4197
+ ...(saved.permissions === undefined
4198
+ ? {}
4199
+ : { permissions: livePermissions(saved.permissions) }),
4200
+ ...(saved.resumeToken === undefined
4201
+ ? {}
4202
+ : { resumeToken: saved.resumeToken }),
4203
+ });
4204
+ }
3506
4205
  captainRuntime = createCaptainRuntime({
3507
4206
  enabledPlaybooks: enabledCatalog(),
3508
4207
  controller,
@@ -3511,10 +4210,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3511
4210
  throw new Error('session Captain runtime does not support restore');
3512
4211
  }
3513
4212
  if (snapshot.mode === 'engaged.parked') {
3514
- const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
3515
4213
  for (const [index, frameSnapshot] of snapshot.frames.entries()) {
3516
4214
  const parentFrame = frames.at(-1);
3517
- const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, rootTokens, index === 0
4215
+ const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, index === 0
3518
4216
  ? undefined
3519
4217
  : {
3520
4218
  frame: parentFrame,
@@ -3531,7 +4229,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3531
4229
  await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
3532
4230
  if (snapshot.mode === 'engaged.parked') {
3533
4231
  for (const [index, frame] of frames.entries()) {
3534
- await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
4232
+ restoringPlayerSessionFrame = frame;
4233
+ try {
4234
+ await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
4235
+ }
4236
+ finally {
4237
+ restoringPlayerSessionFrame = undefined;
4238
+ }
3535
4239
  }
3536
4240
  }
3537
4241
  if (closedGateAttempted) {
@@ -3542,8 +4246,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3542
4246
  for (const [index, frame] of frames.entries()) {
3543
4247
  verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
3544
4248
  }
3545
- if (!isDeepStrictEqual(tokenRecord(rootFrame().playerResumeTokens), snapshot.rootPlayerResumeTokens)) {
3546
- throw new Error('restored root-owned player continuation changed during restore');
4249
+ if (!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)) {
4250
+ throw new Error('restored Captain-session player continuation changed during restore');
3547
4251
  }
3548
4252
  }
3549
4253
  if (closedGateAttempted) {
@@ -3589,14 +4293,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3589
4293
  lifecycle = 'initializing';
3590
4294
  try {
3591
4295
  installSession(initSession, true);
3592
- players = initSession.players;
3593
- const built = await buildEnablements(options, players, loadModule);
4296
+ const built = await buildEnablements(options, loadModule);
3594
4297
  entries = built.entries;
3595
4298
  byCommand = built.byCommand;
3596
4299
  byId = built.byId;
3597
4300
  enablementById = built.enablementById;
3598
- for (const enablement of enablementById.values()) {
3599
- enablement.entry.validateOptions(enablement.optionInput);
4301
+ captainAgent = built.captainAgent;
4302
+ captainAdapter = captainAgent.adapter;
4303
+ playerAgents = built.playerAgents;
4304
+ for (const [playerId, agent] of playerAgents) {
4305
+ playerLedger.set(playerId, fixedAgent(agent));
3600
4306
  }
3601
4307
  await setMode('chat', 'init');
3602
4308
  // CAPTAIN-16: the session Captain exists from `init`, outside the
@@ -3638,6 +4344,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3638
4344
  const parsed = resolveCommandTurn(turn.prompt);
3639
4345
  activeTurn = {
3640
4346
  id: ++turnSequence,
4347
+ captainSyncedJournalSeq: journalSeq,
3641
4348
  bossText: turn.prompt,
3642
4349
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
3643
4350
  ...(parsed ? { resolution: parsed.resolution } : {}),
@@ -3645,6 +4352,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3645
4352
  presentationAttempted: false,
3646
4353
  settlementFacts: [],
3647
4354
  effectThrows: new Set(),
4355
+ controlFailures: new Set(),
4356
+ settingsPreflightFailures: new Set(),
3648
4357
  suppliedIdentifiers: new Set(),
3649
4358
  outcomeRecorded: false,
3650
4359
  };
@@ -3663,11 +4372,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3663
4372
  new Error('the session Captain turn failed at its boundary'));
3664
4373
  }
3665
4374
  else if (result.outcome === 'aborted') {
3666
- conversation = { kind: 'needsSeeding' };
4375
+ markConversationUnsynchronized();
3667
4376
  if (activeTurn && !activeTurn.outcomeRecorded) {
3668
4377
  activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
3669
4378
  journalOutcome([...activeTurn.settlementFacts]);
3670
4379
  }
4380
+ if (context.signal.aborted) {
4381
+ throw context.signal.reason;
4382
+ }
3671
4383
  }
3672
4384
  else if (result.outcome !== 'suspended' &&
3673
4385
  !context.signal.aborted &&
@@ -3681,10 +4393,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3681
4393
  }
3682
4394
  catch (error) {
3683
4395
  if (context.signal.aborted) {
3684
- conversation = { kind: 'needsSeeding' };
4396
+ markConversationUnsynchronized();
3685
4397
  throw error;
3686
4398
  }
3687
- const controlFailure = activeTurn?.controlFailure === true;
4399
+ const controlFailure = activeTurn?.controlFailures.has(error) === true;
3688
4400
  await settleTurnFailure(context, error);
3689
4401
  if (activeTurn?.presentationError !== undefined) {
3690
4402
  throw activeTurn.presentationError;
@@ -3696,7 +4408,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3696
4408
  }
3697
4409
  finally {
3698
4410
  if (context.signal.aborted) {
3699
- conversation = { kind: 'needsSeeding' };
4411
+ markConversationUnsynchronized();
3700
4412
  if (activeTurn && !activeTurn.outcomeRecorded) {
3701
4413
  activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
3702
4414
  journalOutcome([...activeTurn.settlementFacts]);
@@ -3750,6 +4462,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3750
4462
  failure ??= error;
3751
4463
  }
3752
4464
  }
4465
+ // Quarantine is session-wide by design. Only terminal teardown may drop
4466
+ // its ownership after every frame host call and the Captain are drained.
4467
+ playerTransactions.clear();
3753
4468
  lifecycle = 'closed';
3754
4469
  if (failure !== undefined)
3755
4470
  throw failure;