@sublang/playbook 6.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -1,8 +1,10 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
  import PQueue from 'p-queue';
5
- import { hiddenControlEnvelope, registerPlaybookAbortCleanup, } 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';
6
8
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
7
9
  class VisibilityControlError extends Error {
8
10
  constructor(cause) {
@@ -10,10 +12,33 @@ class VisibilityControlError extends Error {
10
12
  this.name = 'VisibilityControlError';
11
13
  }
12
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
+ }
13
36
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
14
37
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
15
38
  const INTERNAL_CAPTAIN_ID = 'captain';
16
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_-]*$/;
17
42
  function parseRegisteredCommand(prompt) {
18
43
  const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
19
44
  if (!match)
@@ -126,7 +151,14 @@ function pendingQuestionLines(pending) {
126
151
  : typeof record.id === 'string'
127
152
  ? record.id
128
153
  : undefined;
129
- const player = typeof record.player === 'string' ? record.player : undefined;
154
+ const asker = typeof record.asker === 'object' && record.asker !== null
155
+ ? record.asker
156
+ : undefined;
157
+ const askerLabel = asker?.kind === 'captain'
158
+ ? 'Captain'
159
+ : asker?.kind === 'role' && typeof asker.roleId === 'string'
160
+ ? asker.roleId
161
+ : undefined;
130
162
  const text = typeof record.question === 'string'
131
163
  ? record.question
132
164
  : typeof record.text === 'string'
@@ -136,9 +168,9 @@ function pendingQuestionLines(pending) {
136
168
  // fragment is never handed back to the tag as a value: bounding it a
137
169
  // second time would cut the line at the seam's limit and drop whatever
138
170
  // the shell had already written after the long part.
139
- const asked = player === undefined
171
+ const asked = askerLabel === undefined
140
172
  ? digestLine `${quoteEvidence(text)}`
141
- : digestLine `${quoteEvidence(player)} asks: ${quoteEvidence(text)}`;
173
+ : digestLine `${quoteEvidence(askerLabel)} asks: ${quoteEvidence(text)}`;
142
174
  const marker = id === undefined ? '' : digestLine `(${quoteEvidence(id)}) `;
143
175
  lines.push(`- ${marker}${asked}`);
144
176
  }
@@ -178,14 +210,20 @@ function renderJournalPayload(payload) {
178
210
  const raw = typeof payload === 'string' ? payload : JSON.stringify(payload);
179
211
  return raw ?? 'null';
180
212
  }
181
- function renderReseedDigest(records) {
213
+ function renderJournalDigest(records, heading) {
182
214
  const lines = records.map((record) => `${record.seq}. turn ${record.turnId} ${record.kind}: ${renderJournalPayload(record.payload)}`);
183
215
  return [
184
- 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.',
216
+ heading,
185
217
  'The labeled ControlView and catalog digest blocks outrank conversation memory.',
186
218
  ...(lines.length === 0 ? ['(no earlier turns)'] : lines),
187
219
  ].join('\n');
188
220
  }
221
+ function renderReseedDigest(records) {
222
+ return renderJournalDigest(records, 'This conversation was replaced after a host-side continuity failure. The recap below is the deterministic session record kept by the host.');
223
+ }
224
+ function renderCatchUpDigest(records) {
225
+ return renderJournalDigest(records, 'This retained conversation missed the host journal records below. Treat this deterministic journal suffix as authoritative.');
226
+ }
189
227
  // DR-028 / CAPTAIN-9: validated captain speech carries no control JSON and no
190
228
  // internal control vocabulary.
191
229
  const CONTROL_VOCABULARY = [
@@ -298,22 +336,31 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
298
336
  return undefined;
299
337
  }
300
338
  // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
301
- // Cligent's Codex adapter rejects any `allowedTools` value — including the
302
- // empty list that expresses tool-free — because the supported Codex SDK
303
- // cannot enforce one, so requesting it fails every control call before the
304
- // model is reached. Omitting the option is the only way such an adapter can
305
- // run a control call at all; its isolation then rests on the authored
306
- // hidden-judge envelope below rather than on provider enforcement.
307
- const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT = new Set([
308
- 'codex',
309
- ]);
339
+ // Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
340
+ // value — including the empty list that expresses tool-free — because their
341
+ // supported provider surfaces cannot enforce one, so requesting it fails
342
+ // every control call before the model is reached. Omitting the option is the
343
+ // only way such an adapter can run a control call at all; its isolation then
344
+ // rests on the authored hidden-judge envelope below rather than on provider
345
+ // enforcement.
346
+ const CAPTAIN_TOOL_ISOLATION_BY_ADAPTER = {
347
+ claude: 'provider-enforced',
348
+ codex: 'prompt-only',
349
+ gemini: 'provider-enforced',
350
+ kimi: 'prompt-only',
351
+ opencode: 'prompt-only',
352
+ };
353
+ function requiresPromptOnlyToolIsolation(captainAdapter) {
354
+ return (Object.hasOwn(CAPTAIN_TOOL_ISOLATION_BY_ADAPTER, captainAdapter) &&
355
+ CAPTAIN_TOOL_ISOLATION_BY_ADAPTER[captainAdapter] === 'prompt-only');
356
+ }
310
357
  // The tool half of a control call's options. An empty allowlist means "no
311
358
  // tools available" and is distinct from omission, which grants the adapter's
312
359
  // full native tool surface — so omit only where the empty list would be
313
360
  // refused, and keep requesting enforcement whenever the adapter is unknown.
314
361
  function controlCallToolOptions(captainAdapter) {
315
362
  if (captainAdapter !== undefined &&
316
- ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)) {
363
+ requiresPromptOnlyToolIsolation(captainAdapter)) {
317
364
  return {};
318
365
  }
319
366
  return { allowedTools: [] };
@@ -329,14 +376,6 @@ function forwardedToolOptions(requested, captainAdapter) {
329
376
  return controlCallToolOptions(captainAdapter);
330
377
  return { allowedTools: requested };
331
378
  }
332
- function readCaptainAdapter(options) {
333
- if (typeof options !== 'object' || options === null)
334
- return undefined;
335
- const adapter = options.captainAdapter;
336
- return typeof adapter === 'string' && adapter.length > 0
337
- ? adapter
338
- : undefined;
339
- }
340
379
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
341
380
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
342
381
  // reply call's envelope — the settlement's outcome-report facts verbatim, the
@@ -407,34 +446,653 @@ function isValidRegistryEntry(value) {
407
446
  if (typeof value !== 'object' || value === null)
408
447
  return false;
409
448
  const e = value;
449
+ if (!Array.isArray(e.requiredRoleIds) ||
450
+ e.requiredRoleIds.some((role) => typeof role !== 'string' ||
451
+ !ROLE_ID_PATTERN.test(role) ||
452
+ role === INTERNAL_CAPTAIN_ID) ||
453
+ new Set(e.requiredRoleIds).size !== e.requiredRoleIds.length ||
454
+ !Array.isArray(e.concurrentRoleSets)) {
455
+ return false;
456
+ }
457
+ const roles = new Set(e.requiredRoleIds);
458
+ const concurrency = e.concurrentRoleSets;
459
+ if (concurrency.some((set) => !Array.isArray(set) ||
460
+ set.length < 2 ||
461
+ set.some((role) => typeof role !== 'string' || !roles.has(role)) ||
462
+ new Set(set).size !== set.length) ||
463
+ new Set(concurrency.map((set) => JSON.stringify(set))).size !==
464
+ concurrency.length) {
465
+ return false;
466
+ }
410
467
  return (typeof e.id === 'string' &&
411
468
  typeof e.command === 'string' &&
412
469
  typeof e.intent === 'string' &&
413
- Array.isArray(e.requiredRoleIds) &&
470
+ e.artifactSchema === 2 &&
414
471
  typeof e.validateOptions === 'function' &&
415
472
  typeof e.createRuntime === 'function');
416
473
  }
417
- function readPlaybooksConfig(options) {
418
- if (typeof options !== 'object' || options === null)
474
+ const SNAPSHOT_ACTIONS = new Set([
475
+ 'respond',
476
+ 'start',
477
+ 'switch',
478
+ 'dismiss',
479
+ 'deliver',
480
+ 'runtime',
481
+ ]);
482
+ const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
483
+ 'ok',
484
+ 'rejected',
485
+ 'failed',
486
+ ]);
487
+ const SNAPSHOT_JOURNAL_KINDS = new Set([
488
+ 'boss',
489
+ 'reply',
490
+ 'handoff',
491
+ 'action',
492
+ 'outcome',
493
+ ]);
494
+ function snapshotRecord(value, path) {
495
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
496
+ throw new TypeError(`${path} must be an object`);
497
+ }
498
+ return value;
499
+ }
500
+ function rejectSnapshotKeys(value, allowed, path) {
501
+ const allowedKeys = new Set(allowed);
502
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
503
+ if (unknown.length > 0) {
504
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
505
+ }
506
+ }
507
+ function snapshotString(value, path, allowEmpty = false) {
508
+ if (typeof value !== 'string' ||
509
+ (!allowEmpty && value.trim().length === 0)) {
510
+ throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
511
+ }
512
+ return value;
513
+ }
514
+ function snapshotInteger(value, path, minimum = 0) {
515
+ if (!Number.isSafeInteger(value) || value < minimum) {
516
+ throw new TypeError(`${path} must be an integer >= ${minimum}`);
517
+ }
518
+ return value;
519
+ }
520
+ function snapshotUuid(value, path) {
521
+ const id = snapshotString(value, path);
522
+ if (!UUID_PATTERN.test(id)) {
523
+ throw new TypeError(`${path} must be a UUID`);
524
+ }
525
+ return id;
526
+ }
527
+ function snapshotPermissions(value, path) {
528
+ if (value === undefined)
419
529
  return undefined;
420
- const pb = options.playbooks;
421
- if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
530
+ const record = snapshotRecord(value, path);
531
+ rejectSnapshotKeys(record, ['mode', 'fileWrite', 'shellExecute', 'networkAccess', 'writablePaths'], path);
532
+ const normalized = {};
533
+ if (record.mode !== undefined) {
534
+ if (record.mode !== 'auto' && record.mode !== 'bypass') {
535
+ throw new TypeError(`${path}.mode must be "auto" or "bypass"`);
536
+ }
537
+ normalized.mode = record.mode;
538
+ }
539
+ for (const key of [
540
+ 'fileWrite',
541
+ 'shellExecute',
542
+ 'networkAccess',
543
+ ]) {
544
+ const level = record[key];
545
+ if (level === undefined)
546
+ continue;
547
+ if (level !== 'allow' && level !== 'ask' && level !== 'deny') {
548
+ throw new TypeError(`${path}.${key} must be "allow", "ask", or "deny"`);
549
+ }
550
+ normalized[key] = level;
551
+ }
552
+ if (record.writablePaths !== undefined) {
553
+ if (!Array.isArray(record.writablePaths) ||
554
+ record.writablePaths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
555
+ throw new TypeError(`${path}.writablePaths must be an array of non-empty strings`);
556
+ }
557
+ normalized.writablePaths = [...record.writablePaths];
558
+ }
559
+ return normalized;
560
+ }
561
+ function livePermissions(value) {
562
+ if (value === undefined)
422
563
  return undefined;
564
+ return {
565
+ ...(value.mode === undefined ? {} : { mode: value.mode }),
566
+ ...(value.fileWrite === undefined ? {} : { fileWrite: value.fileWrite }),
567
+ ...(value.shellExecute === undefined
568
+ ? {}
569
+ : { shellExecute: value.shellExecute }),
570
+ ...(value.networkAccess === undefined
571
+ ? {}
572
+ : { networkAccess: value.networkAccess }),
573
+ ...(value.writablePaths === undefined
574
+ ? {}
575
+ : { writablePaths: [...value.writablePaths] }),
576
+ };
577
+ }
578
+ function snapshotFixedAgent(value, path) {
579
+ const record = snapshotRecord(value, path);
580
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions'], path);
581
+ const adapter = snapshotString(record.adapter, `${path}.adapter`);
582
+ const instruction = record.instruction === undefined
583
+ ? undefined
584
+ : snapshotString(record.instruction, `${path}.instruction`, true);
585
+ const permissions = snapshotPermissions(record.permissions, `${path}.permissions`);
586
+ return {
587
+ adapter,
588
+ ...(instruction === undefined ? {} : { instruction }),
589
+ ...(permissions === undefined ? {} : { permissions }),
590
+ };
591
+ }
592
+ function snapshotPlayerSessions(value, path) {
593
+ const sessions = snapshotRecord(value, path);
594
+ return Object.fromEntries(Object.entries(sessions).map(([playerId, raw]) => {
595
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
596
+ throw new TypeError(`${path} has invalid player id ${JSON.stringify(playerId)}`);
597
+ }
598
+ const record = snapshotRecord(raw, `${path}.${playerId}`);
599
+ rejectSnapshotKeys(record, ['adapter', 'instruction', 'permissions', 'resumeToken'], `${path}.${playerId}`);
600
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'resumeToken')), `${path}.${playerId}`);
601
+ const resumeToken = record.resumeToken === undefined
602
+ ? undefined
603
+ : snapshotString(record.resumeToken, `${path}.${playerId}.resumeToken`);
604
+ return [
605
+ playerId,
606
+ { ...fixed, ...(resumeToken === undefined ? {} : { resumeToken }) },
607
+ ];
608
+ }));
609
+ }
610
+ function snapshotFrameRoleBindings(value, path) {
611
+ const bindings = snapshotRecord(value, path);
612
+ return Object.fromEntries(Object.entries(bindings).map(([roleId, raw]) => {
613
+ if (!ROLE_ID_PATTERN.test(roleId) || roleId === INTERNAL_CAPTAIN_ID) {
614
+ throw new TypeError(`${path} has invalid role id ${JSON.stringify(roleId)}`);
615
+ }
616
+ const playerId = snapshotString(raw, `${path}.${roleId}`);
617
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
618
+ throw new TypeError(`${path}.${roleId} has invalid player id`);
619
+ }
620
+ return [roleId, playerId];
621
+ }));
622
+ }
623
+ function normalizeHostPlayerResult(value, expectedPlayerId) {
624
+ const path = 'tmux-play delegated-player result';
625
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
626
+ throw new TypeError(`${path} must be an object`);
627
+ }
628
+ const prototype = Object.getPrototypeOf(value);
629
+ if (prototype !== Object.prototype && prototype !== null) {
630
+ throw new TypeError(`${path} must be a plain JSON object`);
631
+ }
632
+ const descriptors = Object.getOwnPropertyDescriptors(value);
633
+ const allowedKeys = new Set([
634
+ 'status',
635
+ 'playerId',
636
+ 'turnId',
637
+ 'resumeToken',
638
+ 'finalText',
639
+ 'error',
640
+ ]);
641
+ const normalized = {};
642
+ for (const key of Reflect.ownKeys(descriptors)) {
643
+ if (typeof key === 'symbol') {
644
+ throw new TypeError(`${path} must not contain symbol-keyed properties`);
645
+ }
646
+ const descriptor = descriptors[key];
647
+ if (!allowedKeys.has(key)) {
648
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(key)}`);
649
+ }
650
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
651
+ throw new TypeError(`${path}.${key} must be an enumerable data property`);
652
+ }
653
+ // Cligent deliberately exposes optional result members as own
654
+ // `undefined` data properties. Omit only those members before taking the
655
+ // immutable JSON snapshot; every other value still passes the strict
656
+ // JSON validator below.
657
+ if (descriptor.value !== undefined)
658
+ normalized[key] = descriptor.value;
659
+ }
660
+ const record = snapshotRecord(snapshotJsonValue(normalized, path), path);
661
+ rejectSnapshotKeys(record, ['status', 'playerId', 'turnId', 'resumeToken', 'finalText', 'error'], path);
662
+ if (record.playerId !== expectedPlayerId) {
663
+ throw new TypeError(`${path}.playerId does not match the requested player`);
664
+ }
665
+ snapshotInteger(record.turnId, `${path}.turnId`, 1);
666
+ return validatePlayerResult({
667
+ status: record.status,
668
+ ...(record.resumeToken === undefined
669
+ ? {}
670
+ : { resumeToken: record.resumeToken }),
671
+ ...(record.finalText === undefined
672
+ ? {}
673
+ : { finalText: record.finalText }),
674
+ ...(record.error === undefined ? {} : { error: record.error }),
675
+ }, path);
676
+ }
677
+ /** Validate, detach, and freeze one untrusted shell snapshot. */
678
+ export function assertPlaybookCaptainShellSnapshot(value) {
679
+ const detached = snapshotJsonValue(value, 'Captain shell snapshot');
680
+ const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
681
+ const mode = snapshot.mode;
682
+ const commonKeys = [
683
+ 'schemaVersion',
684
+ 'captain',
685
+ 'playerSessions',
686
+ 'issuedSessionIds',
687
+ 'sequences',
688
+ 'journal',
689
+ 'lastAction',
690
+ 'lastSettlementStatus',
691
+ 'mode',
692
+ ];
693
+ if (mode === 'chat') {
694
+ rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
695
+ }
696
+ else if (mode === 'engaged.parked') {
697
+ rejectSnapshotKeys(snapshot, [
698
+ ...commonKeys,
699
+ 'frames',
700
+ 'pendingBossQuestions',
701
+ 'lastError',
702
+ ], 'Captain shell snapshot');
703
+ }
704
+ else {
705
+ throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
706
+ }
707
+ if (snapshot.schemaVersion !== 3) {
708
+ throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
709
+ }
710
+ const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
711
+ rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
712
+ const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
713
+ const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
714
+ const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
715
+ const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
716
+ let normalizedConversation;
717
+ if (conversation.kind === 'pinned') {
718
+ rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
719
+ normalizedConversation = {
720
+ kind: 'pinned',
721
+ token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token'),
722
+ };
723
+ }
724
+ else if (conversation.kind === 'needsCatchUp') {
725
+ rejectSnapshotKeys(conversation, ['kind', 'resume', 'afterJournalSeq'], 'Captain shell snapshot.captain.conversation');
726
+ normalizedConversation = {
727
+ kind: 'needsCatchUp',
728
+ resume: conversation.resume === false
729
+ ? false
730
+ : snapshotString(conversation.resume, 'Captain shell snapshot.captain.conversation.resume'),
731
+ afterJournalSeq: snapshotInteger(conversation.afterJournalSeq, 'Captain shell snapshot.captain.conversation.afterJournalSeq'),
732
+ };
733
+ }
734
+ else if (conversation.kind === 'unopened' ||
735
+ conversation.kind === 'needsSeeding') {
736
+ rejectSnapshotKeys(conversation, ['kind'], 'Captain shell snapshot.captain.conversation');
737
+ normalizedConversation = { kind: conversation.kind };
738
+ }
739
+ else {
740
+ throw new TypeError('Captain shell snapshot.captain.conversation.kind is not supported');
741
+ }
742
+ if (!Array.isArray(snapshot.issuedSessionIds)) {
743
+ throw new TypeError('Captain shell snapshot.issuedSessionIds must be an array');
744
+ }
745
+ const issued = snapshot.issuedSessionIds.map((id, index) => snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`));
746
+ if (new Set(issued).size !== issued.length) {
747
+ throw new TypeError('Captain shell snapshot.issuedSessionIds must not contain duplicates');
748
+ }
749
+ if (issued[0] !== captainSessionId) {
750
+ throw new TypeError('Captain shell snapshot Captain session id must be the first issued id');
751
+ }
752
+ const sequences = snapshotRecord(snapshot.sequences, 'Captain shell snapshot.sequences');
753
+ rejectSnapshotKeys(sequences, ['turn', 'journal'], 'Captain shell snapshot.sequences');
754
+ const turnSequence = snapshotInteger(sequences.turn, 'Captain shell snapshot.sequences.turn');
755
+ const journalSequence = snapshotInteger(sequences.journal, 'Captain shell snapshot.sequences.journal');
756
+ if (!Array.isArray(snapshot.journal)) {
757
+ throw new TypeError('Captain shell snapshot.journal must be an array');
758
+ }
759
+ const normalizedJournal = [];
760
+ let previousTurn = 0;
761
+ let bossRecords = 0;
762
+ for (const [index, value] of snapshot.journal.entries()) {
763
+ const record = snapshotRecord(value, `Captain shell snapshot.journal[${index}]`);
764
+ rejectSnapshotKeys(record, ['seq', 'turnId', 'kind', 'payload'], `Captain shell snapshot.journal[${index}]`);
765
+ const seq = snapshotInteger(record.seq, `Captain shell snapshot.journal[${index}].seq`, 1);
766
+ if (seq !== index + 1) {
767
+ throw new TypeError('Captain shell snapshot journal sequence must be contiguous from one');
768
+ }
769
+ const turnId = snapshotInteger(record.turnId, `Captain shell snapshot.journal[${index}].turnId`, 1);
770
+ if (turnId < previousTurn || turnId > turnSequence) {
771
+ throw new TypeError('Captain shell snapshot journal turn ids must be ordered and in range');
772
+ }
773
+ const kind = record.kind;
774
+ if (typeof kind !== 'string' ||
775
+ !SNAPSHOT_JOURNAL_KINDS.has(kind)) {
776
+ throw new TypeError(`Captain shell snapshot.journal[${index}].kind is not supported`);
777
+ }
778
+ if (turnId !== previousTurn) {
779
+ if (turnId !== previousTurn + 1 || kind !== 'boss') {
780
+ throw new TypeError('Captain shell snapshot journal must begin every turn with one boss record');
781
+ }
782
+ bossRecords++;
783
+ previousTurn = turnId;
784
+ }
785
+ else if (kind === 'boss') {
786
+ throw new TypeError('Captain shell snapshot journal must contain one boss record per turn');
787
+ }
788
+ normalizedJournal.push({
789
+ seq,
790
+ turnId,
791
+ kind: kind,
792
+ payload: record.payload,
793
+ });
794
+ }
795
+ if (journalSequence !== normalizedJournal.length ||
796
+ bossRecords !== turnSequence) {
797
+ throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
798
+ }
799
+ const emptyHistory = turnSequence === 0 && normalizedJournal.length === 0;
800
+ if ((normalizedConversation.kind === 'unopened') !== emptyHistory) {
801
+ throw new TypeError('Captain shell snapshot history is empty exactly when its conversation is unopened');
802
+ }
803
+ if (normalizedConversation.kind === 'needsCatchUp' &&
804
+ normalizedConversation.afterJournalSeq >= journalSequence) {
805
+ throw new TypeError('Captain shell snapshot catch-up watermark must precede the current journal sequence');
806
+ }
807
+ if (normalizedConversation.kind === 'needsCatchUp' &&
808
+ ((normalizedConversation.resume === false) !==
809
+ (normalizedConversation.afterJournalSeq === 0))) {
810
+ throw new TypeError('Captain shell snapshot catch-up resume is fresh exactly at journal watermark zero');
811
+ }
812
+ let lastAction;
813
+ if (snapshot.lastAction !== undefined) {
814
+ if (typeof snapshot.lastAction !== 'string' ||
815
+ !SNAPSHOT_ACTIONS.has(snapshot.lastAction)) {
816
+ throw new TypeError('Captain shell snapshot.lastAction is not supported');
817
+ }
818
+ lastAction = snapshot.lastAction;
819
+ }
820
+ let lastSettlementStatus;
821
+ if (snapshot.lastSettlementStatus !== undefined) {
822
+ if (typeof snapshot.lastSettlementStatus !== 'string' ||
823
+ !SNAPSHOT_SETTLEMENT_STATUSES.has(snapshot.lastSettlementStatus)) {
824
+ throw new TypeError('Captain shell snapshot.lastSettlementStatus is not supported');
825
+ }
826
+ lastSettlementStatus = snapshot.lastSettlementStatus;
827
+ }
828
+ const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
829
+ const common = {
830
+ schemaVersion: 3,
831
+ captain: {
832
+ sessionId: captainSessionId,
833
+ runtime: captainRuntime,
834
+ agent: captainAgent,
835
+ conversation: normalizedConversation,
836
+ },
837
+ playerSessions,
838
+ issuedSessionIds: issued,
839
+ sequences: { turn: turnSequence, journal: journalSequence },
840
+ journal: normalizedJournal,
841
+ ...(lastAction === undefined ? {} : { lastAction }),
842
+ ...(lastSettlementStatus === undefined
843
+ ? {}
844
+ : { lastSettlementStatus }),
845
+ };
846
+ if (captainRuntime.state.status !== 'active' ||
847
+ !captainRuntime.state.quiescent ||
848
+ !captainRuntime.state.tags.includes('playbook.parked') ||
849
+ captainRuntime.suspendedCall !== undefined ||
850
+ Object.keys(captainRuntime.roleResumeTokens).length > 0 ||
851
+ captainRuntime.pendingBossQuestions.length > 0) {
852
+ throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
853
+ }
854
+ if (captainRuntime.sequences.turn !== turnSequence) {
855
+ throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
856
+ }
857
+ if (mode === 'chat') {
858
+ return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
859
+ }
860
+ if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
861
+ throw new TypeError('Captain shell snapshot.frames must be a non-empty array');
862
+ }
863
+ const normalizedFrames = [];
864
+ for (const [index, value] of snapshot.frames.entries()) {
865
+ const frame = snapshotRecord(value, `Captain shell snapshot.frames[${index}]`);
866
+ rejectSnapshotKeys(frame, [
867
+ 'playbookId',
868
+ 'sessionId',
869
+ 'rootSessionId',
870
+ 'depth',
871
+ 'parentSessionId',
872
+ 'parentCallId',
873
+ 'options',
874
+ 'roleBindings',
875
+ 'runtime',
876
+ ], `Captain shell snapshot.frames[${index}]`);
877
+ const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
878
+ const sessionId = snapshotUuid(frame.sessionId, `Captain shell snapshot.frames[${index}].sessionId`);
879
+ const rootSessionId = snapshotUuid(frame.rootSessionId, `Captain shell snapshot.frames[${index}].rootSessionId`);
880
+ const depth = snapshotInteger(frame.depth, `Captain shell snapshot.frames[${index}].depth`);
881
+ const parentSessionId = frame.parentSessionId === undefined
882
+ ? undefined
883
+ : snapshotUuid(frame.parentSessionId, `Captain shell snapshot.frames[${index}].parentSessionId`);
884
+ const parentCallId = frame.parentCallId === undefined
885
+ ? undefined
886
+ : snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
887
+ const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
888
+ const options = frame.options;
889
+ const roleBindings = snapshotFrameRoleBindings(frame.roleBindings, `Captain shell snapshot.frames[${index}].roleBindings`);
890
+ normalizedFrames.push({
891
+ playbookId,
892
+ sessionId,
893
+ rootSessionId,
894
+ depth,
895
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
896
+ ...(parentCallId === undefined ? {} : { parentCallId }),
897
+ options,
898
+ roleBindings,
899
+ runtime,
900
+ });
901
+ }
902
+ let normalizedLastError;
903
+ if (snapshot.lastError !== undefined) {
904
+ const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
905
+ rejectSnapshotKeys(error, ['name', 'message'], 'Captain shell snapshot.lastError');
906
+ normalizedLastError = {
907
+ name: snapshotString(error.name, 'Captain shell snapshot.lastError.name', true),
908
+ message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
909
+ };
910
+ }
911
+ const activePlaybooks = new Set();
912
+ const activeSessionIds = new Set([captainSessionId]);
913
+ const issuedIds = new Set(issued);
914
+ const rootSessionId = normalizedFrames[0].sessionId;
915
+ for (const [index, frame] of normalizedFrames.entries()) {
916
+ if (activePlaybooks.has(frame.playbookId)) {
917
+ throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
918
+ }
919
+ activePlaybooks.add(frame.playbookId);
920
+ if (activeSessionIds.has(frame.sessionId)) {
921
+ throw new TypeError('Captain shell snapshot frame session ids must be unique');
922
+ }
923
+ activeSessionIds.add(frame.sessionId);
924
+ if (!issuedIds.has(frame.sessionId)) {
925
+ throw new TypeError('Captain shell snapshot frame session id was not historically issued');
926
+ }
927
+ if (frame.depth !== index ||
928
+ frame.rootSessionId !== rootSessionId ||
929
+ frame.runtime.state.status !== 'active' ||
930
+ !frame.runtime.state.quiescent) {
931
+ throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
932
+ }
933
+ if (index === 0) {
934
+ if (frame.sessionId !== frame.rootSessionId ||
935
+ frame.parentSessionId !== undefined ||
936
+ frame.parentCallId !== undefined) {
937
+ throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
938
+ }
939
+ }
940
+ else {
941
+ const parent = normalizedFrames[index - 1];
942
+ const pending = parent.runtime.suspendedCall;
943
+ if (frame.parentSessionId !== parent.sessionId ||
944
+ frame.parentCallId === undefined) {
945
+ throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
946
+ }
947
+ if (!pending ||
948
+ pending.callId !== frame.parentCallId ||
949
+ pending.playbookId !== frame.playbookId ||
950
+ pending.childSessionId !== frame.sessionId) {
951
+ throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
952
+ }
953
+ }
954
+ for (const playerId of Object.values(frame.roleBindings)) {
955
+ if (playerSessions[playerId] === undefined) {
956
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} binds an absent session player`);
957
+ }
958
+ }
959
+ for (const question of frame.runtime.pendingBossQuestions) {
960
+ if (question.asker.kind === 'role' &&
961
+ frame.roleBindings[question.asker.roleId] === undefined) {
962
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} has a pending question from an unbound role`);
963
+ }
964
+ }
965
+ const projectedTokens = Object.fromEntries(Object.entries(frame.roleBindings).flatMap(([role, playerId]) => {
966
+ const token = playerSessions[playerId]?.resumeToken;
967
+ return token === undefined ? [] : [[role, token]];
968
+ }));
969
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.roleResumeTokens)) {
970
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match session continuation`);
971
+ }
423
972
  }
424
- return pb;
973
+ const leafRuntime = normalizedFrames.at(-1).runtime;
974
+ if (leafRuntime.suspendedCall !== undefined ||
975
+ !leafRuntime.state.tags.includes('playbook.parked')) {
976
+ throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
977
+ }
978
+ if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
979
+ throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
980
+ }
981
+ return snapshotJsonValue({
982
+ ...common,
983
+ mode,
984
+ frames: normalizedFrames,
985
+ ...(snapshot.pendingBossQuestions === undefined
986
+ ? {}
987
+ : { pendingBossQuestions: snapshot.pendingBossQuestions }),
988
+ ...(normalizedLastError === undefined
989
+ ? {}
990
+ : { lastError: normalizedLastError }),
991
+ }, 'Captain shell snapshot');
992
+ }
993
+ function snapshotTuningSelection(value, path) {
994
+ const selection = snapshotRecord(value, path);
995
+ if (selection.kind === 'provider-default') {
996
+ rejectSnapshotKeys(selection, ['kind'], path);
997
+ return { kind: 'provider-default' };
998
+ }
999
+ if (selection.kind === 'value') {
1000
+ rejectSnapshotKeys(selection, ['kind', 'value'], path);
1001
+ return {
1002
+ kind: 'value',
1003
+ value: snapshotString(selection.value, `${path}.value`),
1004
+ };
1005
+ }
1006
+ throw new TypeError(`${path}.kind must be "value" or "provider-default"`);
1007
+ }
1008
+ const EFFORT_VALUES = new Set([
1009
+ 'on',
1010
+ 'minimal',
1011
+ 'low',
1012
+ 'medium',
1013
+ 'high',
1014
+ 'xhigh',
1015
+ 'max',
1016
+ 'ultra',
1017
+ 'ultracode',
1018
+ 'off',
1019
+ ]);
1020
+ function snapshotEffortSelection(value, path) {
1021
+ const selection = snapshotTuningSelection(value, path);
1022
+ if (selection.kind === 'value' && !EFFORT_VALUES.has(selection.value)) {
1023
+ throw new TypeError(`${path}.value is not a supported effort selection`);
1024
+ }
1025
+ return selection;
1026
+ }
1027
+ function snapshotSessionAgent(value, path) {
1028
+ const agent = snapshotRecord(value, path);
1029
+ rejectSnapshotKeys(agent, ['adapter', 'model', 'effort', 'instruction', 'permissions'], path);
1030
+ const fixed = snapshotFixedAgent(Object.fromEntries(Object.entries(agent).filter(([key]) => key !== 'model' && key !== 'effort')), path);
1031
+ return {
1032
+ adapter: fixed.adapter,
1033
+ ...(fixed.instruction === undefined
1034
+ ? {}
1035
+ : { instruction: fixed.instruction }),
1036
+ ...(fixed.permissions === undefined
1037
+ ? {}
1038
+ : { permissions: livePermissions(fixed.permissions) }),
1039
+ model: snapshotTuningSelection(agent.model, `${path}.model`),
1040
+ effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
1041
+ };
1042
+ }
1043
+ function fixedAgent(agent) {
1044
+ return {
1045
+ adapter: agent.adapter,
1046
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1047
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1048
+ };
425
1049
  }
426
- // Resolve the active registry at init from `captain.options.playbooks`
427
- // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
428
- // module and bound to namespaced `<id>-<role>` host players.
429
- async function buildEnablements(options, players, loadModule) {
1050
+ function callSettings(agent, tuning = agent) {
1051
+ return {
1052
+ model: tuning.model,
1053
+ effort: tuning.effort,
1054
+ ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1055
+ ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1056
+ };
1057
+ }
1058
+ function promptIdentity(binding) {
1059
+ return binding.model.kind === 'value'
1060
+ ? binding.model.value
1061
+ : binding.agent.adapter;
1062
+ }
1063
+ // Resolve the active registry at init from exact normalized role and session
1064
+ // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1065
+ // exists at this boundary.
1066
+ async function buildEnablements(options, loadModule) {
430
1067
  const entries = [];
431
1068
  const byCommand = new Map();
432
1069
  const byId = new Map();
433
1070
  const enablementById = new Map();
434
- const config = readPlaybooksConfig(options);
435
- if (config === undefined) {
1071
+ const detached = snapshotJsonValue(options, 'captain.options');
1072
+ const top = snapshotRecord(detached, 'captain.options');
1073
+ rejectSnapshotKeys(top, ['playbooks', 'sessionAgents', 'captainAdapter'], 'captain.options');
1074
+ const configValue = top.playbooks;
1075
+ if (typeof configValue !== 'object' ||
1076
+ configValue === null ||
1077
+ Array.isArray(configValue)) {
436
1078
  throw new Error('captain.options.playbooks is required');
437
1079
  }
1080
+ const config = configValue;
1081
+ const sessionAgents = snapshotRecord(top.sessionAgents, 'captain.options.sessionAgents');
1082
+ rejectSnapshotKeys(sessionAgents, ['captain', 'players'], 'captain.options.sessionAgents');
1083
+ const captainAgent = snapshotSessionAgent(sessionAgents.captain, 'captain.options.sessionAgents.captain');
1084
+ if (top.captainAdapter !== undefined &&
1085
+ top.captainAdapter !== captainAgent.adapter) {
1086
+ throw new Error('captain.options.captainAdapter must equal sessionAgents.captain.adapter');
1087
+ }
1088
+ const playerAgentRecord = snapshotRecord(sessionAgents.players, 'captain.options.sessionAgents.players');
1089
+ const playerAgents = new Map();
1090
+ for (const [playerId, agent] of Object.entries(playerAgentRecord)) {
1091
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1092
+ throw new Error(`captain.options.sessionAgents.players has invalid player id ${JSON.stringify(playerId)}`);
1093
+ }
1094
+ playerAgents.set(playerId, snapshotSessionAgent(agent, `captain.options.sessionAgents.players.${playerId}`));
1095
+ }
438
1096
  const ids = Object.keys(config);
439
1097
  if (ids.length === 0) {
440
1098
  throw new Error('captain.options.playbooks must enable at least one playbook');
@@ -448,6 +1106,7 @@ async function buildEnablements(options, players, loadModule) {
448
1106
  throw new Error(`captain.options.playbooks.${id} must be an object`);
449
1107
  }
450
1108
  const record = block;
1109
+ rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
451
1110
  const from = record.from;
452
1111
  if (typeof from !== 'string' || from.length === 0) {
453
1112
  throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
@@ -478,41 +1137,83 @@ async function buildEnablements(options, players, loadModule) {
478
1137
  if (byCommand.has(command)) {
479
1138
  throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
480
1139
  }
481
- const boundPlayers = entry.requiredRoleIds.map((role) => {
482
- const host = players.find((p) => p.id === `${entry.id}-${role}`);
483
- return {
484
- id: role,
485
- ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
486
- ...(host?.model !== undefined ? { model: host.model } : {}),
487
- };
488
- });
1140
+ const roleRecord = snapshotRecord(record.roles, `captain.options.playbooks.${id}.roles`);
1141
+ const required = new Set(entry.requiredRoleIds);
1142
+ const configuredRoles = Object.keys(roleRecord);
1143
+ const missing = entry.requiredRoleIds.filter((role) => !Object.hasOwn(roleRecord, role));
1144
+ const extra = configuredRoles.filter((role) => !required.has(role));
1145
+ if (missing.length > 0 || extra.length > 0) {
1146
+ throw new Error(`captain.options.playbooks.${id}.roles must exactly cover requiredRoleIds`);
1147
+ }
1148
+ const roleBindings = new Map();
1149
+ for (const role of entry.requiredRoleIds) {
1150
+ const path = `captain.options.playbooks.${id}.roles.${role}`;
1151
+ const rawBinding = snapshotRecord(roleRecord[role], path);
1152
+ rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
1153
+ const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
1154
+ if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
1155
+ throw new Error(`${path}.playerId is not a canonical player id`);
1156
+ }
1157
+ const agent = playerAgents.get(playerId);
1158
+ if (!agent) {
1159
+ throw new Error(`${path}.playerId names absent session player ${JSON.stringify(playerId)}`);
1160
+ }
1161
+ roleBindings.set(role, {
1162
+ playerId,
1163
+ model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
1164
+ effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
1165
+ agent,
1166
+ });
1167
+ }
1168
+ for (const concurrentRoles of entry.concurrentRoleSets) {
1169
+ const playerIds = concurrentRoles.map((role) => roleBindings.get(role).playerId);
1170
+ if (new Set(playerIds).size !== playerIds.length) {
1171
+ throw new Error(`captain.options.playbooks.${id}.roles aliases concurrent roles ${JSON.stringify(concurrentRoles)}`);
1172
+ }
1173
+ }
1174
+ const validatedOptions = snapshotJsonValue(entry.validateOptions(record.options), `captain.options.playbooks.${id}.options`);
489
1175
  entries.push(entry);
490
1176
  byId.set(entry.id, entry);
491
1177
  byCommand.set(command, entry);
492
1178
  enablementById.set(entry.id, {
493
1179
  entry,
494
1180
  command,
495
- optionInput: record.options,
496
- boundPlayers,
497
- hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
1181
+ options: validatedOptions,
1182
+ roleBindings,
498
1183
  });
499
1184
  }
500
- return { entries, byCommand, byId, enablementById };
1185
+ const referenced = new Set([...enablementById.values()].flatMap((enablement) => [...enablement.roleBindings.values()].map((binding) => binding.playerId)));
1186
+ const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
1187
+ if (unreferenced !== undefined) {
1188
+ throw new Error(`captain.options.sessionAgents.players has unreferenced player ${JSON.stringify(unreferenced)}`);
1189
+ }
1190
+ return {
1191
+ entries,
1192
+ byCommand,
1193
+ byId,
1194
+ enablementById,
1195
+ captainAgent,
1196
+ playerAgents,
1197
+ };
501
1198
  }
502
1199
  export function createPlaybookCaptainShell(options, deps = {}) {
503
1200
  const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
504
1201
  const createSessionId = deps.createSessionId ?? randomUUID;
505
1202
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
506
- // DR-013 A1: the launcher passes the resolved captain adapter through
507
- // `captain.options`; a raw `--config` launch leaves it undefined, which
508
- // keeps the enforced empty allowlist and its fail-closed behavior.
509
- const captainAdapter = readCaptainAdapter(options);
1203
+ let captainAgent;
1204
+ let captainAdapter;
1205
+ let playerAgents = new Map();
1206
+ const playerLedger = new Map();
1207
+ const playerTransactions = new Map();
510
1208
  let entries = [];
511
1209
  let byCommand = new Map();
512
1210
  let byId = new Map();
513
1211
  let enablementById = new Map();
514
1212
  let session;
515
- let players = [];
1213
+ let sessionEmissionsOpen = false;
1214
+ let closedGateAttempted = false;
1215
+ let lifecycle = 'fresh';
1216
+ let terminallyDisposed = false;
516
1217
  let activeContext;
517
1218
  const frames = [];
518
1219
  let mode = 'chat';
@@ -524,18 +1225,57 @@ export function createPlaybookCaptainShell(options, deps = {}) {
524
1225
  const pendingChildParents = new Set();
525
1226
  const captainQueue = new PQueue({ concurrency: 1 });
526
1227
  let disposing = false;
1228
+ const admitHostBoundary = () => {
1229
+ if (sessionEmissionsOpen)
1230
+ return;
1231
+ closedGateAttempted = true;
1232
+ throw new Error('Captain shell host boundaries are closed during restore');
1233
+ };
1234
+ const admitHostEmission = () => {
1235
+ if (sessionEmissionsOpen)
1236
+ return true;
1237
+ closedGateAttempted = true;
1238
+ return false;
1239
+ };
1240
+ const installSession = (initSession, emissionsOpen) => {
1241
+ sessionEmissionsOpen = emissionsOpen;
1242
+ closedGateAttempted = false;
1243
+ session = {
1244
+ signal: initSession.signal,
1245
+ players: initSession.players,
1246
+ emitStatus: async (message, data) => {
1247
+ if (!admitHostEmission())
1248
+ return;
1249
+ await initSession.emitStatus(message, data);
1250
+ },
1251
+ emitTelemetry: async (event) => {
1252
+ if (!admitHostEmission())
1253
+ return;
1254
+ await initSession.emitTelemetry(event);
1255
+ },
1256
+ setVisiblePlayers: async (playerIds) => {
1257
+ if (!admitHostEmission())
1258
+ return;
1259
+ await initSession.setVisiblePlayers(playerIds);
1260
+ },
1261
+ };
1262
+ };
527
1263
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
528
1264
  let captainRuntime;
529
1265
  let captainSessionId;
530
- // CAPTAIN-35: the conversation is exactly one of unopened, pinned, or
531
- // owed-a-reseed. There is no fourth state in which a non-first call starts a
532
- // bare conversation.
1266
+ // CAPTAIN-35: a preflight settings rejection retains proven continuity but
1267
+ // records the exact journal suffix still owed; other continuity failures
1268
+ // require a fresh, full reseed.
533
1269
  let conversation = { kind: 'unopened' };
534
1270
  let shuttingDown = false;
535
1271
  const journal = [];
536
1272
  let journalSeq = 0;
537
1273
  let turnSequence = 0;
538
1274
  let activeTurn;
1275
+ // `PlayerSessionStore.restore` is authoritative only while the shell is
1276
+ // awaiting the exact owning runtime's restore during a closed-gate shell
1277
+ // restoration. A runtime cannot use the store as a general ledger writer.
1278
+ let restoringPlayerSessionFrame;
539
1279
  // The durable call the runtime is about to make, taken from the paired
540
1280
  // `captain.call.started` boundary the engine emits before the port call
541
1281
  // (CAPTAIN-9): the shell never infers a call's kind from its prose.
@@ -603,7 +1343,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
603
1343
  // (CAPTAIN-5/CAPTAIN-6).
604
1344
  ...(captainRuntime
605
1345
  ? {
606
- durableConversation: conversation.kind === 'pinned',
1346
+ durableConversation: conversation.kind === 'pinned' ||
1347
+ (conversation.kind === 'needsCatchUp' &&
1348
+ conversation.resume !== false),
607
1349
  sessionJournal: true,
608
1350
  }
609
1351
  : {}),
@@ -759,40 +1501,133 @@ export function createPlaybookCaptainShell(options, deps = {}) {
759
1501
  };
760
1502
  let callNestedPlaybook;
761
1503
  const createPorts = (frame) => ({
762
- callPlayer: async (playerId, prompt, signal, options) => {
763
- if (!activeContext) {
1504
+ callPlayer: async (roleId, prompt, signal, options) => {
1505
+ admitHostBoundary();
1506
+ if (!activeContext || !activeTurn || !frame.playerCallScope) {
764
1507
  throw new Error('callPlayer invoked outside a Boss turn');
765
1508
  }
766
1509
  const context = activeContext;
1510
+ const admittedTurn = activeTurn;
1511
+ const scope = frame.playerCallScope;
767
1512
  signal.throwIfAborted();
768
- const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
769
- const result = await trackHostCall(frame, context.callPlayer(hostPlayerId, prompt, {
770
- resume: options.resume,
771
- }));
772
- // CaptainContext is turn-scoped and cannot accept a narrower XState
773
- // invocation signal. Recheck after the host call so a sibling
774
- // cancellation is still reported as aborted and cannot rotate a
775
- // stopped branch's player token in the linked runtime.
776
- signal.throwIfAborted();
777
- // CAPTAIN-20: only a player call that actually produced work is an
778
- // interruption the Boss was spared. A call that errored or aborted
779
- // saved nothing, so it never feeds the saved-counts gate.
780
- const summary = activeTurnSummary;
781
- if (summary && summaryIncludes(frame) && result.status === 'ok') {
782
- summary.counts.interruptions++;
1513
+ const binding = bindingFor(frame, roleId);
1514
+ const ledger = playerLedger.get(binding.playerId);
1515
+ if (!ledger) {
1516
+ throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
783
1517
  }
784
- return {
785
- status: result.status,
786
- ...(result.resumeToken !== undefined
787
- ? { resumeToken: result.resumeToken }
788
- : {}),
789
- ...(result.finalText !== undefined
790
- ? { finalText: result.finalText }
791
- : {}),
792
- ...(result.error !== undefined ? { error: result.error } : {}),
1518
+ const expectedResume = ledger.resumeToken ?? false;
1519
+ if (options.resume !== expectedResume) {
1520
+ throw new Error(`${frameLabel(frame)} player continuation changed before dispatch`);
1521
+ }
1522
+ if (playerTransactions.has(binding.playerId)) {
1523
+ throw new Error(`session player ${JSON.stringify(binding.playerId)} already has a call in flight`);
1524
+ }
1525
+ const settings = callSettings(binding.agent, binding);
1526
+ const calling = {
1527
+ phase: 'calling',
1528
+ frame,
1529
+ roleId,
1530
+ turnId: admittedTurn.id,
1531
+ signal,
1532
+ scope,
1533
+ abandoned: false,
793
1534
  };
1535
+ playerTransactions.set(binding.playerId, calling);
1536
+ let result;
1537
+ let hostResolved = false;
1538
+ try {
1539
+ let rawResult;
1540
+ try {
1541
+ rawResult = await trackHostCall(frame, classifySettingsCall(() => context.callPlayer(binding.playerId, prompt, {
1542
+ resume: options.resume,
1543
+ settings,
1544
+ })));
1545
+ hostResolved = true;
1546
+ }
1547
+ catch (error) {
1548
+ if (error instanceof AgentSettingsPreflightError) {
1549
+ if (playerTransactions.get(binding.playerId) !== calling ||
1550
+ calling.abandoned ||
1551
+ signal.aborted ||
1552
+ activeTurn !== admittedTurn ||
1553
+ frame.playerCallScope !== scope ||
1554
+ !frames.includes(frame)) {
1555
+ if (playerTransactions.get(binding.playerId) === calling) {
1556
+ playerTransactions.delete(binding.playerId);
1557
+ }
1558
+ signal.throwIfAborted();
1559
+ throw new Error(`${frameLabel(frame)} player settings rejection arrived after its runtime operation ended`);
1560
+ }
1561
+ throw rememberSettingsPreflight(error.rejection);
1562
+ }
1563
+ throw error;
1564
+ }
1565
+ result = normalizeHostPlayerResult(rawResult, binding.playerId);
1566
+ const transitionRequired = result.resumeToken !== undefined || result.status === 'ok';
1567
+ if (playerTransactions.get(binding.playerId) !== calling ||
1568
+ calling.abandoned ||
1569
+ signal.aborted ||
1570
+ activeTurn !== admittedTurn ||
1571
+ frame.playerCallScope !== scope ||
1572
+ !frames.includes(frame)) {
1573
+ if (playerTransactions.get(binding.playerId) === calling) {
1574
+ if (transitionRequired) {
1575
+ playerTransactions.set(binding.playerId, {
1576
+ phase: 'quarantined',
1577
+ frame,
1578
+ roleId,
1579
+ turnId: admittedTurn.id,
1580
+ signal,
1581
+ scope,
1582
+ reason: 'a transition-worthy result arrived after its runtime operation ended',
1583
+ });
1584
+ }
1585
+ else {
1586
+ playerTransactions.delete(binding.playerId);
1587
+ }
1588
+ }
1589
+ signal.throwIfAborted();
1590
+ throw new Error(`${frameLabel(frame)} player result arrived after its runtime operation ended`);
1591
+ }
1592
+ if (transitionRequired) {
1593
+ playerTransactions.set(binding.playerId, {
1594
+ phase: 'awaitingCommit',
1595
+ frame,
1596
+ roleId,
1597
+ turnId: admittedTurn.id,
1598
+ signal,
1599
+ scope,
1600
+ status: result.status,
1601
+ expectedToken: result.resumeToken,
1602
+ });
1603
+ }
1604
+ else {
1605
+ playerTransactions.delete(binding.playerId);
1606
+ }
1607
+ }
1608
+ catch (error) {
1609
+ if (playerTransactions.get(binding.playerId) === calling) {
1610
+ if (hostResolved) {
1611
+ playerTransactions.set(binding.playerId, {
1612
+ phase: 'quarantined',
1613
+ frame,
1614
+ roleId,
1615
+ turnId: admittedTurn.id,
1616
+ signal,
1617
+ scope,
1618
+ reason: 'a late player result could not be validated',
1619
+ });
1620
+ }
1621
+ else {
1622
+ playerTransactions.delete(binding.playerId);
1623
+ }
1624
+ }
1625
+ throw error;
1626
+ }
1627
+ return result;
794
1628
  },
795
1629
  callCaptain: async (prompt, signal, options) => {
1630
+ admitHostBoundary();
796
1631
  if (!activeContext) {
797
1632
  throw new Error('callCaptain invoked outside a Boss turn');
798
1633
  }
@@ -810,6 +1645,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
810
1645
  };
811
1646
  },
812
1647
  callJudge: async (prompt, signal) => {
1648
+ admitHostBoundary();
813
1649
  if (!activeContext) {
814
1650
  throw new Error('callJudge invoked outside a Boss turn');
815
1651
  }
@@ -835,6 +1671,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
835
1671
  return result.finalText;
836
1672
  },
837
1673
  callPlaybook: (request, signal) => {
1674
+ admitHostBoundary();
838
1675
  const opening = callNestedPlaybook(frame, request, signal);
839
1676
  let exposed;
840
1677
  const registerOpeningCleanup = () => {
@@ -848,23 +1685,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
848
1685
  registerOpeningCleanup();
849
1686
  return exposed;
850
1687
  },
851
- emitStatus: async (message, data) => {
852
- await requireSession().emitStatus(message, data);
1688
+ emitStatus: (message, data) => {
1689
+ if (!admitHostEmission())
1690
+ return Promise.resolve();
1691
+ return trackHostCall(frame, (async () => {
1692
+ await requireSession().emitStatus(message, data);
1693
+ })());
853
1694
  },
854
- emitTelemetry: async (event) => {
855
- if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
856
- await mirrorSubRuntimeTelemetry(frame, event.payload);
857
- }
858
- await requireSession().emitTelemetry(event);
1695
+ emitTelemetry: (event) => {
1696
+ if (!admitHostEmission())
1697
+ return Promise.resolve();
1698
+ const emission = (async () => {
1699
+ if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1700
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
1701
+ }
1702
+ await requireSession().emitTelemetry(event);
1703
+ })();
1704
+ return trackHostCall(frame, emission);
859
1705
  },
860
1706
  });
861
1707
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
862
- // visibility for that playbook's generated host players. A pane
1708
+ // visibility for that playbook's explicitly bound session players. A pane
863
1709
  // reconciliation failure is display-only in tmux-play and does not
864
1710
  // reject; the legacy path carries no generated set and skips this.
865
1711
  const requestVisibility = async (frame) => {
866
- const ids = [...new Set([...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId))];
867
- if (!ids || ids.length === 0 || !activeContext)
1712
+ const ids = [...new Set([...frame.playerBindings.values()].map(({ playerId }) => playerId))];
1713
+ // A roleless frame does not ask a non-empty host roster to show `[]`:
1714
+ // tmux-play reserves that value for a genuinely empty configured roster.
1715
+ if (ids.length === 0 || !activeContext)
868
1716
  return;
869
1717
  try {
870
1718
  await activeContext.setVisiblePlayers(ids);
@@ -896,34 +1744,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
896
1744
  : undefined;
897
1745
  return typeof stack === 'string' ? { ...compact, stack } : compact;
898
1746
  };
1747
+ const makePlayerBindings = (enablement) => {
1748
+ return new Map(enablement.roleBindings);
1749
+ };
899
1750
  const makeFrame = (enablement, parent) => {
900
1751
  const entry = enablement.entry;
901
1752
  const sessionId = allocateSessionId();
902
- const playerBindings = new Map();
903
- for (const role of entry.requiredRoleIds) {
904
- let inherited;
905
- for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
906
- inherited = ancestor.playerBindings.get(role);
907
- }
908
- if (inherited) {
909
- playerBindings.set(role, inherited);
910
- continue;
911
- }
912
- const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
913
- playerBindings.set(role, {
914
- hostPlayerId: enablement.hostPlayerId(role),
915
- player: configured,
916
- });
917
- }
918
- const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
919
- const runtime = entry.createRuntime({
920
- captainOptions: enablement.optionInput,
921
- players: [...playerBindings].map(([role, { player }]) => ({
922
- id: role,
923
- ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
924
- ...(player.model === undefined ? {} : { model: player.model }),
925
- })),
926
- });
1753
+ const playerBindings = makePlayerBindings(enablement);
1754
+ const runtime = entry.createRuntime(enablement.options);
927
1755
  return {
928
1756
  entry,
929
1757
  enablement,
@@ -932,59 +1760,188 @@ export function createPlaybookCaptainShell(options, deps = {}) {
932
1760
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
933
1761
  depth: parent ? parent.frame.depth + 1 : 0,
934
1762
  playerBindings,
935
- playerResumeTokens,
936
1763
  ...(parent ? { parent } : {}),
937
1764
  inFlightHostCalls: new Set(),
938
1765
  };
939
1766
  };
1767
+ const makeRestoredFrame = (enablement, snapshot, parent) => {
1768
+ const entry = enablement.entry;
1769
+ const playerBindings = makePlayerBindings(enablement);
1770
+ const runtime = entry.createRuntime(enablement.options);
1771
+ return {
1772
+ entry,
1773
+ enablement,
1774
+ runtime,
1775
+ sessionId: snapshot.sessionId,
1776
+ rootSessionId: snapshot.rootSessionId,
1777
+ depth: snapshot.depth,
1778
+ playerBindings,
1779
+ ...(parent ? { parent } : {}),
1780
+ state: snapshot.runtime.state,
1781
+ inFlightHostCalls: new Set(),
1782
+ };
1783
+ };
940
1784
  const playerSessionStore = (frame) => ({
941
1785
  select(playerId) {
942
1786
  const binding = bindingFor(frame, playerId);
943
- return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
1787
+ return playerLedger.get(binding.playerId)?.resumeToken ?? false;
944
1788
  },
945
1789
  update(playerId, resumeToken) {
946
1790
  const binding = bindingFor(frame, playerId);
947
- if (resumeToken === undefined) {
948
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1791
+ const ledger = playerLedger.get(binding.playerId);
1792
+ if (!ledger) {
1793
+ throw new Error(`${frameLabel(frame)} resolved absent session player ${JSON.stringify(binding.playerId)}`);
949
1794
  }
950
- else {
951
- frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
1795
+ const pending = playerTransactions.get(binding.playerId);
1796
+ if (pending?.phase !== 'awaitingCommit' ||
1797
+ pending.frame !== frame ||
1798
+ pending.roleId !== playerId ||
1799
+ pending.scope !== frame.playerCallScope ||
1800
+ pending.expectedToken !== resumeToken) {
1801
+ throw new Error(`${frameLabel(frame)} player update does not acknowledge a validated host result`);
1802
+ }
1803
+ if (pending.signal.aborted || activeTurn?.id !== pending.turnId) {
1804
+ playerTransactions.set(binding.playerId, {
1805
+ phase: 'quarantined',
1806
+ frame: pending.frame,
1807
+ roleId: pending.roleId,
1808
+ turnId: pending.turnId,
1809
+ signal: pending.signal,
1810
+ scope: pending.scope,
1811
+ reason: 'the runtime aborted before committing a validated result',
1812
+ });
1813
+ throw new Error(`${frameLabel(frame)} rejected a late or aborted player continuation update`);
1814
+ }
1815
+ try {
1816
+ if (resumeToken === undefined)
1817
+ delete ledger.resumeToken;
1818
+ else
1819
+ ledger.resumeToken = resumeToken;
1820
+ // CAPTAIN-20: a result counts only after the runtime validated it and
1821
+ // atomically published its authorized continuation transition.
1822
+ const summary = activeTurnSummary;
1823
+ if (pending.status === 'ok' &&
1824
+ summary &&
1825
+ summaryIncludes(frame)) {
1826
+ summary.counts.interruptions++;
1827
+ }
1828
+ }
1829
+ finally {
1830
+ playerTransactions.delete(binding.playerId);
952
1831
  }
953
1832
  },
954
1833
  snapshot() {
955
1834
  const tokens = {};
956
1835
  for (const [playerId, binding] of frame.playerBindings) {
957
- const token = frame.playerResumeTokens.get(binding.hostPlayerId);
1836
+ const token = playerLedger.get(binding.playerId)?.resumeToken;
958
1837
  if (token !== undefined)
959
1838
  tokens[playerId] = token;
960
1839
  }
961
1840
  return tokens;
962
1841
  },
963
1842
  restore(tokens) {
964
- for (const binding of frame.playerBindings.values()) {
965
- frame.playerResumeTokens.delete(binding.hostPlayerId);
1843
+ if (lifecycle !== 'restoring' ||
1844
+ restoringPlayerSessionFrame !== frame) {
1845
+ throw new Error(`${frameLabel(frame)} player-session restore is only available during shell restoration`);
966
1846
  }
1847
+ const byPlayer = new Map();
967
1848
  for (const [playerId, token] of Object.entries(tokens)) {
968
1849
  const binding = bindingFor(frame, playerId);
969
- frame.playerResumeTokens.set(binding.hostPlayerId, token);
1850
+ const previous = byPlayer.get(binding.playerId);
1851
+ if (previous !== undefined && previous !== token) {
1852
+ throw new Error(`${frameLabel(frame)} restored conflicting tokens for shared player ${JSON.stringify(binding.playerId)}`);
1853
+ }
1854
+ byPlayer.set(binding.playerId, token);
1855
+ }
1856
+ for (const binding of frame.playerBindings.values()) {
1857
+ if (!byPlayer.has(binding.playerId))
1858
+ byPlayer.set(binding.playerId, undefined);
1859
+ }
1860
+ for (const [playerId, token] of byPlayer) {
1861
+ const ledger = playerLedger.get(playerId);
1862
+ if (!ledger) {
1863
+ throw new Error(`${frameLabel(frame)} restored absent session player ${JSON.stringify(playerId)}`);
1864
+ }
1865
+ if (token === undefined)
1866
+ delete ledger.resumeToken;
1867
+ else
1868
+ ledger.resumeToken = token;
970
1869
  }
971
1870
  },
972
1871
  });
1872
+ const closePlayerCallScope = (frame, scope) => {
1873
+ if (frame.playerCallScope === scope)
1874
+ frame.playerCallScope = undefined;
1875
+ const missing = [];
1876
+ for (const [playerId, transaction] of playerTransactions) {
1877
+ if (transaction.frame !== frame || transaction.scope !== scope)
1878
+ continue;
1879
+ if (transaction.phase === 'calling') {
1880
+ transaction.abandoned = true;
1881
+ }
1882
+ else {
1883
+ playerTransactions.set(playerId, {
1884
+ phase: 'quarantined',
1885
+ frame: transaction.frame,
1886
+ roleId: transaction.roleId,
1887
+ turnId: transaction.turnId,
1888
+ signal: transaction.signal,
1889
+ scope: transaction.scope,
1890
+ reason: transaction.phase === 'awaitingCommit'
1891
+ ? 'the runtime returned without committing a validated result'
1892
+ : transaction.reason,
1893
+ });
1894
+ }
1895
+ if (!transaction.signal.aborted)
1896
+ missing.push(playerId);
1897
+ }
1898
+ return missing.length === 0
1899
+ ? undefined
1900
+ : new Error(`${frameLabel(frame)} runtime returned without committing validated player result for ${missing.map((id) => JSON.stringify(id)).join(', ')}`);
1901
+ };
1902
+ const runFrameOperation = async (frame, operation) => {
1903
+ if (frame.playerCallScope !== undefined) {
1904
+ throw new Error(`${frameLabel(frame)} runtime operations must not overlap`);
1905
+ }
1906
+ const scope = {};
1907
+ frame.playerCallScope = scope;
1908
+ let outcome;
1909
+ try {
1910
+ outcome = { ok: true, value: await operation() };
1911
+ }
1912
+ catch (error) {
1913
+ outcome = { ok: false, error };
1914
+ }
1915
+ const cleanupError = closePlayerCallScope(frame, scope);
1916
+ if (!outcome.ok)
1917
+ throw outcome.error;
1918
+ if (cleanupError !== undefined)
1919
+ throw cleanupError;
1920
+ return outcome.value;
1921
+ };
1922
+ const frameSession = (frame) => ({
1923
+ sessionId: frame.sessionId,
1924
+ playbookId: frame.entry.id,
1925
+ rootSessionId: frame.rootSessionId,
1926
+ ...(frame.parent
1927
+ ? {
1928
+ parentSessionId: frame.parent.frame.sessionId,
1929
+ parentCallId: frame.parent.callId,
1930
+ }
1931
+ : {}),
1932
+ depth: frame.depth,
1933
+ roleBindings: Object.fromEntries([...frame.playerBindings].map(([roleId, binding]) => [
1934
+ roleId,
1935
+ {
1936
+ playerId: binding.playerId,
1937
+ promptIdentity: promptIdentity(binding),
1938
+ },
1939
+ ])),
1940
+ playerSessions: playerSessionStore(frame),
1941
+ ports: createPorts(frame),
1942
+ });
973
1943
  const initFrame = async (frame) => {
974
- await frame.runtime.init({
975
- sessionId: frame.sessionId,
976
- playbookId: frame.entry.id,
977
- rootSessionId: frame.rootSessionId,
978
- ...(frame.parent
979
- ? {
980
- parentSessionId: frame.parent.frame.sessionId,
981
- parentCallId: frame.parent.callId,
982
- }
983
- : {}),
984
- depth: frame.depth,
985
- playerSessions: playerSessionStore(frame),
986
- ports: createPorts(frame),
987
- });
1944
+ await frame.runtime.init(frameSession(frame));
988
1945
  };
989
1946
  const clearLeafLedger = () => {
990
1947
  pendingBossQuestions = undefined;
@@ -1264,7 +2221,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1264
2221
  // exception filed against an effect that never ran.
1265
2222
  await requestVisibility(frame);
1266
2223
  await setMode('engaged.driving', 'submit');
1267
- const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
2224
+ const result = await runFrameOperation(frame, () => runEffect(() => frame.runtime.handleBossInput({ text, signal })));
1268
2225
  frame.state = result.state;
1269
2226
  return result;
1270
2227
  };
@@ -1310,11 +2267,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1310
2267
  }
1311
2268
  let result;
1312
2269
  try {
1313
- result = await runEffect(() => parent.runtime.resumePlaybookCall({
2270
+ result = await runFrameOperation(parent, () => runEffect(() => parent.runtime.resumePlaybookCall({
1314
2271
  callId: parentLink.callId,
1315
2272
  result: effectiveResult,
1316
2273
  signal: context.signal,
1317
- }));
2274
+ })));
1318
2275
  }
1319
2276
  catch (error) {
1320
2277
  if (disposing || invocationSignal?.aborted)
@@ -1346,6 +2303,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1346
2303
  await resumeParent(frame, callResultFor(frame, result), context);
1347
2304
  }
1348
2305
  else {
2306
+ // CAPTAIN-20: the root is still alive here, so this is the one
2307
+ // authoritative boundary that can retain the Boss-facing meaning its
2308
+ // runtime publishes before disposal removes the frame. The opaque run
2309
+ // output remains runtime-to-runtime data and never becomes Captain
2310
+ // evidence (CAPPLAY-10).
2311
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame));
1349
2312
  await runEffect(() => disposeStack('final'));
1350
2313
  }
1351
2314
  return;
@@ -1664,7 +2627,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1664
2627
  stateDigestLine(view.state, view.stateDescription),
1665
2628
  ].join(': '));
1666
2629
  lines.push(...leafContextLines(view.context));
1667
- const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.player)} asks: ${quoteEvidence(question.question)}`);
2630
+ const pending = view.pendingQuestions.map((question) => digestLine `- (${quoteEvidence(question.questionId)}) ${quoteEvidence(question.asker.kind === 'captain'
2631
+ ? 'Captain'
2632
+ : question.asker.roleId)} asks: ${quoteEvidence(question.question)}`);
1668
2633
  lines.push(pending.length === 0
1669
2634
  ? 'Pending Boss questions: none.'
1670
2635
  : ['Pending Boss questions:', ...pending].join('\n'));
@@ -1760,6 +2725,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1760
2725
  await trackTurnCall(settlement.context.emitReply(settlement.text));
1761
2726
  }
1762
2727
  catch (error) {
2728
+ conversation = { kind: 'needsSeeding' };
1763
2729
  const normalized = normalizeErrorCompact(error) ?? {
1764
2730
  name: 'Error',
1765
2731
  message: String(error),
@@ -1856,8 +2822,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1856
2822
  * text, replies, handoffs, playbook ids, facts, labels, and reasons are prose
1857
2823
  * the Captain may need to repeat.
1858
2824
  */
1859
- const reseedDigest = () => {
1860
- for (const record of journal) {
2825
+ const conversationDigest = (records, render) => {
2826
+ for (const record of records) {
1861
2827
  if (record.kind === 'action' &&
1862
2828
  typeof record.payload === 'object' &&
1863
2829
  record.payload !== null &&
@@ -1867,13 +2833,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1867
2833
  recordSuppliedIdentifier(actionId);
1868
2834
  }
1869
2835
  }
1870
- return renderReseedDigest(journal);
2836
+ return render(records);
1871
2837
  };
2838
+ const reseedDigest = () => conversationDigest(journal, renderReseedDigest);
2839
+ const catchUpDigest = (afterJournalSeq) => conversationDigest(journal.filter((record) => record.seq > afterJournalSeq), renderCatchUpDigest);
1872
2840
  const markControlFailure = (error) => {
1873
- if (activeTurn)
1874
- activeTurn.controlFailure = true;
2841
+ activeTurn?.controlFailures.add(error);
1875
2842
  return error;
1876
2843
  };
2844
+ const markSettingsRejection = (error) => markControlFailure(error);
2845
+ const rememberSettingsPreflight = (error) => {
2846
+ activeTurn?.settingsPreflightFailures.add(error);
2847
+ return markSettingsRejection(error);
2848
+ };
2849
+ const markConversationCatchUp = () => {
2850
+ if (conversation.kind === 'needsSeeding' || conversation.kind === 'needsCatchUp') {
2851
+ return;
2852
+ }
2853
+ conversation = {
2854
+ kind: 'needsCatchUp',
2855
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
2856
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
2857
+ };
2858
+ };
2859
+ const markConversationUnsynchronized = () => {
2860
+ if (conversation.kind !== 'needsCatchUp') {
2861
+ conversation = { kind: 'needsSeeding' };
2862
+ }
2863
+ };
1877
2864
  /**
1878
2865
  * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
1879
2866
  * engagement constructed, a stack disposed, an advertised action applied.
@@ -1919,14 +2906,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1919
2906
  this.name = 'CaptainProseError';
1920
2907
  }
1921
2908
  }
1922
- const rawDurableCall = async (context, prompt, resume) => {
2909
+ const rawDurableCall = async (context, prompt, resume, attempt) => {
1923
2910
  const queued = captainQueue.add(async () => {
1924
2911
  context.signal.throwIfAborted();
1925
- const result = await context.callCaptain(prompt, {
2912
+ attempt.providerBoundaryEntered = true;
2913
+ const result = await classifySettingsCall(() => context.callCaptain(prompt, {
1926
2914
  visibility: 'hidden',
1927
2915
  resume,
1928
2916
  ...controlCallToolOptions(captainAdapter),
1929
- });
2917
+ settings: callSettings(captainAgent),
2918
+ }));
1930
2919
  context.signal.throwIfAborted();
1931
2920
  return result;
1932
2921
  });
@@ -1938,17 +2927,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1938
2927
  // conversation that is owed a reseed carries the digest on its very next
1939
2928
  // call, so the turn after a failed reseed starts seeded rather than blank.
1940
2929
  const durableCall = async (context, compose) => {
1941
- const resume = conversation.kind === 'pinned' ? conversation.token : false;
2930
+ const startingConversation = conversation;
2931
+ const resume = startingConversation.kind === 'pinned'
2932
+ ? startingConversation.token
2933
+ : startingConversation.kind === 'needsCatchUp'
2934
+ ? startingConversation.resume
2935
+ : false;
1942
2936
  const seedFirstCall = conversation.kind === 'needsSeeding';
2937
+ const catchUpFirstCall = conversation.kind === 'needsCatchUp';
2938
+ const representedJournalSeq = journalSeq;
2939
+ const firstAttempt = { providerBoundaryEntered: false };
1943
2940
  let result;
1944
2941
  let failure;
1945
2942
  try {
1946
- result = await rawDurableCall(context, compose(seedFirstCall ? { reseedDigest: reseedDigest() } : {}), resume);
2943
+ result = await rawDurableCall(context, compose(seedFirstCall
2944
+ ? { reseedDigest: reseedDigest() }
2945
+ : startingConversation.kind === 'needsCatchUp'
2946
+ ? {
2947
+ reseedDigest: catchUpDigest(startingConversation.afterJournalSeq),
2948
+ }
2949
+ : {}), resume, firstAttempt);
1947
2950
  }
1948
2951
  catch (error) {
1949
2952
  if (context.signal.aborted) {
1950
- conversation = { kind: 'needsSeeding' };
1951
- throw error;
2953
+ if (firstAttempt.providerBoundaryEntered) {
2954
+ conversation = { kind: 'needsSeeding' };
2955
+ }
2956
+ else {
2957
+ markConversationUnsynchronized();
2958
+ }
2959
+ throw context.signal.reason ?? error;
2960
+ }
2961
+ if (error instanceof AgentSettingsPreflightError) {
2962
+ if (conversation.kind !== 'needsCatchUp' &&
2963
+ conversation.kind !== 'needsSeeding') {
2964
+ conversation = {
2965
+ kind: 'needsCatchUp',
2966
+ resume: conversation.kind === 'pinned' ? conversation.token : false,
2967
+ afterJournalSeq: activeTurn?.captainSyncedJournalSeq ?? 0,
2968
+ };
2969
+ }
2970
+ throw rememberSettingsPreflight(error.rejection);
1952
2971
  }
1953
2972
  failure = error;
1954
2973
  }
@@ -1958,11 +2977,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1958
2977
  result.resumeToken === undefined;
1959
2978
  if (!unsynchronized) {
1960
2979
  conversation = { kind: 'pinned', token: result.resumeToken };
2980
+ if (activeTurn) {
2981
+ activeTurn.captainSyncedJournalSeq = representedJournalSeq;
2982
+ }
1961
2983
  return {
1962
2984
  ...(result.finalText !== undefined
1963
2985
  ? { finalText: result.finalText }
1964
2986
  : {}),
1965
- correctiveSpent: seedFirstCall,
2987
+ correctiveSpent: seedFirstCall || catchUpFirstCall,
1966
2988
  };
1967
2989
  }
1968
2990
  // Only the model-side conversation is replaced: the stack, player
@@ -1972,13 +2994,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1972
2994
  conversation = { kind: 'needsSeeding' };
1973
2995
  const recap = reseedDigest();
1974
2996
  let reissued;
2997
+ const reissueAttempt = { providerBoundaryEntered: false };
1975
2998
  try {
1976
- reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false);
2999
+ reissued = await rawDurableCall(context, compose({ reseedDigest: recap }), false, reissueAttempt);
1977
3000
  }
1978
3001
  catch (error) {
1979
3002
  if (context.signal.aborted) {
1980
3003
  conversation = { kind: 'needsSeeding' };
1981
- throw error;
3004
+ throw context.signal.reason ?? error;
3005
+ }
3006
+ if (error instanceof AgentSettingsPreflightError) {
3007
+ throw rememberSettingsPreflight(error.rejection);
1982
3008
  }
1983
3009
  throw markControlFailure(new CaptainContinuityError(error));
1984
3010
  }
@@ -1987,6 +3013,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1987
3013
  `callCaptain status "${reissued.status}" without a resume token`));
1988
3014
  }
1989
3015
  conversation = { kind: 'pinned', token: reissued.resumeToken };
3016
+ if (activeTurn)
3017
+ activeTurn.captainSyncedJournalSeq = journalSeq;
1990
3018
  return {
1991
3019
  ...(reissued.finalText !== undefined
1992
3020
  ? { finalText: reissued.finalText }
@@ -2025,9 +3053,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2025
3053
  // -------------------------------------------------------------------------
2026
3054
  const captainPorts = () => ({
2027
3055
  callPlayer: async () => {
3056
+ admitHostBoundary();
2028
3057
  throw new Error('the session Captain has no players');
2029
3058
  },
2030
3059
  callCaptain: async (prompt, signal) => {
3060
+ admitHostBoundary();
2031
3061
  if (!activeContext) {
2032
3062
  throw new Error('the session Captain called out of a Boss turn');
2033
3063
  }
@@ -2085,29 +3115,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2085
3115
  return { status: 'ok', finalText: 'ok' };
2086
3116
  },
2087
3117
  callJudge: async () => {
3118
+ admitHostBoundary();
2088
3119
  throw new Error('the session Captain makes no judge call');
2089
3120
  },
2090
3121
  callPlaybook: async () => {
3122
+ admitHostBoundary();
2091
3123
  throw new Error('the session Captain never calls a playbook');
2092
3124
  },
2093
3125
  // CAPTAIN-9: the session Captain's human status stream is suppressed while
2094
3126
  // its structured telemetry is forwarded.
2095
- emitStatus: async () => { },
2096
- emitTelemetry: async (event) => {
2097
- if (event.topic === 'playbook.trace') {
2098
- const payload = payloadRecord(event.payload);
2099
- if (payload?.type === 'captain.call.started') {
2100
- const identity = payloadRecord(payload.payload);
2101
- const stateId = identity?.stateId;
2102
- servingCall =
2103
- stateId === 'reporting'
2104
- ? 'closingReply'
2105
- : stateId === 'answeringCommand'
2106
- ? 'commandReply'
2107
- : 'decision';
3127
+ emitStatus: async () => {
3128
+ admitHostEmission();
3129
+ },
3130
+ emitTelemetry: (event) => {
3131
+ if (!admitHostEmission())
3132
+ return Promise.resolve();
3133
+ const emission = (async () => {
3134
+ if (event.topic === 'playbook.trace') {
3135
+ const payload = payloadRecord(event.payload);
3136
+ if (payload?.type === 'captain.call.started') {
3137
+ const identity = payloadRecord(payload.payload);
3138
+ const stateId = identity?.stateId;
3139
+ servingCall =
3140
+ stateId === 'reporting'
3141
+ ? 'closingReply'
3142
+ : stateId === 'answeringCommand'
3143
+ ? 'commandReply'
3144
+ : 'decision';
3145
+ }
2108
3146
  }
2109
- }
2110
- await requireSession().emitTelemetry(event);
3147
+ await requireSession().emitTelemetry(event);
3148
+ })();
3149
+ return trackTurnCall(emission);
2111
3150
  },
2112
3151
  });
2113
3152
  const resolveCommandTurn = (text) => {
@@ -2175,6 +3214,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2175
3214
  return undefined;
2176
3215
  }
2177
3216
  };
3217
+ const rootCompletionFact = (frame) => {
3218
+ const published = leafStateDescription(frame);
3219
+ const description = published === undefined ? '' : compactEvidence(published);
3220
+ return description === ''
3221
+ ? `${frameLabel(frame)} completed; its runtime published no result description.`
3222
+ : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
3223
+ };
2178
3224
  const leafStateSummary = () => {
2179
3225
  const leaf = leafFrame();
2180
3226
  if (!leaf)
@@ -2312,7 +3358,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2312
3358
  message: String(error),
2313
3359
  };
2314
3360
  if (aborted) {
2315
- conversation = { kind: 'needsSeeding' };
3361
+ markConversationUnsynchronized();
2316
3362
  if (turn?.outcomePending) {
2317
3363
  turn.settlementFacts.push(`The ${selection.action} action was aborted before its outcome could be confirmed; it was not repeated automatically.`);
2318
3364
  journalOutcome(journalOutcomeEvidence(turn.settlementFacts, 'failed', turn.report));
@@ -2573,9 +3619,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2573
3619
  };
2574
3620
  throw outcome.error;
2575
3621
  }
2576
- if (!frames.includes(leaf)) {
2577
- facts.push(`${frameLabel(leaf)} finished and was disposed.`);
2578
- }
2579
3622
  const runFailed = drainRunFailureFacts(facts);
2580
3623
  const summary = leafStateSummary();
2581
3624
  turn.report = {
@@ -2645,7 +3688,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2645
3688
  // guard against re-execution — a repeated selection returns the recorded
2646
3689
  // receipt rather than acting twice.
2647
3690
  const key = `turn-${turn.id}-apply-${actionId}`;
2648
- const outcome = await withCounting(leaf, async () => runEffect(() => leaf.runtime.apply({ actionId, key, signal })));
3691
+ const outcome = await withCounting(leaf, async () => runFrameOperation(leaf, () => runEffect(() => leaf.runtime.apply({ actionId, key, signal }))));
2649
3692
  if (outcome.error !== undefined)
2650
3693
  throw outcome.error;
2651
3694
  const receipt = outcome.result;
@@ -2730,8 +3773,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2730
3773
  };
2731
3774
  };
2732
3775
  const controller = {
2733
- submit: (selection, signal) => settleSelection(selection, signal),
2734
- resolveParsedTurn: () => shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution,
3776
+ submit: (selection, signal) => {
3777
+ admitHostBoundary();
3778
+ return settleSelection(selection, signal);
3779
+ },
3780
+ resolveParsedTurn: () => {
3781
+ admitHostBoundary();
3782
+ return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
3783
+ },
2735
3784
  };
2736
3785
  // -------------------------------------------------------------------------
2737
3786
  // Failure surface (CAPTAIN-34): a Boss-appropriate reply naming a concrete
@@ -2787,7 +3836,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2787
3836
  // The durable Captain conversation did not receive the shell-authored
2788
3837
  // fallback. Force its next call through the journal so it cannot interpret
2789
3838
  // the Boss's follow-up without the reply the Boss was given this turn.
2790
- conversation = { kind: 'needsSeeding' };
3839
+ if (activeTurn?.settingsPreflightFailures.has(error)) {
3840
+ markConversationCatchUp();
3841
+ }
3842
+ else {
3843
+ markConversationUnsynchronized();
3844
+ }
2791
3845
  // A rejected presentation may already have emitted bytes. It is therefore
2792
3846
  // final for this turn even though the Promise did not prove it was shown.
2793
3847
  if (activeTurn?.presentationAttempted === true)
@@ -2800,40 +3854,445 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2800
3854
  text: failureReplyText(),
2801
3855
  });
2802
3856
  };
2803
- return {
2804
- async init(initSession) {
2805
- session = initSession;
2806
- players = initSession.players;
2807
- const built = await buildEnablements(options, players, loadModule);
3857
+ const enabledCatalog = () => Object.freeze(entries.map((entry) => Object.freeze({
3858
+ id: entry.id,
3859
+ command: enablementById.get(entry.id).command,
3860
+ intent: entry.intent,
3861
+ })));
3862
+ const captainPlaybookSession = (id) => ({
3863
+ sessionId: id,
3864
+ playbookId: INTERNAL_CAPTAIN_ID,
3865
+ rootSessionId: id,
3866
+ depth: 0,
3867
+ roleBindings: {},
3868
+ ports: captainPorts(),
3869
+ });
3870
+ const playerLedgerRecord = () => Object.fromEntries([...playerLedger].map(([playerId, entry]) => [
3871
+ playerId,
3872
+ {
3873
+ adapter: entry.adapter,
3874
+ ...(entry.instruction === undefined
3875
+ ? {}
3876
+ : { instruction: entry.instruction }),
3877
+ ...(entry.permissions === undefined
3878
+ ? {}
3879
+ : { permissions: entry.permissions }),
3880
+ ...(entry.resumeToken === undefined
3881
+ ? {}
3882
+ : { resumeToken: entry.resumeToken }),
3883
+ },
3884
+ ]));
3885
+ const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
3886
+ if (captainAgent === undefined ||
3887
+ !isDeepStrictEqual(snapshot.captain.agent, fixedAgent(captainAgent))) {
3888
+ throw new TypeError('Captain shell snapshot Captain agent is incompatible with current config');
3889
+ }
3890
+ const configuredPlayerIds = [...playerAgents.keys()].sort();
3891
+ const savedPlayerIds = Object.keys(snapshot.playerSessions).sort();
3892
+ if (!isDeepStrictEqual(savedPlayerIds, configuredPlayerIds)) {
3893
+ throw new TypeError('Captain shell snapshot player ledger does not match current referenced players');
3894
+ }
3895
+ for (const playerId of configuredPlayerIds) {
3896
+ const saved = snapshot.playerSessions[playerId];
3897
+ const configured = playerAgents.get(playerId);
3898
+ const savedFixed = {
3899
+ adapter: saved.adapter,
3900
+ ...(saved.instruction === undefined
3901
+ ? {}
3902
+ : { instruction: saved.instruction }),
3903
+ ...(saved.permissions === undefined
3904
+ ? {}
3905
+ : { permissions: saved.permissions }),
3906
+ };
3907
+ if (!isDeepStrictEqual(savedFixed, fixedAgent(configured))) {
3908
+ throw new TypeError(`Captain shell snapshot player ${JSON.stringify(playerId)} is incompatible with current config`);
3909
+ }
3910
+ }
3911
+ if (snapshot.mode === 'chat')
3912
+ return;
3913
+ for (const frame of snapshot.frames) {
3914
+ const enablement = enabled.get(frame.playbookId);
3915
+ if (!enablement) {
3916
+ throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
3917
+ }
3918
+ const configuredBindings = Object.fromEntries([...enablement.roleBindings].map(([role, binding]) => [
3919
+ role,
3920
+ binding.playerId,
3921
+ ]));
3922
+ if (!isDeepStrictEqual(frame.options, enablement.options)) {
3923
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} options changed`);
3924
+ }
3925
+ if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
3926
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
3927
+ }
3928
+ }
3929
+ };
3930
+ const safeCapturePoint = () => {
3931
+ if (lifecycle !== 'ready' ||
3932
+ terminallyDisposed ||
3933
+ !sessionEmissionsOpen ||
3934
+ !session ||
3935
+ session.signal.aborted ||
3936
+ !captainRuntime ||
3937
+ disposing ||
3938
+ shuttingDown ||
3939
+ activeContext !== undefined ||
3940
+ activeTurn !== undefined ||
3941
+ activeTurnHostCalls !== undefined ||
3942
+ activeTurnSummary !== undefined ||
3943
+ runFailureFacts !== undefined ||
3944
+ servingCall !== undefined ||
3945
+ decisionCall !== undefined ||
3946
+ playerTransactions.size !== 0 ||
3947
+ captainQueue.pending !== 0 ||
3948
+ captainQueue.size !== 0 ||
3949
+ (mode !== 'chat' && mode !== 'engaged.parked')) {
3950
+ return false;
3951
+ }
3952
+ if ((mode === 'chat' &&
3953
+ (frames.length !== 0 ||
3954
+ pendingChildParents.size !== 0 ||
3955
+ pendingBossQuestions !== undefined ||
3956
+ lastError !== undefined)) ||
3957
+ (mode === 'engaged.parked' && frames.length === 0)) {
3958
+ return false;
3959
+ }
3960
+ const expectedParents = new Set(frames.slice(0, -1));
3961
+ if (pendingChildParents.size !== expectedParents.size ||
3962
+ [...pendingChildParents].some((frame) => !expectedParents.has(frame))) {
3963
+ return false;
3964
+ }
3965
+ return frames.every((frame, index) => {
3966
+ const liveInvocation = frame.invocationSignal !== undefined || frame.abortListener !== undefined;
3967
+ return (frame.state !== undefined &&
3968
+ frame.state.status === 'active' &&
3969
+ frame.state.quiescent &&
3970
+ !frame.disposing &&
3971
+ frame.disposePromise === undefined &&
3972
+ frame.removal === undefined &&
3973
+ frame.inFlightHostCalls.size === 0 &&
3974
+ (index === 0
3975
+ ? !liveInvocation
3976
+ : !liveInvocation ||
3977
+ (frame.invocationSignal !== undefined &&
3978
+ !frame.invocationSignal.aborted &&
3979
+ frame.abortListener !== undefined)));
3980
+ });
3981
+ };
3982
+ const exportShellSnapshot = () => {
3983
+ if (!safeCapturePoint() ||
3984
+ !captainRuntime ||
3985
+ !captainSessionId ||
3986
+ !captainAgent) {
3987
+ return undefined;
3988
+ }
3989
+ try {
3990
+ if (typeof captainRuntime.exportSnapshot !== 'function' ||
3991
+ typeof captainRuntime.restore !== 'function') {
3992
+ return undefined;
3993
+ }
3994
+ const captainSnapshot = captainRuntime.exportSnapshot();
3995
+ if (captainSnapshot === undefined)
3996
+ return undefined;
3997
+ const frameSnapshots = [];
3998
+ for (const frame of frames) {
3999
+ if (typeof frame.runtime.exportSnapshot !== 'function' ||
4000
+ typeof frame.runtime.restore !== 'function') {
4001
+ return undefined;
4002
+ }
4003
+ const runtime = frame.runtime.exportSnapshot();
4004
+ if (runtime === undefined ||
4005
+ !isDeepStrictEqual(frame.state, runtime.state)) {
4006
+ return undefined;
4007
+ }
4008
+ frameSnapshots.push({
4009
+ playbookId: frame.entry.id,
4010
+ sessionId: frame.sessionId,
4011
+ rootSessionId: frame.rootSessionId,
4012
+ depth: frame.depth,
4013
+ ...(frame.parent
4014
+ ? {
4015
+ parentSessionId: frame.parent.frame.sessionId,
4016
+ parentCallId: frame.parent.callId,
4017
+ }
4018
+ : {}),
4019
+ options: frame.enablement.options,
4020
+ roleBindings: Object.fromEntries([...frame.playerBindings].map(([role, binding]) => [
4021
+ role,
4022
+ binding.playerId,
4023
+ ])),
4024
+ runtime,
4025
+ });
4026
+ }
4027
+ const common = {
4028
+ schemaVersion: 3,
4029
+ captain: {
4030
+ sessionId: captainSessionId,
4031
+ runtime: captainSnapshot,
4032
+ agent: fixedAgent(captainAgent),
4033
+ conversation,
4034
+ },
4035
+ playerSessions: playerLedgerRecord(),
4036
+ issuedSessionIds: [...issuedSessionIds],
4037
+ sequences: { turn: turnSequence, journal: journalSeq },
4038
+ journal,
4039
+ ...(lastAction === undefined ? {} : { lastAction }),
4040
+ ...(lastSettlementStatus === undefined
4041
+ ? {}
4042
+ : { lastSettlementStatus }),
4043
+ };
4044
+ const candidate = mode === 'chat'
4045
+ ? { ...common, mode }
4046
+ : {
4047
+ ...common,
4048
+ mode: 'engaged.parked',
4049
+ frames: frameSnapshots,
4050
+ ...(pendingBossQuestions === undefined
4051
+ ? {}
4052
+ : { pendingBossQuestions: pendingBossQuestions }),
4053
+ ...(lastError === undefined ? {} : { lastError }),
4054
+ };
4055
+ const normalized = assertPlaybookCaptainShellSnapshot(candidate);
4056
+ assertSnapshotMatchesEnablements(normalized, enablementById);
4057
+ return normalized;
4058
+ }
4059
+ catch {
4060
+ return undefined;
4061
+ }
4062
+ };
4063
+ const verifyRestoredRuntime = (runtime, expected, playbookId, allowSuspendedCall) => {
4064
+ const actual = runtime.exportSnapshot?.();
4065
+ if (actual === undefined) {
4066
+ throw new Error(`restored ${playbookId} runtime did not reach a safe snapshot boundary`);
4067
+ }
4068
+ const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
4069
+ for (const key of [
4070
+ 'state',
4071
+ 'roleResumeTokens',
4072
+ 'sequences',
4073
+ 'pendingBossQuestions',
4074
+ 'suspendedCall',
4075
+ ]) {
4076
+ if (!isDeepStrictEqual(normalized[key], expected[key])) {
4077
+ throw new Error(`restored ${playbookId} runtime changed snapshot field ${key}`);
4078
+ }
4079
+ }
4080
+ };
4081
+ const resetFailedRestore = async () => {
4082
+ const cleanupFailures = [];
4083
+ for (const frame of [...frames].reverse()) {
4084
+ frame.disposing = true;
4085
+ try {
4086
+ await frame.runtime.dispose();
4087
+ }
4088
+ catch (error) {
4089
+ cleanupFailures.push(error);
4090
+ }
4091
+ }
4092
+ if (captainRuntime) {
4093
+ shuttingDown = true;
4094
+ try {
4095
+ await captainRuntime.dispose();
4096
+ }
4097
+ catch (error) {
4098
+ cleanupFailures.push(error);
4099
+ }
4100
+ }
4101
+ frames.splice(0);
4102
+ pendingChildParents.clear();
4103
+ issuedSessionIds.clear();
4104
+ journal.splice(0);
4105
+ entries = [];
4106
+ byCommand = new Map();
4107
+ byId = new Map();
4108
+ enablementById = new Map();
4109
+ captainAgent = undefined;
4110
+ captainAdapter = undefined;
4111
+ playerAgents = new Map();
4112
+ playerLedger.clear();
4113
+ playerTransactions.clear();
4114
+ session = undefined;
4115
+ sessionEmissionsOpen = false;
4116
+ closedGateAttempted = false;
4117
+ captainRuntime = undefined;
4118
+ captainSessionId = undefined;
4119
+ conversation = { kind: 'unopened' };
4120
+ mode = 'chat';
4121
+ pendingBossQuestions = undefined;
4122
+ lastError = undefined;
4123
+ journalSeq = 0;
4124
+ turnSequence = 0;
4125
+ lastAction = undefined;
4126
+ lastSettlementStatus = undefined;
4127
+ shuttingDown = false;
4128
+ if (cleanupFailures.length > 0) {
4129
+ terminallyDisposed = true;
4130
+ lifecycle = 'closed';
4131
+ }
4132
+ else {
4133
+ lifecycle = 'fresh';
4134
+ }
4135
+ return cleanupFailures;
4136
+ };
4137
+ const restoreShellSnapshot = async (initSession, untrusted) => {
4138
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
4139
+ throw new Error('Captain shell restore requires a fresh shell');
4140
+ }
4141
+ if (initSession.signal.aborted) {
4142
+ throw new Error('cannot restore an aborted Captain session');
4143
+ }
4144
+ lifecycle = 'restoring';
4145
+ try {
4146
+ const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
4147
+ const built = await buildEnablements(options, loadModule);
4148
+ captainAgent = built.captainAgent;
4149
+ captainAdapter = captainAgent.adapter;
4150
+ playerAgents = built.playerAgents;
4151
+ assertSnapshotMatchesEnablements(snapshot, built.enablementById);
4152
+ installSession(initSession, false);
2808
4153
  entries = built.entries;
2809
4154
  byCommand = built.byCommand;
2810
4155
  byId = built.byId;
2811
4156
  enablementById = built.enablementById;
2812
- for (const enablement of enablementById.values()) {
2813
- enablement.entry.validateOptions(enablement.optionInput);
2814
- }
2815
- await setMode('chat', 'init');
2816
- // CAPTAIN-16: the session Captain exists from `init`, outside the
2817
- // engagement stack, with its own playbook session id.
2818
- const catalog = Object.freeze(entries.map((entry) => Object.freeze({
2819
- id: entry.id,
2820
- command: enablementById.get(entry.id).command,
2821
- intent: entry.intent,
2822
- })));
2823
- captainSessionId = allocateSessionId();
4157
+ for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
4158
+ playerLedger.set(playerId, {
4159
+ adapter: saved.adapter,
4160
+ ...(saved.instruction === undefined
4161
+ ? {}
4162
+ : { instruction: saved.instruction }),
4163
+ ...(saved.permissions === undefined
4164
+ ? {}
4165
+ : { permissions: livePermissions(saved.permissions) }),
4166
+ ...(saved.resumeToken === undefined
4167
+ ? {}
4168
+ : { resumeToken: saved.resumeToken }),
4169
+ });
4170
+ }
2824
4171
  captainRuntime = createCaptainRuntime({
2825
- enabledPlaybooks: catalog,
4172
+ enabledPlaybooks: enabledCatalog(),
2826
4173
  controller,
2827
4174
  });
2828
- await captainRuntime.init({
2829
- sessionId: captainSessionId,
2830
- playbookId: INTERNAL_CAPTAIN_ID,
2831
- rootSessionId: captainSessionId,
2832
- depth: 0,
2833
- ports: captainPorts(),
2834
- });
4175
+ if (typeof captainRuntime.restore !== 'function') {
4176
+ throw new Error('session Captain runtime does not support restore');
4177
+ }
4178
+ if (snapshot.mode === 'engaged.parked') {
4179
+ for (const [index, frameSnapshot] of snapshot.frames.entries()) {
4180
+ const parentFrame = frames.at(-1);
4181
+ const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, index === 0
4182
+ ? undefined
4183
+ : {
4184
+ frame: parentFrame,
4185
+ callId: frameSnapshot.parentCallId,
4186
+ });
4187
+ if (typeof frame.runtime.restore !== 'function') {
4188
+ throw new Error(`playbook ${frame.entry.id} runtime does not support restore`);
4189
+ }
4190
+ frames.push(frame);
4191
+ if (parentFrame)
4192
+ pendingChildParents.add(parentFrame);
4193
+ }
4194
+ }
4195
+ await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
4196
+ if (snapshot.mode === 'engaged.parked') {
4197
+ for (const [index, frame] of frames.entries()) {
4198
+ restoringPlayerSessionFrame = frame;
4199
+ try {
4200
+ await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
4201
+ }
4202
+ finally {
4203
+ restoringPlayerSessionFrame = undefined;
4204
+ }
4205
+ }
4206
+ }
4207
+ if (closedGateAttempted) {
4208
+ throw new Error('a runtime attempted a host emission during restore');
4209
+ }
4210
+ verifyRestoredRuntime(captainRuntime, snapshot.captain.runtime, INTERNAL_CAPTAIN_ID, false);
4211
+ if (snapshot.mode === 'engaged.parked') {
4212
+ for (const [index, frame] of frames.entries()) {
4213
+ verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
4214
+ }
4215
+ if (!isDeepStrictEqual(playerLedgerRecord(), snapshot.playerSessions)) {
4216
+ throw new Error('restored Captain-session player continuation changed during restore');
4217
+ }
4218
+ }
4219
+ if (closedGateAttempted) {
4220
+ throw new Error('a runtime attempted a host emission during restore');
4221
+ }
4222
+ if (requireSession().signal.aborted) {
4223
+ throw new Error('Captain session aborted during restore');
4224
+ }
4225
+ for (const id of snapshot.issuedSessionIds)
4226
+ issuedSessionIds.add(id);
4227
+ journal.push(...snapshot.journal);
4228
+ journalSeq = snapshot.sequences.journal;
4229
+ turnSequence = snapshot.sequences.turn;
4230
+ conversation = snapshot.captain.conversation;
4231
+ captainSessionId = snapshot.captain.sessionId;
4232
+ lastAction = snapshot.lastAction;
4233
+ lastSettlementStatus = snapshot.lastSettlementStatus;
4234
+ mode = snapshot.mode;
4235
+ if (snapshot.mode === 'engaged.parked') {
4236
+ pendingBossQuestions = snapshot.pendingBossQuestions;
4237
+ lastError = snapshot.lastError;
4238
+ }
4239
+ lifecycle = 'ready';
4240
+ // The final commit is deliberately one non-throwing assignment.
4241
+ sessionEmissionsOpen = true;
4242
+ }
4243
+ catch (error) {
4244
+ const cleanupFailures = await resetFailedRestore();
4245
+ if (cleanupFailures.length > 0) {
4246
+ throw new AggregateError([error, ...cleanupFailures], 'Captain shell restore and cleanup failed');
4247
+ }
4248
+ throw error;
4249
+ }
4250
+ };
4251
+ return {
4252
+ async init(initSession) {
4253
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
4254
+ throw new Error('Captain shell requires a fresh instance for init');
4255
+ }
4256
+ if (initSession.signal.aborted) {
4257
+ throw new Error('cannot initialize an aborted Captain session');
4258
+ }
4259
+ lifecycle = 'initializing';
4260
+ try {
4261
+ installSession(initSession, true);
4262
+ const built = await buildEnablements(options, loadModule);
4263
+ entries = built.entries;
4264
+ byCommand = built.byCommand;
4265
+ byId = built.byId;
4266
+ enablementById = built.enablementById;
4267
+ captainAgent = built.captainAgent;
4268
+ captainAdapter = captainAgent.adapter;
4269
+ playerAgents = built.playerAgents;
4270
+ for (const [playerId, agent] of playerAgents) {
4271
+ playerLedger.set(playerId, fixedAgent(agent));
4272
+ }
4273
+ await setMode('chat', 'init');
4274
+ // CAPTAIN-16: the session Captain exists from `init`, outside the
4275
+ // engagement stack, with its own playbook session id.
4276
+ captainSessionId = allocateSessionId();
4277
+ captainRuntime = createCaptainRuntime({
4278
+ enabledPlaybooks: enabledCatalog(),
4279
+ controller,
4280
+ });
4281
+ await captainRuntime.init(captainPlaybookSession(captainSessionId));
4282
+ lifecycle = 'ready';
4283
+ }
4284
+ catch (error) {
4285
+ terminallyDisposed = true;
4286
+ lifecycle = 'closed';
4287
+ throw error;
4288
+ }
2835
4289
  },
4290
+ exportSnapshot: exportShellSnapshot,
4291
+ restore: restoreShellSnapshot,
2836
4292
  async handleBossTurn(turn, context) {
4293
+ if (lifecycle !== 'ready' || terminallyDisposed) {
4294
+ throw new Error('init must be called first, or restore must complete before handling a Boss turn');
4295
+ }
2837
4296
  requireSession();
2838
4297
  if (!captainRuntime) {
2839
4298
  throw new Error('init must be called first');
@@ -2851,6 +4310,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2851
4310
  const parsed = resolveCommandTurn(turn.prompt);
2852
4311
  activeTurn = {
2853
4312
  id: ++turnSequence,
4313
+ captainSyncedJournalSeq: journalSeq,
2854
4314
  bossText: turn.prompt,
2855
4315
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
2856
4316
  ...(parsed ? { resolution: parsed.resolution } : {}),
@@ -2858,6 +4318,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2858
4318
  presentationAttempted: false,
2859
4319
  settlementFacts: [],
2860
4320
  effectThrows: new Set(),
4321
+ controlFailures: new Set(),
4322
+ settingsPreflightFailures: new Set(),
2861
4323
  suppliedIdentifiers: new Set(),
2862
4324
  outcomeRecorded: false,
2863
4325
  };
@@ -2876,11 +4338,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2876
4338
  new Error('the session Captain turn failed at its boundary'));
2877
4339
  }
2878
4340
  else if (result.outcome === 'aborted') {
2879
- conversation = { kind: 'needsSeeding' };
4341
+ markConversationUnsynchronized();
2880
4342
  if (activeTurn && !activeTurn.outcomeRecorded) {
2881
4343
  activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
2882
4344
  journalOutcome([...activeTurn.settlementFacts]);
2883
4345
  }
4346
+ if (context.signal.aborted) {
4347
+ throw context.signal.reason;
4348
+ }
2884
4349
  }
2885
4350
  else if (result.outcome !== 'suspended' &&
2886
4351
  !context.signal.aborted &&
@@ -2894,10 +4359,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2894
4359
  }
2895
4360
  catch (error) {
2896
4361
  if (context.signal.aborted) {
2897
- conversation = { kind: 'needsSeeding' };
4362
+ markConversationUnsynchronized();
2898
4363
  throw error;
2899
4364
  }
2900
- const controlFailure = activeTurn?.controlFailure === true;
4365
+ const controlFailure = activeTurn?.controlFailures.has(error) === true;
2901
4366
  await settleTurnFailure(context, error);
2902
4367
  if (activeTurn?.presentationError !== undefined) {
2903
4368
  throw activeTurn.presentationError;
@@ -2909,7 +4374,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2909
4374
  }
2910
4375
  finally {
2911
4376
  if (context.signal.aborted) {
2912
- conversation = { kind: 'needsSeeding' };
4377
+ markConversationUnsynchronized();
2913
4378
  if (activeTurn && !activeTurn.outcomeRecorded) {
2914
4379
  activeTurn.settlementFacts.push('The Boss turn was aborted before it settled; no action was repeated automatically.');
2915
4380
  journalOutcome([...activeTurn.settlementFacts]);
@@ -2926,10 +4391,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2926
4391
  }
2927
4392
  },
2928
4393
  async prepareDispose() {
4394
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
4395
+ throw new Error('cannot dispose while Captain shell setup is in progress');
4396
+ }
2929
4397
  activeContext = undefined;
2930
4398
  await teardown();
2931
4399
  },
2932
4400
  async dispose() {
4401
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
4402
+ throw new Error('cannot dispose while Captain shell setup is in progress');
4403
+ }
2933
4404
  activeContext = undefined;
2934
4405
  await teardown();
2935
4406
  },
@@ -2937,6 +4408,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2937
4408
  // CAPTAIN-16: dispose every active frame from leaf to root, then the
2938
4409
  // session Captain last.
2939
4410
  async function teardown() {
4411
+ terminallyDisposed = true;
4412
+ lifecycle = 'disposing';
2940
4413
  let failure;
2941
4414
  try {
2942
4415
  await disposeStack('dispose');
@@ -2955,6 +4428,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2955
4428
  failure ??= error;
2956
4429
  }
2957
4430
  }
4431
+ // Quarantine is session-wide by design. Only terminal teardown may drop
4432
+ // its ownership after every frame host call and the Captain are drained.
4433
+ playerTransactions.clear();
4434
+ lifecycle = 'closed';
2958
4435
  if (failure !== undefined)
2959
4436
  throw failure;
2960
4437
  }