@sublang/playbook 0.9.0 → 1.3.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 (51) hide show
  1. package/README.md +190 -151
  2. package/package.json +50 -6
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
  12. package/reference/sdlc/code.playbook/bin/run.js +999 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
  21. package/reference/sdlc/code.playbook/code.playbook.js +199 -488
  22. package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
  24. package/reference/sdlc/code.playbook/code.registry.js +0 -3
  25. package/reference/sdlc/code.playbook/code.registry.ts +0 -6
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
  27. package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1165 -89
  42. package/slc/optimize.md +92 -0
  43. package/slc/text2gears.md +255 -7
  44. package/src/runtime.d.ts +146 -3
  45. package/src/runtime.ts +201 -2
  46. package/src/xstate-playbook-runtime.d.ts +201 -0
  47. package/src/xstate-playbook-runtime.js +2058 -0
  48. package/src/xstate-playbook-runtime.ts +2792 -0
  49. package/src/xstate-runtime.d.ts +95 -0
  50. package/src/xstate-runtime.js +1258 -0
  51. package/src/xstate-runtime.ts +1816 -0
@@ -1,6 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
+ import { randomUUID } from 'node:crypto';
5
+ import PQueue from 'p-queue';
6
+
4
7
  import type {
5
8
  BossTurn,
6
9
  Captain,
@@ -8,13 +11,18 @@ import type {
8
11
  CaptainSession,
9
12
  } from '@sublang/cligent/tmux-play';
10
13
  import type {
14
+ NormalizedError,
15
+ PlaybookCallRequest,
16
+ PlaybookCallResult,
17
+ PlaybookCallStart,
11
18
  PlaybookPorts,
19
+ PlaybookRunResult,
12
20
  PlaybookRuntime,
13
- } from './code.playbook.js';
14
- import type {
15
- PlaybookSummaryPolicy,
16
- RegistryPlayer,
17
- } from './code.registry.js';
21
+ PlaybookState,
22
+ } from '@sublang/playbook/runtime';
23
+ import { registerPlaybookAbortCleanup } from '../../../src/xstate-runtime.js';
24
+ import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
25
+ import type { PlaybookSummaryPolicy, RegistryPlayer } from './code.registry.js';
18
26
 
19
27
  export interface CreatePlaybookRuntimeOptions {
20
28
  captainOptions: unknown;
@@ -23,6 +31,14 @@ export interface CreatePlaybookRuntimeOptions {
23
31
 
24
32
  export interface PlaybookCaptainDeps {
25
33
  loadModule?: (specifier: string) => Promise<unknown>;
34
+ createSessionId?: () => string;
35
+ createCaptainRuntime?: (options: {
36
+ readonly enabledPlaybooks: readonly {
37
+ readonly id: string;
38
+ readonly command: string;
39
+ readonly intent: string;
40
+ }[];
41
+ }) => PlaybookRuntime;
26
42
  }
27
43
 
28
44
  export interface PlaybookCaptainRegistryEntry {
@@ -30,9 +46,6 @@ export interface PlaybookCaptainRegistryEntry {
30
46
  command: string;
31
47
  intent: string;
32
48
  requiredRoleIds: readonly string[];
33
- idleStateId: string;
34
- finalStateId: string;
35
- parkStateIds: readonly string[];
36
49
  summaryPolicy?: PlaybookSummaryPolicy;
37
50
  validateOptions(captainOptions: unknown): unknown;
38
51
  createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
@@ -50,33 +63,67 @@ interface Enablement {
50
63
  visiblePlayerIds?: readonly string[];
51
64
  }
52
65
 
53
- interface ActiveEngagement {
66
+ interface EngagementFrame {
54
67
  entry: PlaybookCaptainRegistryEntry;
55
68
  enablement: Enablement;
56
69
  runtime: PlaybookRuntime;
70
+ sessionId: string;
71
+ rootSessionId: string;
72
+ depth: number;
73
+ parent?: {
74
+ frame: EngagementFrame;
75
+ callId: string;
76
+ };
77
+ state?: PlaybookState;
78
+ abortListener?: () => void;
79
+ invocationSignal?: AbortSignal;
80
+ inFlightHostCalls: Set<Promise<unknown>>;
81
+ disposePromise?: Promise<void>;
82
+ removal?: {
83
+ reason: 'return' | 'abandoned' | 'stack';
84
+ promise: Promise<void>;
85
+ };
86
+ internal: boolean;
57
87
  }
58
88
 
59
- type RouterDecision =
60
- | { decision: 'chat'; text: string }
61
- | { decision: 'dispatch'; playbookId: string; text: string }
62
- | { decision: 'sub'; text: string }
63
- | { decision: 'dismiss'; text?: string };
89
+ type LifecycleDecision = { decision: 'deliver' | 'dismiss' };
90
+
91
+ class VisibilityControlError extends Error {
92
+ constructor(cause: unknown) {
93
+ super(
94
+ `playbook visibility request failed: ${String(
95
+ (cause as { message?: unknown })?.message ?? cause,
96
+ )}`,
97
+ { cause },
98
+ );
99
+ this.name = 'VisibilityControlError';
100
+ }
101
+ }
64
102
 
65
103
  type DisposalReason = 'dismiss' | 'final' | 'dispose';
66
104
 
67
105
  interface ControlLedger {
68
106
  activePlaybookId?: string;
107
+ activeSessionId?: string;
108
+ rootPlaybookId?: string;
109
+ rootSessionId?: string;
110
+ stackDepth: number;
111
+ stackPath: readonly string[];
69
112
  mode: ShellMode;
70
113
  latestSubRuntimeStateId?: string;
71
- pendingBossQuestion?: unknown;
114
+ latestSubRuntimeState?: PlaybookState;
115
+ pendingBossQuestions?: unknown;
72
116
  lastError?: { name: string; message: string };
73
- lastRouteDecision?: RouterDecision['decision'];
117
+ lastRouteDecision?: LifecycleDecision['decision'];
74
118
  }
75
119
 
76
120
  type ShellMode = 'chat' | 'engaged.driving' | 'engaged.parked';
77
121
 
78
122
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
79
123
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
124
+ const INTERNAL_CAPTAIN_ID = 'captain';
125
+ const UUID_PATTERN =
126
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
80
127
 
81
128
  interface TurnSummaryCounts {
82
129
  interruptions: number;
@@ -84,6 +131,7 @@ interface TurnSummaryCounts {
84
131
  }
85
132
 
86
133
  interface ActiveTurnSummary {
134
+ owner: EngagementFrame;
87
135
  counts: TurnSummaryCounts;
88
136
  stateCounts: Map<string, number>;
89
137
  }
@@ -101,7 +149,7 @@ function parseRegisteredCommand(
101
149
  function visibleChatEnvelope(message: string): string {
102
150
  return [
103
151
  'You are the Playbook Captain shell.',
104
- 'This is visible Boss chat. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
152
+ 'This is visible Boss chat. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
105
153
  message,
106
154
  ].join('\n\n');
107
155
  }
@@ -116,7 +164,7 @@ function visibleTurnSummaryEnvelope(input: {
116
164
  }): string {
117
165
  return [
118
166
  'You are the Playbook Captain shell.',
119
- 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
167
+ 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
120
168
  'Write a brief, clearly formatted turn-summary block for Boss.',
121
169
  'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
122
170
  'State only what was done or what changed; do not explain how it was done.',
@@ -140,10 +188,8 @@ function stateCountLabel(
140
188
  stateId: string,
141
189
  entry: PlaybookCaptainRegistryEntry,
142
190
  ): string | undefined {
143
- if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
144
- return undefined;
145
- }
146
- const registryLabel = entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
191
+ const registryLabel =
192
+ entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
147
193
  return registryLabel || undefined;
148
194
  }
149
195
 
@@ -154,14 +200,18 @@ function pluralizeStateCount(label: string, count: number): string {
154
200
  return `${count} ${label}s`;
155
201
  }
156
202
 
157
- function summaryProgressPhrase(stateCounts: ReadonlyMap<string, number>): string {
203
+ function summaryProgressPhrase(
204
+ stateCounts: ReadonlyMap<string, number>,
205
+ ): string {
158
206
  if (stateCounts.size === 0) return 'none';
159
207
  return [...stateCounts.entries()]
160
208
  .map(([label, count]) => pluralizeStateCount(label, count))
161
209
  .join(', ');
162
210
  }
163
211
 
164
- function summaryProgressRoundCount(stateCounts: ReadonlyMap<string, number>): number {
212
+ function summaryProgressRoundCount(
213
+ stateCounts: ReadonlyMap<string, number>,
214
+ ): number {
165
215
  return [...stateCounts.values()].reduce((total, count) => total + count, 0);
166
216
  }
167
217
 
@@ -179,9 +229,6 @@ function isValidRegistryEntry(
179
229
  typeof e.command === 'string' &&
180
230
  typeof e.intent === 'string' &&
181
231
  Array.isArray(e.requiredRoleIds) &&
182
- typeof e.idleStateId === 'string' &&
183
- typeof e.finalStateId === 'string' &&
184
- Array.isArray(e.parkStateIds) &&
185
232
  typeof e.validateOptions === 'function' &&
186
233
  typeof e.createRuntime === 'function'
187
234
  );
@@ -230,6 +277,11 @@ async function buildEnablements(
230
277
  );
231
278
  }
232
279
  for (const id of ids) {
280
+ if (id === INTERNAL_CAPTAIN_ID) {
281
+ throw new Error(
282
+ `captain.options.playbooks.${id} collides with the reserved internal Captain id`,
283
+ );
284
+ }
233
285
  const block = config[id];
234
286
  if (typeof block !== 'object' || block === null || Array.isArray(block)) {
235
287
  throw new Error(`captain.options.playbooks.${id} must be an object`);
@@ -271,6 +323,11 @@ async function buildEnablements(
271
323
  typeof record.command === 'string' && record.command.length > 0
272
324
  ? record.command
273
325
  : entry.command;
326
+ if (command === INTERNAL_CAPTAIN_ID) {
327
+ throw new Error(
328
+ `captain.options.playbooks.${id} command collides with the reserved internal Captain command`,
329
+ );
330
+ }
274
331
  if (byCommand.has(command)) {
275
332
  throw new Error(
276
333
  `captain.options.playbooks has a duplicate effective command "${command}"`,
@@ -278,7 +335,11 @@ async function buildEnablements(
278
335
  }
279
336
  const boundPlayers = entry.requiredRoleIds.map((role) => {
280
337
  const host = players.find((p) => p.id === `${entry.id}-${role}`);
281
- return { id: role, adapter: host?.adapter, model: host?.model };
338
+ return {
339
+ id: role,
340
+ ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
341
+ ...(host?.model !== undefined ? { model: host.model } : {}),
342
+ };
282
343
  });
283
344
  entries.push(entry);
284
345
  byId.set(entry.id, entry);
@@ -303,21 +364,34 @@ export function createPlaybookCaptainShell(
303
364
  ): Captain {
304
365
  const loadModule =
305
366
  deps.loadModule ?? ((specifier: string) => import(specifier));
367
+ const createSessionId = deps.createSessionId ?? randomUUID;
368
+ const createCaptainRuntime: NonNullable<
369
+ PlaybookCaptainDeps['createCaptainRuntime']
370
+ > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
306
371
  let entries: readonly PlaybookCaptainRegistryEntry[] = [];
307
372
  let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
308
373
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
309
374
  let enablementById = new Map<string, Enablement>();
375
+ let internalCaptainEnablement: Enablement | undefined;
310
376
  let session: CaptainSession | undefined;
311
377
  let players: readonly RegistryPlayer[] = [];
312
378
  let activeContext: CaptainContext | undefined;
313
- let active: ActiveEngagement | undefined;
379
+ const frames: EngagementFrame[] = [];
314
380
  let mode: ShellMode = 'chat';
315
- let latestSubRuntimeStateId: string | undefined;
316
- let pendingBossQuestion: unknown;
381
+ let pendingBossQuestions: unknown;
317
382
  let lastError: { name: string; message: string } | undefined;
318
- let lastRouteDecision: RouterDecision['decision'] | undefined;
319
- let finalDisposalRequested: ActiveEngagement | undefined;
383
+ let lastRouteDecision: LifecycleDecision['decision'] | undefined;
320
384
  let activeTurnSummary: ActiveTurnSummary | undefined;
385
+ let activeTurnHostCalls: Set<Promise<unknown>> | undefined;
386
+ const issuedSessionIds = new Set<string>();
387
+ const pendingChildParents = new Set<EngagementFrame>();
388
+ const captainQueue = new PQueue({ concurrency: 1 });
389
+ let disposing = false;
390
+
391
+ const rootFrame = (): EngagementFrame | undefined => frames[0];
392
+ const leafFrame = (): EngagementFrame | undefined => frames.at(-1);
393
+ const frameLabel = (frame: EngagementFrame): string =>
394
+ frame.internal ? 'Captain' : `/${frame.enablement.command}`;
321
395
 
322
396
  const requireSession = (): CaptainSession => {
323
397
  if (!session) {
@@ -327,12 +401,27 @@ export function createPlaybookCaptainShell(
327
401
  };
328
402
 
329
403
  const ledgerSnapshot = (
330
- playbookId: string | undefined = active?.entry.id,
404
+ playbookId: string | undefined = leafFrame()?.entry.id,
405
+ activeSessionId: string | undefined = leafFrame()?.sessionId,
331
406
  ): ControlLedger => ({
332
407
  ...(playbookId ? { activePlaybookId: playbookId } : {}),
408
+ ...(activeSessionId ? { activeSessionId } : {}),
409
+ ...(rootFrame()
410
+ ? {
411
+ rootPlaybookId: rootFrame()!.entry.id,
412
+ rootSessionId: rootFrame()!.sessionId,
413
+ }
414
+ : {}),
415
+ stackDepth: frames.length,
416
+ stackPath: frames.map((frame) => frame.entry.id),
333
417
  mode,
334
- ...(latestSubRuntimeStateId ? { latestSubRuntimeStateId } : {}),
335
- ...(pendingBossQuestion !== undefined ? { pendingBossQuestion } : {}),
418
+ ...(leafFrame()?.state?.stateId
419
+ ? { latestSubRuntimeStateId: leafFrame()!.state!.stateId }
420
+ : {}),
421
+ ...(leafFrame()?.state
422
+ ? { latestSubRuntimeState: leafFrame()!.state }
423
+ : {}),
424
+ ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
336
425
  ...(lastError ? { lastError } : {}),
337
426
  ...(lastRouteDecision ? { lastRouteDecision } : {}),
338
427
  });
@@ -341,7 +430,8 @@ export function createPlaybookCaptainShell(
341
430
  from: ShellMode,
342
431
  to: ShellMode,
343
432
  event: string,
344
- playbookId: string | undefined = active?.entry.id,
433
+ playbookId: string | undefined = leafFrame()?.entry.id,
434
+ activeSessionId: string | undefined = leafFrame()?.sessionId,
345
435
  ): Promise<void> => {
346
436
  await requireSession().emitTelemetry({
347
437
  topic: SHELL_FSM_TOPIC,
@@ -349,7 +439,7 @@ export function createPlaybookCaptainShell(
349
439
  from,
350
440
  to,
351
441
  event,
352
- ledger: ledgerSnapshot(playbookId),
442
+ ledger: ledgerSnapshot(playbookId, activeSessionId),
353
443
  },
354
444
  });
355
445
  };
@@ -357,12 +447,19 @@ export function createPlaybookCaptainShell(
357
447
  const setMode = async (
358
448
  nextMode: ShellMode,
359
449
  event: string,
360
- playbookId: string | undefined = active?.entry.id,
450
+ playbookId: string | undefined = leafFrame()?.entry.id,
451
+ activeSessionId: string | undefined = leafFrame()?.sessionId,
361
452
  ): Promise<void> => {
362
453
  if (mode === nextMode) return;
363
454
  const from = mode;
364
455
  mode = nextMode;
365
- await emitShellTelemetry(from, nextMode, event, playbookId);
456
+ await emitShellTelemetry(
457
+ from,
458
+ nextMode,
459
+ event,
460
+ playbookId,
461
+ activeSessionId,
462
+ );
366
463
  };
367
464
 
368
465
  const normalizeErrorCompact = (
@@ -391,69 +488,193 @@ export function createPlaybookCaptainShell(
391
488
  ? (payload as Record<string, unknown>)
392
489
  : undefined;
393
490
 
394
- const mirroredStateId = (payload: unknown): string | undefined => {
395
- const record = payloadRecord(payload);
396
- if (!record) return undefined;
397
- if (typeof record.to === 'string') return record.to;
398
- return typeof record.state === 'string' ? record.state : undefined;
491
+ const playbookState = (value: unknown): PlaybookState | undefined => {
492
+ const record = payloadRecord(value);
493
+ if (
494
+ !record ||
495
+ !Array.isArray(record.activeStateIds) ||
496
+ !record.activeStateIds.every((id) => typeof id === 'string') ||
497
+ !Array.isArray(record.tags) ||
498
+ !record.tags.every((tag) => typeof tag === 'string') ||
499
+ typeof record.status !== 'string' ||
500
+ typeof record.quiescent !== 'boolean' ||
501
+ !('value' in record)
502
+ ) {
503
+ return undefined;
504
+ }
505
+ return record as unknown as PlaybookState;
399
506
  };
400
507
 
401
- const mirrorSubRuntimeTelemetry = async (payload: unknown): Promise<void> => {
402
- if (!active) return;
403
- const record = payloadRecord(payload);
404
- const stateId = mirroredStateId(payload);
405
- if (stateId === undefined) return;
406
-
407
- const countLabel = stateCountLabel(stateId, active.entry);
408
- if (activeTurnSummary && countLabel) {
409
- activeTurnSummary.stateCounts.set(
410
- countLabel,
411
- (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1,
412
- );
508
+ const stateValueContains = (value: unknown, stateId: string): boolean => {
509
+ if (typeof value === 'string') return value === stateId;
510
+ const record = payloadRecord(value);
511
+ if (!record) return false;
512
+ return Object.entries(record).some(
513
+ ([key, nested]) => key === stateId || stateValueContains(nested, stateId),
514
+ );
515
+ };
516
+
517
+ const drainHostCalls = async (
518
+ calls: Set<Promise<unknown>>,
519
+ ): Promise<void> => {
520
+ while (calls.size > 0) {
521
+ await Promise.allSettled([...calls]);
413
522
  }
523
+ };
414
524
 
415
- latestSubRuntimeStateId = stateId;
416
- pendingBossQuestion = record?.pendingBossQuestion;
417
- lastError = normalizeErrorCompact(record?.lastError);
525
+ const trackHostCall = <T>(
526
+ frame: EngagementFrame,
527
+ call: Promise<T>,
528
+ ): Promise<T> => {
529
+ // Cligent's host methods are scoped to the whole Boss turn, while an
530
+ // XState invocation can carry a narrower sibling-cancellation signal.
531
+ // Keep both frame and turn ownership after XState stops awaiting the
532
+ // promise so the host cannot outlive frame disposal or turn settlement.
533
+ const turnCalls = activeTurnHostCalls;
534
+ let tracked!: Promise<T>;
535
+ tracked = call.finally(() => {
536
+ frame.inFlightHostCalls.delete(tracked);
537
+ turnCalls?.delete(tracked);
538
+ });
539
+ frame.inFlightHostCalls.add(tracked);
540
+ turnCalls?.add(tracked);
541
+ return tracked;
542
+ };
418
543
 
419
- if (stateId === active.entry.finalStateId) {
420
- finalDisposalRequested = active;
421
- return;
544
+ const callCaptainQueued = (
545
+ frame: EngagementFrame,
546
+ context: CaptainContext,
547
+ prompt: string,
548
+ options: Parameters<CaptainContext['callCaptain']>[1],
549
+ signal: AbortSignal,
550
+ ): ReturnType<CaptainContext['callCaptain']> => {
551
+ const queued = captainQueue.add(async () => {
552
+ signal.throwIfAborted();
553
+ const result = await trackHostCall(
554
+ frame,
555
+ context.callCaptain(prompt, options),
556
+ );
557
+ signal.throwIfAborted();
558
+ return result;
559
+ });
560
+ return trackHostCall(frame, queued);
561
+ };
562
+
563
+ const mirrorSubRuntimeTelemetry = async (
564
+ frame: EngagementFrame,
565
+ payload: unknown,
566
+ ): Promise<void> => {
567
+ const record = payloadRecord(payload);
568
+ const state = playbookState(record?.state);
569
+ if (!record || !state) return;
570
+ const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
571
+ frame.state = state;
572
+
573
+ if (activeTurnSummary?.owner === frame) {
574
+ for (const stateId of state.activeStateIds) {
575
+ const newlyActive = !previousActiveIds.has(stateId);
576
+ const structuredEntry =
577
+ stateValueContains(record.to, stateId) &&
578
+ !stateValueContains(record.from, stateId);
579
+ if (!newlyActive && !structuredEntry) continue;
580
+ const countLabel = stateCountLabel(stateId, frame.entry);
581
+ if (countLabel) {
582
+ activeTurnSummary.stateCounts.set(
583
+ countLabel,
584
+ (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1,
585
+ );
586
+ }
587
+ }
422
588
  }
423
589
 
424
- if (
425
- stateId === active.entry.idleStateId ||
426
- active.entry.parkStateIds.includes(stateId)
427
- ) {
428
- await setMode('engaged.parked', `sub-runtime:${stateId}`);
590
+ if (leafFrame() === frame) {
591
+ pendingBossQuestions =
592
+ record.pendingBossQuestions ?? record.pendingBossQuestion;
593
+ lastError = normalizeErrorCompact(record.lastError);
594
+ if (state.quiescent && state.tags.includes('playbook.parked')) {
595
+ await setMode(
596
+ 'engaged.parked',
597
+ `sub-runtime:${state.stateId ?? 'structured'}`,
598
+ );
599
+ }
429
600
  }
430
601
  };
431
602
 
432
- const createPorts = (): PlaybookPorts => ({
433
- callPlayer: async (playerId, prompt, _signal) => {
603
+ let callNestedPlaybook: (
604
+ frame: EngagementFrame,
605
+ request: PlaybookCallRequest,
606
+ signal: AbortSignal,
607
+ ) => Promise<PlaybookCallStart>;
608
+
609
+ const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
610
+ callPlayer: async (playerId, prompt, signal, options) => {
434
611
  if (!activeContext) {
435
612
  throw new Error('callPlayer invoked outside a Boss turn');
436
613
  }
437
- const hostPlayerId = active
438
- ? active.enablement.hostPlayerId(playerId)
439
- : playerId;
440
- const result = await activeContext.callPlayer(hostPlayerId, prompt);
441
- if (activeTurnSummary) {
614
+ const context = activeContext;
615
+ signal.throwIfAborted();
616
+ const hostPlayerId = frame.enablement.hostPlayerId(playerId);
617
+ const result = await trackHostCall(
618
+ frame,
619
+ context.callPlayer(hostPlayerId, prompt, {
620
+ resume: options.resume,
621
+ }),
622
+ );
623
+ // CaptainContext is turn-scoped and cannot accept a narrower XState
624
+ // invocation signal. Recheck after the host call so a sibling
625
+ // cancellation is still reported as aborted and cannot rotate a
626
+ // stopped branch's player token in the linked runtime.
627
+ signal.throwIfAborted();
628
+ if (activeTurnSummary?.owner === frame) {
442
629
  activeTurnSummary.counts.interruptions++;
443
630
  }
444
631
  return {
445
632
  status: result.status,
446
- finalText: result.finalText,
447
- error: result.error,
633
+ ...(result.resumeToken !== undefined
634
+ ? { resumeToken: result.resumeToken }
635
+ : {}),
636
+ ...(result.finalText !== undefined
637
+ ? { finalText: result.finalText }
638
+ : {}),
639
+ ...(result.error !== undefined ? { error: result.error } : {}),
448
640
  };
449
641
  },
450
- callJudge: async (prompt, _signal) => {
642
+ callCaptain: async (prompt, signal, options) => {
643
+ if (!activeContext) {
644
+ throw new Error('callCaptain invoked outside a Boss turn');
645
+ }
646
+ const result = await callCaptainQueued(
647
+ frame,
648
+ activeContext,
649
+ prompt,
650
+ {
651
+ visibility: options.visibility,
652
+ resume: options.resume,
653
+ ...(options.allowedTools === undefined
654
+ ? {}
655
+ : { allowedTools: options.allowedTools }),
656
+ },
657
+ signal,
658
+ );
659
+ return {
660
+ status: result.status,
661
+ ...(result.finalText !== undefined
662
+ ? { finalText: result.finalText }
663
+ : {}),
664
+ ...(result.error !== undefined ? { error: result.error } : {}),
665
+ };
666
+ },
667
+ callJudge: async (prompt, signal) => {
451
668
  if (!activeContext) {
452
669
  throw new Error('callJudge invoked outside a Boss turn');
453
670
  }
454
- const result = await activeContext.callCaptain(prompt, {
455
- visibility: 'hidden',
456
- });
671
+ const result = await callCaptainQueued(
672
+ frame,
673
+ activeContext,
674
+ prompt,
675
+ { visibility: 'hidden', resume: false, allowedTools: [] },
676
+ signal,
677
+ );
457
678
  if (result.status !== 'ok') {
458
679
  throw new Error(
459
680
  result.error ?? `callCaptain status "${result.status}"`,
@@ -465,14 +686,28 @@ export function createPlaybookCaptainShell(
465
686
  const guard = guardFromJudgeReply(result.finalText);
466
687
  if (
467
688
  guard &&
468
- active?.entry.summaryPolicy?.copyPasteGuardNames.includes(guard) &&
469
- activeTurnSummary
689
+ activeTurnSummary?.owner === frame &&
690
+ frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
470
691
  ) {
471
692
  activeTurnSummary.counts.copyPastes++;
472
693
  }
473
694
  return result.finalText;
474
695
  },
696
+ callPlaybook: (request, signal) => {
697
+ const opening = callNestedPlaybook(frame, request, signal);
698
+ let exposed!: Promise<PlaybookCallStart>;
699
+ const registerOpeningCleanup = (): void => {
700
+ registerPlaybookAbortCleanup(signal, exposed);
701
+ };
702
+ exposed = opening.finally(() => {
703
+ signal.removeEventListener('abort', registerOpeningCleanup);
704
+ });
705
+ signal.addEventListener('abort', registerOpeningCleanup, { once: true });
706
+ if (signal.aborted) registerOpeningCleanup();
707
+ return exposed;
708
+ },
475
709
  emitStatus: async (message, data) => {
710
+ if (frame.internal) return;
476
711
  await requireSession().emitStatus(
477
712
  message,
478
713
  data as Record<string, unknown> | undefined,
@@ -480,7 +715,7 @@ export function createPlaybookCaptainShell(
480
715
  },
481
716
  emitTelemetry: async (event) => {
482
717
  if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
483
- await mirrorSubRuntimeTelemetry(event.payload);
718
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
484
719
  }
485
720
  await requireSession().emitTelemetry(event);
486
721
  },
@@ -493,65 +728,755 @@ export function createPlaybookCaptainShell(
493
728
  const requestVisibility = async (enablement: Enablement): Promise<void> => {
494
729
  const ids = enablement.visiblePlayerIds;
495
730
  if (!ids || ids.length === 0 || !activeContext) return;
496
- await activeContext.setVisiblePlayers(ids);
731
+ try {
732
+ await activeContext.setVisiblePlayers(ids);
733
+ } catch (error) {
734
+ throw new VisibilityControlError(error);
735
+ }
497
736
  };
498
737
 
499
- const engage = async (
500
- entry: PlaybookCaptainRegistryEntry,
501
- ): Promise<ActiveEngagement> => {
502
- if (active?.entry.id === entry.id) return active;
503
- const enablement = enablementById.get(entry.id)!;
738
+ const allocateSessionId = (): string => {
739
+ const sessionId = createSessionId();
740
+ if (!UUID_PATTERN.test(sessionId)) {
741
+ throw new Error(
742
+ `playbook session id generator returned a non-UUID value: ${JSON.stringify(
743
+ sessionId,
744
+ )}`,
745
+ );
746
+ }
747
+ if (issuedSessionIds.has(sessionId)) {
748
+ throw new Error(`playbook session id collision: ${sessionId}`);
749
+ }
750
+ issuedSessionIds.add(sessionId);
751
+ return sessionId;
752
+ };
753
+
754
+ const normalizeErrorFull = (value: unknown): NormalizedError => {
755
+ const compact = normalizeErrorCompact(value) ?? {
756
+ name: 'Error',
757
+ message: String(value),
758
+ };
759
+ const stack =
760
+ value instanceof Error
761
+ ? value.stack
762
+ : typeof value === 'object' && value !== null
763
+ ? (value as Record<string, unknown>).stack
764
+ : undefined;
765
+ return typeof stack === 'string' ? { ...compact, stack } : compact;
766
+ };
767
+
768
+ const makeFrame = (
769
+ enablement: Enablement,
770
+ parent?: { frame: EngagementFrame; callId: string },
771
+ internal = false,
772
+ ): EngagementFrame => {
773
+ const entry = enablement.entry;
774
+ const sessionId = allocateSessionId();
504
775
  const runtime = entry.createRuntime({
505
776
  captainOptions: enablement.optionInput,
506
777
  players: enablement.boundPlayers,
507
778
  });
508
- active = { entry, enablement, runtime };
509
- latestSubRuntimeStateId = undefined;
510
- pendingBossQuestion = undefined;
779
+ return {
780
+ entry,
781
+ enablement,
782
+ runtime,
783
+ sessionId,
784
+ rootSessionId: parent?.frame.rootSessionId ?? sessionId,
785
+ depth: parent ? parent.frame.depth + 1 : 0,
786
+ ...(parent ? { parent } : {}),
787
+ inFlightHostCalls: new Set(),
788
+ internal,
789
+ };
790
+ };
791
+
792
+ const initFrame = async (frame: EngagementFrame): Promise<void> => {
793
+ await frame.runtime.init({
794
+ sessionId: frame.sessionId,
795
+ playbookId: frame.entry.id,
796
+ rootSessionId: frame.rootSessionId,
797
+ ...(frame.parent
798
+ ? {
799
+ parentSessionId: frame.parent.frame.sessionId,
800
+ parentCallId: frame.parent.callId,
801
+ }
802
+ : {}),
803
+ depth: frame.depth,
804
+ ports: createPorts(frame),
805
+ });
806
+ };
807
+
808
+ const clearLeafLedger = (): void => {
809
+ pendingBossQuestions = undefined;
511
810
  lastError = undefined;
512
- finalDisposalRequested = undefined;
513
- await setMode('engaged.parked', 'engage', entry.id);
514
- await runtime.init(createPorts());
515
- await requireSession().emitStatus(`◇ /${enablement.command} started`);
516
- return active;
811
+ };
812
+
813
+ const engageEnablement = async (
814
+ enablement: Enablement,
815
+ internal: boolean,
816
+ ): Promise<EngagementFrame> => {
817
+ const entry = enablement.entry;
818
+ const existing = rootFrame();
819
+ if (existing?.entry.id === entry.id && frames.length === 1) {
820
+ return existing;
821
+ }
822
+ if (existing) {
823
+ throw new Error('cannot engage a second root playbook');
824
+ }
825
+ const frame = makeFrame(enablement, undefined, internal);
826
+ frames.push(frame);
827
+ clearLeafLedger();
828
+ try {
829
+ await setMode('engaged.parked', 'engage', entry.id, frame.sessionId);
830
+ await initFrame(frame);
831
+ if (!internal) {
832
+ await requireSession().emitStatus(`◇ ${frameLabel(frame)} started`);
833
+ }
834
+ return frame;
835
+ } catch (error) {
836
+ if (leafFrame() === frame) frames.pop();
837
+ clearLeafLedger();
838
+ try {
839
+ await frame.runtime.dispose();
840
+ } catch {
841
+ // Preserve the initialization failure while still making a
842
+ // best-effort attempt to release partially acquired resources.
843
+ }
844
+ try {
845
+ await setMode('chat', 'engage.failed');
846
+ } catch {
847
+ // setMode updates the authoritative mode before telemetry; preserve
848
+ // the initialization failure if that recovery emission also fails.
849
+ mode = 'chat';
850
+ }
851
+ throw error;
852
+ }
853
+ };
854
+
855
+ const engage = async (
856
+ entry: PlaybookCaptainRegistryEntry,
857
+ ): Promise<EngagementFrame> =>
858
+ engageEnablement(enablementById.get(entry.id)!, false);
859
+
860
+ const createInternalCaptainEnablement = (): Enablement => {
861
+ const catalog = Object.freeze(
862
+ entries.map((entry) =>
863
+ Object.freeze({
864
+ id: entry.id,
865
+ command: enablementById.get(entry.id)!.command,
866
+ intent: entry.intent,
867
+ }),
868
+ ),
869
+ );
870
+ const entry: PlaybookCaptainRegistryEntry = {
871
+ id: INTERNAL_CAPTAIN_ID,
872
+ command: INTERNAL_CAPTAIN_ID,
873
+ intent: 'internal orchestration policy',
874
+ requiredRoleIds: [],
875
+ validateOptions: () => undefined,
876
+ createRuntime: () => createCaptainRuntime({ enabledPlaybooks: catalog }),
877
+ };
878
+ return {
879
+ entry,
880
+ command: INTERNAL_CAPTAIN_ID,
881
+ optionInput: undefined,
882
+ boundPlayers: [],
883
+ hostPlayerId(localRole) {
884
+ throw new Error(
885
+ `internal Captain has no player binding for ${JSON.stringify(localRole)}`,
886
+ );
887
+ },
888
+ };
889
+ };
890
+
891
+ const engageInternalCaptain = async (): Promise<EngagementFrame> => {
892
+ if (!internalCaptainEnablement) {
893
+ throw new Error('internal Captain enablement is unavailable before init');
894
+ }
895
+ return engageEnablement(internalCaptainEnablement, true);
896
+ };
897
+
898
+ const disposeFrame = (frame: EngagementFrame): Promise<void> => {
899
+ if (frame.disposePromise) return frame.disposePromise;
900
+ const operation = (async (): Promise<void> => {
901
+ if (frame.invocationSignal && frame.abortListener) {
902
+ frame.invocationSignal.removeEventListener(
903
+ 'abort',
904
+ frame.abortListener,
905
+ );
906
+ }
907
+ frame.invocationSignal = undefined;
908
+ frame.abortListener = undefined;
909
+ let disposeError: unknown;
910
+ try {
911
+ await frame.runtime.dispose();
912
+ } catch (error) {
913
+ disposeError = error;
914
+ }
915
+ await drainHostCalls(frame.inFlightHostCalls);
916
+ if (disposeError !== undefined) throw disposeError;
917
+ })();
918
+ frame.disposePromise = operation;
919
+ return operation;
920
+ };
921
+
922
+ const removeTopFrame = (
923
+ frame: EngagementFrame,
924
+ reason: NonNullable<EngagementFrame['removal']>['reason'],
925
+ ): {
926
+ claimed: boolean;
927
+ reason: NonNullable<EngagementFrame['removal']>['reason'];
928
+ promise: Promise<void>;
929
+ } => {
930
+ if (frame.removal) {
931
+ return {
932
+ claimed: false,
933
+ reason: frame.removal.reason,
934
+ promise: frame.removal.promise,
935
+ };
936
+ }
937
+ const operation = (async (): Promise<void> => {
938
+ if (leafFrame() !== frame) {
939
+ throw new Error('nested playbook stack is not LIFO');
940
+ }
941
+ let removalError: unknown;
942
+ try {
943
+ await disposeFrame(frame);
944
+ } catch (error) {
945
+ removalError = error;
946
+ } finally {
947
+ if (leafFrame() === frame) {
948
+ frames.pop();
949
+ if (frame.parent) {
950
+ pendingChildParents.delete(frame.parent.frame);
951
+ }
952
+ pendingChildParents.delete(frame);
953
+ } else if (frames.includes(frame)) {
954
+ const stackError = new Error(
955
+ 'nested playbook stack changed during frame removal',
956
+ );
957
+ removalError =
958
+ removalError === undefined
959
+ ? stackError
960
+ : new AggregateError(
961
+ [removalError, stackError],
962
+ 'nested playbook frame removal failed',
963
+ );
964
+ }
965
+ }
966
+ if (removalError !== undefined) throw removalError;
967
+ })();
968
+ frame.removal = { reason, promise: operation };
969
+ return { claimed: true, reason, promise: operation };
970
+ };
971
+
972
+ const unwindFramesFrom = async (
973
+ frame: EngagementFrame,
974
+ reason: NonNullable<EngagementFrame['removal']>['reason'] = 'stack',
975
+ ): Promise<void> => {
976
+ const index = frames.indexOf(frame);
977
+ if (index < 0) return;
978
+ const failures: unknown[] = [];
979
+ while (frames.length > index) {
980
+ const current = leafFrame()!;
981
+ const removal = removeTopFrame(current, reason);
982
+ try {
983
+ await removal.promise;
984
+ } catch (error) {
985
+ failures.push(error);
986
+ }
987
+ if (frames.includes(current)) {
988
+ failures.push(
989
+ new Error('nested playbook frame remained after removal attempt'),
990
+ );
991
+ break;
992
+ }
993
+ }
994
+ clearLeafLedger();
995
+ if (failures.length === 1) throw failures[0];
996
+ if (failures.length > 1) {
997
+ throw new AggregateError(
998
+ failures,
999
+ 'nested playbook stack disposal failed',
1000
+ );
1001
+ }
1002
+ };
1003
+
1004
+ const popChild = async (
1005
+ frame: EngagementFrame,
1006
+ status: 'returned' | 'stopped',
1007
+ ): Promise<boolean> => {
1008
+ if (!frame.parent || (leafFrame() !== frame && !frame.removal)) {
1009
+ throw new Error('nested playbook stack is not LIFO');
1010
+ }
1011
+ const parent = frame.parent.frame;
1012
+ const removal = removeTopFrame(frame, 'return');
1013
+ if (!removal.claimed) {
1014
+ await removal.promise;
1015
+ return false;
1016
+ }
1017
+ let cleanupError: unknown;
1018
+ try {
1019
+ await removal.promise;
1020
+ } catch (error) {
1021
+ cleanupError = error;
1022
+ }
1023
+ const message =
1024
+ status === 'returned'
1025
+ ? `◇ ${frameLabel(frame)} returned to ${frameLabel(parent)}`
1026
+ : `◇ ${frameLabel(frame)} stopped; returning to ${frameLabel(parent)}`;
1027
+ try {
1028
+ await requireSession().emitStatus(message);
1029
+ } catch (error) {
1030
+ cleanupError =
1031
+ cleanupError === undefined
1032
+ ? error
1033
+ : new AggregateError(
1034
+ [cleanupError, error],
1035
+ 'nested playbook return cleanup failed',
1036
+ );
1037
+ }
1038
+ let visibilityError: unknown;
1039
+ try {
1040
+ await requestVisibility(parent.enablement);
1041
+ } catch (error) {
1042
+ visibilityError = error;
1043
+ }
1044
+ if (visibilityError !== undefined) {
1045
+ if (cleanupError !== undefined) {
1046
+ throw new VisibilityControlError(
1047
+ new AggregateError(
1048
+ [cleanupError, visibilityError],
1049
+ 'nested playbook return and visibility failed',
1050
+ ),
1051
+ );
1052
+ }
1053
+ throw visibilityError;
1054
+ }
1055
+ if (cleanupError !== undefined) throw cleanupError;
1056
+ return true;
1057
+ };
1058
+
1059
+ const disposeStack = async (reason: DisposalReason): Promise<void> => {
1060
+ const root = rootFrame();
1061
+ if (!root) return;
1062
+ const rootId = root.entry.id;
1063
+ const rootSessionId = root.sessionId;
1064
+ const failures: unknown[] = [];
1065
+ disposing = true;
1066
+ try {
1067
+ if (reason !== 'dispose') {
1068
+ try {
1069
+ await setMode('chat', reason, rootId, rootSessionId);
1070
+ } catch (error) {
1071
+ failures.push(error);
1072
+ mode = 'chat';
1073
+ }
1074
+ } else {
1075
+ mode = 'chat';
1076
+ }
1077
+ try {
1078
+ await unwindFramesFrom(root);
1079
+ } catch (error) {
1080
+ failures.push(error);
1081
+ }
1082
+ } finally {
1083
+ disposing = false;
1084
+ pendingChildParents.clear();
1085
+ clearLeafLedger();
1086
+ }
1087
+ if (!root.internal) {
1088
+ try {
1089
+ if (reason === 'dismiss') {
1090
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} stopped`);
1091
+ } else if (reason === 'final') {
1092
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} finished`);
1093
+ }
1094
+ } catch (error) {
1095
+ failures.push(error);
1096
+ }
1097
+ }
1098
+ if (failures.length === 1) throw failures[0];
1099
+ if (failures.length > 1) {
1100
+ throw new AggregateError(failures, 'playbook stack disposal failed');
1101
+ }
1102
+ };
1103
+
1104
+ const callResultFor = (
1105
+ frame: EngagementFrame,
1106
+ result: PlaybookRunResult,
1107
+ ): PlaybookCallResult => {
1108
+ if (result.outcome === 'terminal') {
1109
+ return {
1110
+ status: 'ok',
1111
+ playbookId: frame.entry.id,
1112
+ childSessionId: frame.sessionId,
1113
+ state: result.state,
1114
+ ...(result.output !== undefined ? { output: result.output } : {}),
1115
+ };
1116
+ }
1117
+ if (result.outcome === 'aborted') {
1118
+ return {
1119
+ status: 'aborted',
1120
+ playbookId: frame.entry.id,
1121
+ childSessionId: frame.sessionId,
1122
+ state: result.state,
1123
+ ...(result.error ? { error: result.error } : {}),
1124
+ };
1125
+ }
1126
+ throw new Error(`playbook ${frame.entry.id} has not returned`);
1127
+ };
1128
+
1129
+ const assertRetainableResult = (
1130
+ frame: EngagementFrame,
1131
+ result: PlaybookRunResult,
1132
+ ): void => {
1133
+ if (result.outcome === 'suspended') return;
1134
+ if (
1135
+ result.state.quiescent &&
1136
+ result.state.tags.includes('playbook.parked')
1137
+ ) {
1138
+ return;
1139
+ }
1140
+ throw new Error(
1141
+ `playbook ${frame.entry.id} returned outcome "${result.outcome}" ` +
1142
+ 'without a quiescent playbook.parked state',
1143
+ );
1144
+ };
1145
+
1146
+ const driveFrame = async (
1147
+ frame: EngagementFrame,
1148
+ text: string,
1149
+ context: CaptainContext,
1150
+ signal: AbortSignal = context.signal,
1151
+ ): Promise<PlaybookRunResult> => {
1152
+ if (leafFrame() !== frame) {
1153
+ throw new Error('only the active leaf may receive Boss input');
1154
+ }
1155
+ await requestVisibility(frame.enablement);
1156
+ await setMode('engaged.driving', 'submit');
1157
+ const result = await frame.runtime.handleBossInput({
1158
+ text,
1159
+ signal,
1160
+ });
1161
+ frame.state = result.state;
1162
+ return result;
1163
+ };
1164
+
1165
+ async function resumeParent(
1166
+ child: EngagementFrame,
1167
+ callResult: PlaybookCallResult,
1168
+ context: CaptainContext,
1169
+ status: 'returned' | 'stopped' = 'returned',
1170
+ ): Promise<void> {
1171
+ const parentLink = child.parent;
1172
+ if (!parentLink) throw new Error('root playbook has no caller');
1173
+ const parent = parentLink.frame;
1174
+ const invocationSignal = child.invocationSignal;
1175
+ let effectiveResult = callResult;
1176
+ let ownsReturn = false;
1177
+ let visibilityControlError: unknown;
1178
+ try {
1179
+ ownsReturn = await popChild(child, status);
1180
+ } catch (error) {
1181
+ ownsReturn = child.removal?.reason === 'return';
1182
+ if (error instanceof VisibilityControlError) {
1183
+ visibilityControlError = error;
1184
+ } else {
1185
+ effectiveResult = {
1186
+ status: context.signal.aborted ? 'aborted' : 'error',
1187
+ playbookId: child.entry.id,
1188
+ childSessionId: child.sessionId,
1189
+ ...(child.state ? { state: child.state } : {}),
1190
+ error: normalizeErrorFull(error),
1191
+ };
1192
+ }
1193
+ }
1194
+ if (
1195
+ !ownsReturn ||
1196
+ disposing ||
1197
+ invocationSignal?.aborted ||
1198
+ !frames.includes(parent)
1199
+ ) {
1200
+ return;
1201
+ }
1202
+ let result: PlaybookRunResult;
1203
+ try {
1204
+ result = await parent.runtime.resumePlaybookCall({
1205
+ callId: parentLink.callId,
1206
+ result: effectiveResult,
1207
+ signal: context.signal,
1208
+ });
1209
+ } catch (error) {
1210
+ if (disposing || invocationSignal?.aborted) return;
1211
+ await returnBoundaryFailure(parent, error, context);
1212
+ return;
1213
+ }
1214
+ parent.state = result.state;
1215
+ await processFrameResult(parent, result, context);
1216
+ if (visibilityControlError !== undefined) throw visibilityControlError;
1217
+ }
1218
+
1219
+ async function returnBoundaryFailure(
1220
+ frame: EngagementFrame,
1221
+ error: unknown,
1222
+ context: CaptainContext,
1223
+ ): Promise<void> {
1224
+ if (!frame.parent) throw error;
1225
+ await resumeParent(
1226
+ frame,
1227
+ {
1228
+ status: context.signal.aborted ? 'aborted' : 'error',
1229
+ playbookId: frame.entry.id,
1230
+ childSessionId: frame.sessionId,
1231
+ ...(frame.state ? { state: frame.state } : {}),
1232
+ error: normalizeErrorFull(error),
1233
+ },
1234
+ context,
1235
+ );
1236
+ }
1237
+
1238
+ async function processFrameResult(
1239
+ frame: EngagementFrame,
1240
+ result: PlaybookRunResult,
1241
+ context: CaptainContext,
1242
+ ): Promise<void> {
1243
+ if (result.outcome === 'terminal') {
1244
+ if (frame.parent) {
1245
+ await resumeParent(frame, callResultFor(frame, result), context);
1246
+ } else {
1247
+ await disposeStack('final');
1248
+ }
1249
+ return;
1250
+ }
1251
+ if (result.outcome === 'aborted' && frame.parent) {
1252
+ await resumeParent(frame, callResultFor(frame, result), context);
1253
+ return;
1254
+ }
1255
+ assertRetainableResult(frame, result);
1256
+ if (leafFrame()) {
1257
+ await setMode('engaged.parked', `turn:${result.outcome}`);
1258
+ }
1259
+ }
1260
+
1261
+ const disposeAbandonedChild = async (
1262
+ child: EngagementFrame,
1263
+ ): Promise<void> => {
1264
+ if (disposing || !frames.includes(child) || !child.parent) return;
1265
+ if (child.removal) {
1266
+ await child.removal.promise;
1267
+ return;
1268
+ }
1269
+ const parent = child.parent.frame;
1270
+ let cleanupError: unknown;
1271
+ try {
1272
+ await unwindFramesFrom(child, 'abandoned');
1273
+ } catch (error) {
1274
+ cleanupError = error;
1275
+ }
1276
+ if (frames.includes(parent)) {
1277
+ await requestVisibility(parent.enablement);
1278
+ }
1279
+ if (cleanupError !== undefined) throw cleanupError;
1280
+ };
1281
+
1282
+ callNestedPlaybook = async (
1283
+ parent,
1284
+ request,
1285
+ invocationSignal,
1286
+ ): Promise<PlaybookCallStart> => {
1287
+ if (!activeContext) {
1288
+ throw new Error('callPlaybook invoked outside a Boss turn');
1289
+ }
1290
+ invocationSignal.throwIfAborted();
1291
+ if (leafFrame() !== parent) {
1292
+ throw new Error('only the active leaf may call a child playbook');
1293
+ }
1294
+ if (pendingChildParents.has(parent)) {
1295
+ throw new Error('playbook frame already has an outstanding child');
1296
+ }
1297
+ if (typeof request.callId !== 'string' || request.callId.trim() === '') {
1298
+ throw new Error('nested playbook call id must be a non-empty string');
1299
+ }
1300
+ if (
1301
+ typeof request.playbookId !== 'string' ||
1302
+ request.playbookId.trim() === ''
1303
+ ) {
1304
+ throw new Error('nested playbook id must be a non-empty string');
1305
+ }
1306
+ if (typeof request.text !== 'string') {
1307
+ throw new Error('nested playbook input text must be a string');
1308
+ }
1309
+ if (request.playbookId === INTERNAL_CAPTAIN_ID) {
1310
+ throw new Error('the internal Captain playbook cannot call itself');
1311
+ }
1312
+ const entry = byId.get(request.playbookId);
1313
+ if (!entry) {
1314
+ throw new Error(`playbook "${request.playbookId}" is not enabled`);
1315
+ }
1316
+ if (frames.some((frame) => frame.entry.id === request.playbookId)) {
1317
+ throw new Error(
1318
+ `nested playbook cycle: ${[
1319
+ ...frames.map((frame) => frame.entry.id),
1320
+ request.playbookId,
1321
+ ].join(' -> ')}`,
1322
+ );
1323
+ }
1324
+
1325
+ pendingChildParents.add(parent);
1326
+ let child: EngagementFrame;
1327
+ try {
1328
+ child = makeFrame(enablementById.get(entry.id)!, {
1329
+ frame: parent,
1330
+ callId: request.callId,
1331
+ });
1332
+ } catch (error) {
1333
+ pendingChildParents.delete(parent);
1334
+ throw error;
1335
+ }
1336
+ frames.push(child);
1337
+ clearLeafLedger();
1338
+ let calledStatusEmitted = false;
1339
+ let returnStatusHandled = false;
1340
+ try {
1341
+ await initFrame(child);
1342
+ invocationSignal.throwIfAborted();
1343
+ await requireSession().emitStatus(
1344
+ `◇ ${frameLabel(child)} called by ${frameLabel(parent)}`,
1345
+ );
1346
+ calledStatusEmitted = true;
1347
+ const result = await driveFrame(
1348
+ child,
1349
+ request.text,
1350
+ activeContext,
1351
+ AbortSignal.any([invocationSignal, activeContext.signal]),
1352
+ );
1353
+ if (result.outcome === 'terminal' || result.outcome === 'aborted') {
1354
+ const callResult = callResultFor(child, result);
1355
+ returnStatusHandled = true;
1356
+ const returned = await popChild(
1357
+ child,
1358
+ result.outcome === 'aborted' ? 'stopped' : 'returned',
1359
+ );
1360
+ if (!returned) {
1361
+ throw new Error('nested playbook return lost its active frame');
1362
+ }
1363
+ return { state: 'settled', result: callResult };
1364
+ }
1365
+ assertRetainableResult(child, result);
1366
+ if (invocationSignal.aborted) {
1367
+ const callResult: PlaybookCallResult = {
1368
+ status: 'aborted',
1369
+ playbookId: child.entry.id,
1370
+ childSessionId: child.sessionId,
1371
+ state: result.state,
1372
+ };
1373
+ returnStatusHandled = true;
1374
+ const returned = await popChild(child, 'stopped');
1375
+ if (!returned) {
1376
+ throw new Error('nested playbook abort lost its active frame');
1377
+ }
1378
+ return { state: 'settled', result: callResult };
1379
+ }
1380
+ const abortListener = (): void => {
1381
+ registerPlaybookAbortCleanup(
1382
+ invocationSignal,
1383
+ disposeAbandonedChild(child),
1384
+ );
1385
+ };
1386
+ child.invocationSignal = invocationSignal;
1387
+ child.abortListener = abortListener;
1388
+ invocationSignal.addEventListener('abort', abortListener, { once: true });
1389
+ return { state: 'suspended', childSessionId: child.sessionId };
1390
+ } catch (error) {
1391
+ let boundaryError = error;
1392
+ let visibilityControlFailure = error instanceof VisibilityControlError;
1393
+ if (frames.includes(child)) {
1394
+ try {
1395
+ await unwindFramesFrom(child, 'stack');
1396
+ } catch (cleanupError) {
1397
+ boundaryError = new AggregateError(
1398
+ [error, cleanupError],
1399
+ 'nested playbook call and cleanup failed',
1400
+ );
1401
+ }
1402
+ }
1403
+ pendingChildParents.delete(parent);
1404
+ if (calledStatusEmitted && !returnStatusHandled) {
1405
+ try {
1406
+ await requireSession().emitStatus(
1407
+ `◇ ${frameLabel(child)} stopped; returning to ${frameLabel(parent)}`,
1408
+ );
1409
+ } catch (statusError) {
1410
+ boundaryError = new AggregateError(
1411
+ [boundaryError, statusError],
1412
+ 'nested playbook failure status emission failed',
1413
+ );
1414
+ }
1415
+ }
1416
+ if (frames.includes(parent)) {
1417
+ try {
1418
+ await requestVisibility(parent.enablement);
1419
+ } catch (visibilityError) {
1420
+ visibilityControlFailure = true;
1421
+ boundaryError = new AggregateError(
1422
+ [boundaryError, visibilityError],
1423
+ 'nested playbook call return failed',
1424
+ );
1425
+ }
1426
+ }
1427
+ if (visibilityControlFailure) throw boundaryError;
1428
+ return {
1429
+ state: 'settled',
1430
+ result: {
1431
+ status: invocationSignal.aborted ? 'aborted' : 'error',
1432
+ playbookId: request.playbookId,
1433
+ childSessionId: child.sessionId,
1434
+ error: normalizeErrorFull(boundaryError),
1435
+ },
1436
+ };
1437
+ }
517
1438
  };
518
1439
 
519
1440
  const submitToActive = async (
520
- engagement: ActiveEngagement,
1441
+ frame: EngagementFrame,
521
1442
  text: string,
522
1443
  context: CaptainContext,
523
1444
  ): Promise<void> => {
524
- await requestVisibility(engagement.enablement);
525
- const policy = engagement.entry.summaryPolicy;
1445
+ const policy = frame.entry.summaryPolicy;
526
1446
  const summaryCounts: TurnSummaryCounts = {
527
1447
  interruptions: 0,
528
1448
  copyPastes: 0,
529
1449
  };
530
1450
  const summaryStateCounts = new Map<string, number>();
531
- let shouldSummarize = false;
532
1451
  activeTurnSummary = policy
533
- ? { counts: summaryCounts, stateCounts: summaryStateCounts }
1452
+ ? {
1453
+ owner: frame,
1454
+ counts: summaryCounts,
1455
+ stateCounts: summaryStateCounts,
1456
+ }
534
1457
  : undefined;
535
- await setMode('engaged.driving', 'submit');
1458
+ let completed = false;
536
1459
  try {
537
- await engagement.runtime.handleBossInput({
538
- text,
539
- signal: context.signal,
540
- });
541
- shouldSummarize = policy !== undefined;
1460
+ const result = await driveFrame(frame, text, context);
1461
+ await processFrameResult(frame, result, context);
1462
+ completed = true;
1463
+ } catch (error) {
1464
+ if (frame.parent && frames.includes(frame)) {
1465
+ await returnBoundaryFailure(frame, error, context);
1466
+ completed = true;
1467
+ } else {
1468
+ throw error;
1469
+ }
542
1470
  } finally {
543
1471
  activeTurnSummary = undefined;
544
- if (active === engagement && finalDisposalRequested === engagement) {
545
- finalDisposalRequested = undefined;
546
- await disposeActive('final');
547
- } else if (active === engagement && mode === 'engaged.driving') {
1472
+ if (leafFrame() && mode === 'engaged.driving') {
548
1473
  await setMode('engaged.parked', 'turn.settled');
549
1474
  }
550
1475
  }
551
- if (shouldSummarize && policy) {
1476
+ if (completed && policy) {
552
1477
  const progressRounds = summaryProgressRoundCount(summaryStateCounts);
553
- await callVisibleTurnSummary(context, {
554
- playbookId: engagement.entry.id,
1478
+ await callVisibleTurnSummary(frame, context, {
1479
+ playbookId: frame.entry.id,
555
1480
  submittedText: text,
556
1481
  counts: summaryCounts,
557
1482
  progressPhrase: summaryProgressPhrase(summaryStateCounts),
@@ -561,48 +1486,25 @@ export function createPlaybookCaptainShell(
561
1486
  }
562
1487
  };
563
1488
 
564
- const disposeActive = async (
565
- reason: DisposalReason,
566
- ): Promise<void> => {
567
- const engagement = active;
568
- if (!engagement) return;
569
- const playbookId = engagement.entry.id;
570
- const commandLabel = `/${engagement.enablement.command}`;
571
- active = undefined;
572
- finalDisposalRequested = undefined;
573
- if (reason === 'dispose') {
574
- mode = 'chat';
575
- await engagement.runtime.dispose();
576
- latestSubRuntimeStateId = undefined;
577
- pendingBossQuestion = undefined;
578
- lastError = undefined;
579
- return;
580
- }
581
- await setMode('chat', reason, playbookId);
582
- await engagement.runtime.dispose();
583
- if (reason === 'dismiss') {
584
- await requireSession().emitStatus(`◇ ${commandLabel} stopped`);
585
- } else if (reason === 'final') {
586
- await requireSession().emitStatus(`◇ ${commandLabel} finished`);
587
- }
588
- latestSubRuntimeStateId = undefined;
589
- pendingBossQuestion = undefined;
590
- lastError = undefined;
591
- };
592
-
593
1489
  const callVisibleChat = async (
1490
+ frame: EngagementFrame,
594
1491
  context: CaptainContext,
595
1492
  message: string,
596
1493
  ): Promise<void> => {
597
- const result = await context.callCaptain(visibleChatEnvelope(message));
1494
+ const result = await callCaptainQueued(
1495
+ frame,
1496
+ context,
1497
+ visibleChatEnvelope(message),
1498
+ { visibility: 'visible', resume: false, allowedTools: [] },
1499
+ context.signal,
1500
+ );
598
1501
  if (result.status !== 'ok') {
599
- throw new Error(
600
- result.error ?? `callCaptain status "${result.status}"`,
601
- );
1502
+ throw new Error(result.error ?? `callCaptain status "${result.status}"`);
602
1503
  }
603
1504
  };
604
1505
 
605
1506
  const callVisibleTurnSummary = async (
1507
+ frame: EngagementFrame,
606
1508
  context: CaptainContext,
607
1509
  input: {
608
1510
  playbookId: string;
@@ -613,48 +1515,34 @@ export function createPlaybookCaptainShell(
613
1515
  savedLine: string;
614
1516
  },
615
1517
  ): Promise<void> => {
616
- const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
1518
+ const result = await callCaptainQueued(
1519
+ frame,
1520
+ context,
1521
+ visibleTurnSummaryEnvelope(input),
1522
+ { visibility: 'visible', resume: false, allowedTools: [] },
1523
+ context.signal,
1524
+ );
617
1525
  if (result.status !== 'ok') {
618
- throw new Error(
619
- result.error ?? `callCaptain status "${result.status}"`,
620
- );
1526
+ throw new Error(result.error ?? `callCaptain status "${result.status}"`);
621
1527
  }
622
1528
  };
623
1529
 
624
- const hiddenRouterEnvelope = (prompt: string): string =>
1530
+ const hiddenLifecycleEnvelope = (prompt: string): string =>
625
1531
  [
626
- 'You are the Playbook Captain shell router.',
1532
+ 'You are the Playbook Captain shell lifecycle classifier.',
627
1533
  'This is hidden control work. Return only one JSON object and no prose.',
628
1534
  'Allowed decisions:',
629
- '{"decision":"chat","text":"visible clarification or chat reply"}',
630
- '{"decision":"dispatch","playbookId":"<registered id>","text":"Boss text for that playbook"}',
631
- '{"decision":"sub","text":"Boss text for the active playbook"}',
632
- '{"decision":"dismiss","text":"optional visible dismissal reply"}',
633
- 'Use chat for near-miss command-like input or low-confidence playbook selection.',
634
- 'Treat unregistered slash-prefixed input as ordinary router input.',
635
- `Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
636
- `Registry:\n${JSON.stringify(
637
- entries.map((entry) => ({
638
- id: entry.id,
639
- command: enablementById.get(entry.id)?.command ?? entry.command,
640
- intent: entry.intent,
641
- })),
642
- )}`,
1535
+ '{"decision":"deliver"}',
1536
+ '{"decision":"dismiss"}',
1537
+ 'Choose dismiss only when Boss explicitly asks to stop or dismiss the current active engagement.',
1538
+ 'Choose deliver for every task instruction, answer, clarification, continuation, command-like near miss, or ambiguous message.',
1539
+ 'Do not rewrite, summarize, or copy the Boss message into the result.',
643
1540
  `Boss message:\n${prompt}`,
644
1541
  ].join('\n\n');
645
1542
 
646
- const routerClarification = async (
647
- context: CaptainContext,
648
- ): Promise<void> => {
649
- await callVisibleChat(
650
- context,
651
- "I'm not sure whether this should be Captain chat or a /code task. Please clarify.",
652
- );
653
- };
654
-
655
- const parseRouterDecision = (
1543
+ const parseLifecycleDecision = (
656
1544
  finalText: string,
657
- ): RouterDecision | undefined => {
1545
+ ): LifecycleDecision | undefined => {
658
1546
  let parsed: unknown;
659
1547
  try {
660
1548
  parsed = JSON.parse(finalText);
@@ -670,91 +1558,57 @@ export function createPlaybookCaptainShell(
670
1558
  }
671
1559
  const record = parsed as Record<string, unknown>;
672
1560
  const decision = record.decision;
673
- if (decision === 'chat') {
674
- return typeof record.text === 'string' && record.text.trim()
675
- ? { decision, text: record.text.trim() }
676
- : undefined;
677
- }
678
- if (decision === 'dispatch') {
679
- return typeof record.playbookId === 'string' &&
680
- byId.has(record.playbookId) &&
681
- typeof record.text === 'string' &&
682
- record.text.trim()
683
- ? {
684
- decision,
685
- playbookId: record.playbookId,
686
- text: record.text.trim(),
687
- }
688
- : undefined;
689
- }
690
- if (decision === 'sub') {
691
- return typeof record.text === 'string' && record.text.trim()
692
- ? { decision, text: record.text.trim() }
693
- : undefined;
694
- }
695
- if (decision === 'dismiss') {
696
- return typeof record.text === 'string' && record.text.trim()
697
- ? { decision, text: record.text.trim() }
698
- : { decision };
699
- }
1561
+ if (decision === 'deliver') return { decision };
1562
+ if (decision === 'dismiss') return { decision };
700
1563
  return undefined;
701
1564
  };
702
1565
 
703
- const routeHidden = async (
1566
+ const routeEngaged = async (
704
1567
  turn: BossTurn,
705
1568
  context: CaptainContext,
706
1569
  ): Promise<void> => {
707
- const result = await context.callCaptain(
708
- hiddenRouterEnvelope(turn.prompt),
709
- { visibility: 'hidden' },
710
- );
711
- if (result.status !== 'ok' || result.finalText === undefined) {
712
- await routerClarification(context);
713
- return;
714
- }
715
- const decision = parseRouterDecision(result.finalText);
716
- if (!decision) {
717
- await routerClarification(context);
718
- return;
719
- }
720
- lastRouteDecision = decision.decision;
721
-
722
- if (decision.decision === 'chat') {
723
- await callVisibleChat(context, decision.text);
724
- return;
1570
+ const leaf = leafFrame();
1571
+ if (!leaf) {
1572
+ throw new Error('engaged lifecycle routing requires an active leaf');
725
1573
  }
726
-
727
- if (decision.decision === 'dispatch') {
728
- const entry = byId.get(decision.playbookId);
729
- if (!entry || (active && active.entry.id !== entry.id)) {
730
- await routerClarification(context);
731
- return;
1574
+ let decision: LifecycleDecision | undefined;
1575
+ try {
1576
+ const result = await callCaptainQueued(
1577
+ leaf,
1578
+ context,
1579
+ hiddenLifecycleEnvelope(turn.prompt),
1580
+ { visibility: 'hidden', resume: false, allowedTools: [] },
1581
+ context.signal,
1582
+ );
1583
+ if (result.status === 'ok' && result.finalText !== undefined) {
1584
+ decision = parseLifecycleDecision(result.finalText);
732
1585
  }
733
- const engagement = await engage(entry);
734
- await submitToActive(engagement, decision.text, context);
735
- return;
1586
+ } catch {
1587
+ // Lifecycle classification is advisory. Delivery is fail-open so an
1588
+ // unavailable classifier can never consume a parked leaf's Boss reply.
736
1589
  }
737
-
738
- if (decision.decision === 'sub') {
739
- if (!active) {
740
- await routerClarification(context);
741
- return;
742
- }
743
- await submitToActive(active, decision.text, context);
1590
+ if (decision?.decision !== 'dismiss') {
1591
+ lastRouteDecision = 'deliver';
1592
+ await submitToActive(leaf, turn.prompt, context);
744
1593
  return;
745
1594
  }
746
1595
 
747
- if (!active) {
748
- await routerClarification(context);
749
- return;
1596
+ lastRouteDecision = 'dismiss';
1597
+ if (leaf.parent) {
1598
+ await resumeParent(
1599
+ leaf,
1600
+ {
1601
+ status: 'aborted',
1602
+ playbookId: leaf.entry.id,
1603
+ childSessionId: leaf.sessionId,
1604
+ ...(leaf.state ? { state: leaf.state } : {}),
1605
+ },
1606
+ context,
1607
+ 'stopped',
1608
+ );
1609
+ } else {
1610
+ await disposeStack('dismiss');
750
1611
  }
751
-
752
- const dismissedCommandLabel = `/${active.enablement.command}`;
753
- await disposeActive('dismiss');
754
- await callVisibleChat(
755
- context,
756
- decision.text ?? `${dismissedCommandLabel} stopped.`,
757
- );
758
1612
  };
759
1613
 
760
1614
  const handleRegisteredCommand = async (
@@ -763,18 +1617,21 @@ export function createPlaybookCaptainShell(
763
1617
  context: CaptainContext,
764
1618
  ): Promise<void> => {
765
1619
  const enablement = enablementById.get(entry.id)!;
766
- if (active && active.entry.id !== entry.id) {
1620
+ const leaf = leafFrame();
1621
+ if (leaf && leaf.entry.id !== entry.id) {
767
1622
  await callVisibleChat(
1623
+ leaf,
768
1624
  context,
769
- `/${active.enablement.command} is already running. Finish or stop it before starting /${enablement.command}.`,
1625
+ `${frameLabel(leaf)} is already running. Finish or stop it before starting /${enablement.command}.`,
770
1626
  );
771
1627
  return;
772
1628
  }
773
1629
 
774
- const engagement = await engage(entry);
1630
+ const engagement = leaf ?? (await engage(entry));
775
1631
  if (text.length === 0) {
776
1632
  await requestVisibility(engagement.enablement);
777
1633
  await callVisibleChat(
1634
+ engagement,
778
1635
  context,
779
1636
  `Ask what task to run with /${enablement.command}.`,
780
1637
  );
@@ -796,6 +1653,7 @@ export function createPlaybookCaptainShell(
796
1653
  for (const enablement of enablementById.values()) {
797
1654
  enablement.entry.validateOptions(enablement.optionInput);
798
1655
  }
1656
+ internalCaptainEnablement = createInternalCaptainEnablement();
799
1657
  await setMode('chat', 'init');
800
1658
  },
801
1659
 
@@ -804,6 +1662,11 @@ export function createPlaybookCaptainShell(
804
1662
  context: CaptainContext,
805
1663
  ): Promise<void> {
806
1664
  requireSession();
1665
+ if (activeTurnHostCalls !== undefined) {
1666
+ throw new Error('cannot handle concurrent Boss turns');
1667
+ }
1668
+ const turnHostCalls = new Set<Promise<unknown>>();
1669
+ activeTurnHostCalls = turnHostCalls;
807
1670
  activeContext = context;
808
1671
  try {
809
1672
  const command = parseRegisteredCommand(turn.prompt);
@@ -815,15 +1678,31 @@ export function createPlaybookCaptainShell(
815
1678
  }
816
1679
  }
817
1680
 
818
- await routeHidden(turn, context);
1681
+ const leaf = leafFrame();
1682
+ if (leaf) {
1683
+ await routeEngaged(turn, context);
1684
+ return;
1685
+ }
1686
+ if (turn.prompt.trim().length === 0) return;
1687
+ const captain = await engageInternalCaptain();
1688
+ await submitToActive(captain, turn.prompt, context);
819
1689
  } finally {
1690
+ await drainHostCalls(turnHostCalls);
1691
+ if (activeTurnHostCalls === turnHostCalls) {
1692
+ activeTurnHostCalls = undefined;
1693
+ }
820
1694
  activeContext = undefined;
821
1695
  }
822
1696
  },
823
1697
 
1698
+ async prepareDispose(): Promise<void> {
1699
+ activeContext = undefined;
1700
+ await disposeStack('dispose');
1701
+ },
1702
+
824
1703
  async dispose(): Promise<void> {
825
1704
  activeContext = undefined;
826
- await disposeActive('dispose');
1705
+ await disposeStack('dispose');
827
1706
  },
828
1707
  };
829
1708
  }