@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,7 +1,19 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ import { randomUUID } from 'node:crypto';
4
+ import PQueue from 'p-queue';
5
+ import { registerPlaybookAbortCleanup } from '../../../src/xstate-runtime.js';
6
+ import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
7
+ class VisibilityControlError extends Error {
8
+ constructor(cause) {
9
+ super(`playbook visibility request failed: ${String(cause?.message ?? cause)}`, { cause });
10
+ this.name = 'VisibilityControlError';
11
+ }
12
+ }
3
13
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
4
14
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
15
+ const INTERNAL_CAPTAIN_ID = 'captain';
16
+ 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;
5
17
  function parseRegisteredCommand(prompt) {
6
18
  const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
7
19
  if (!match)
@@ -11,14 +23,14 @@ function parseRegisteredCommand(prompt) {
11
23
  function visibleChatEnvelope(message) {
12
24
  return [
13
25
  'You are the Playbook Captain shell.',
14
- 'This is visible Boss chat. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
26
+ 'This is visible Boss chat. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
15
27
  message,
16
28
  ].join('\n\n');
17
29
  }
18
30
  function visibleTurnSummaryEnvelope(input) {
19
31
  return [
20
32
  'You are the Playbook Captain shell.',
21
- 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
33
+ 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
22
34
  'Write a brief, clearly formatted turn-summary block for Boss.',
23
35
  'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
24
36
  'State only what was done or what changed; do not explain how it was done.',
@@ -38,9 +50,6 @@ function visibleTurnSummaryEnvelope(input) {
38
50
  ].join('\n\n');
39
51
  }
40
52
  function stateCountLabel(stateId, entry) {
41
- if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
42
- return undefined;
43
- }
44
53
  const registryLabel = entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
45
54
  return registryLabel || undefined;
46
55
  }
@@ -74,9 +83,6 @@ function isValidRegistryEntry(value) {
74
83
  typeof e.command === 'string' &&
75
84
  typeof e.intent === 'string' &&
76
85
  Array.isArray(e.requiredRoleIds) &&
77
- typeof e.idleStateId === 'string' &&
78
- typeof e.finalStateId === 'string' &&
79
- Array.isArray(e.parkStateIds) &&
80
86
  typeof e.validateOptions === 'function' &&
81
87
  typeof e.createRuntime === 'function');
82
88
  }
@@ -106,6 +112,9 @@ async function buildEnablements(options, players, loadModule) {
106
112
  throw new Error('captain.options.playbooks must enable at least one playbook');
107
113
  }
108
114
  for (const id of ids) {
115
+ if (id === INTERNAL_CAPTAIN_ID) {
116
+ throw new Error(`captain.options.playbooks.${id} collides with the reserved internal Captain id`);
117
+ }
109
118
  const block = config[id];
110
119
  if (typeof block !== 'object' || block === null || Array.isArray(block)) {
111
120
  throw new Error(`captain.options.playbooks.${id} must be an object`);
@@ -135,12 +144,19 @@ async function buildEnablements(options, players, loadModule) {
135
144
  const command = typeof record.command === 'string' && record.command.length > 0
136
145
  ? record.command
137
146
  : entry.command;
147
+ if (command === INTERNAL_CAPTAIN_ID) {
148
+ throw new Error(`captain.options.playbooks.${id} command collides with the reserved internal Captain command`);
149
+ }
138
150
  if (byCommand.has(command)) {
139
151
  throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
140
152
  }
141
153
  const boundPlayers = entry.requiredRoleIds.map((role) => {
142
154
  const host = players.find((p) => p.id === `${entry.id}-${role}`);
143
- return { id: role, adapter: host?.adapter, model: host?.model };
155
+ return {
156
+ id: role,
157
+ ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
158
+ ...(host?.model !== undefined ? { model: host.model } : {}),
159
+ };
144
160
  });
145
161
  entries.push(entry);
146
162
  byId.set(entry.id, entry);
@@ -158,52 +174,75 @@ async function buildEnablements(options, players, loadModule) {
158
174
  }
159
175
  export function createPlaybookCaptainShell(options, deps = {}) {
160
176
  const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
177
+ const createSessionId = deps.createSessionId ?? randomUUID;
178
+ const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
161
179
  let entries = [];
162
180
  let byCommand = new Map();
163
181
  let byId = new Map();
164
182
  let enablementById = new Map();
183
+ let internalCaptainEnablement;
165
184
  let session;
166
185
  let players = [];
167
186
  let activeContext;
168
- let active;
187
+ const frames = [];
169
188
  let mode = 'chat';
170
- let latestSubRuntimeStateId;
171
- let pendingBossQuestion;
189
+ let pendingBossQuestions;
172
190
  let lastError;
173
191
  let lastRouteDecision;
174
- let finalDisposalRequested;
175
192
  let activeTurnSummary;
193
+ let activeTurnHostCalls;
194
+ const issuedSessionIds = new Set();
195
+ const pendingChildParents = new Set();
196
+ const captainQueue = new PQueue({ concurrency: 1 });
197
+ let disposing = false;
198
+ const rootFrame = () => frames[0];
199
+ const leafFrame = () => frames.at(-1);
200
+ const frameLabel = (frame) => frame.internal ? 'Captain' : `/${frame.enablement.command}`;
176
201
  const requireSession = () => {
177
202
  if (!session) {
178
203
  throw new Error('init must be called first');
179
204
  }
180
205
  return session;
181
206
  };
182
- const ledgerSnapshot = (playbookId = active?.entry.id) => ({
207
+ const ledgerSnapshot = (playbookId = leafFrame()?.entry.id, activeSessionId = leafFrame()?.sessionId) => ({
183
208
  ...(playbookId ? { activePlaybookId: playbookId } : {}),
209
+ ...(activeSessionId ? { activeSessionId } : {}),
210
+ ...(rootFrame()
211
+ ? {
212
+ rootPlaybookId: rootFrame().entry.id,
213
+ rootSessionId: rootFrame().sessionId,
214
+ }
215
+ : {}),
216
+ stackDepth: frames.length,
217
+ stackPath: frames.map((frame) => frame.entry.id),
184
218
  mode,
185
- ...(latestSubRuntimeStateId ? { latestSubRuntimeStateId } : {}),
186
- ...(pendingBossQuestion !== undefined ? { pendingBossQuestion } : {}),
219
+ ...(leafFrame()?.state?.stateId
220
+ ? { latestSubRuntimeStateId: leafFrame().state.stateId }
221
+ : {}),
222
+ ...(leafFrame()?.state
223
+ ? { latestSubRuntimeState: leafFrame().state }
224
+ : {}),
225
+ ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
187
226
  ...(lastError ? { lastError } : {}),
188
227
  ...(lastRouteDecision ? { lastRouteDecision } : {}),
189
228
  });
190
- const emitShellTelemetry = async (from, to, event, playbookId = active?.entry.id) => {
229
+ const emitShellTelemetry = async (from, to, event, playbookId = leafFrame()?.entry.id, activeSessionId = leafFrame()?.sessionId) => {
191
230
  await requireSession().emitTelemetry({
192
231
  topic: SHELL_FSM_TOPIC,
193
232
  payload: {
194
233
  from,
195
234
  to,
196
235
  event,
197
- ledger: ledgerSnapshot(playbookId),
236
+ ledger: ledgerSnapshot(playbookId, activeSessionId),
198
237
  },
199
238
  });
200
239
  };
201
- const setMode = async (nextMode, event, playbookId = active?.entry.id) => {
240
+ const setMode = async (nextMode, event, playbookId = leafFrame()?.entry.id, activeSessionId = leafFrame()?.sessionId) => {
202
241
  if (mode === nextMode)
203
242
  return;
204
243
  const from = mode;
205
244
  mode = nextMode;
206
- await emitShellTelemetry(from, nextMode, event, playbookId);
245
+ await emitShellTelemetry(from, nextMode, event, playbookId, activeSessionId);
207
246
  };
208
247
  const normalizeErrorCompact = (value) => {
209
248
  if (value === undefined || value === null)
@@ -225,62 +264,141 @@ export function createPlaybookCaptainShell(options, deps = {}) {
225
264
  const payloadRecord = (payload) => typeof payload === 'object' && payload !== null && !Array.isArray(payload)
226
265
  ? payload
227
266
  : undefined;
228
- const mirroredStateId = (payload) => {
229
- const record = payloadRecord(payload);
230
- if (!record)
267
+ const playbookState = (value) => {
268
+ const record = payloadRecord(value);
269
+ if (!record ||
270
+ !Array.isArray(record.activeStateIds) ||
271
+ !record.activeStateIds.every((id) => typeof id === 'string') ||
272
+ !Array.isArray(record.tags) ||
273
+ !record.tags.every((tag) => typeof tag === 'string') ||
274
+ typeof record.status !== 'string' ||
275
+ typeof record.quiescent !== 'boolean' ||
276
+ !('value' in record)) {
231
277
  return undefined;
232
- if (typeof record.to === 'string')
233
- return record.to;
234
- return typeof record.state === 'string' ? record.state : undefined;
278
+ }
279
+ return record;
235
280
  };
236
- const mirrorSubRuntimeTelemetry = async (payload) => {
237
- if (!active)
238
- return;
281
+ const stateValueContains = (value, stateId) => {
282
+ if (typeof value === 'string')
283
+ return value === stateId;
284
+ const record = payloadRecord(value);
285
+ if (!record)
286
+ return false;
287
+ return Object.entries(record).some(([key, nested]) => key === stateId || stateValueContains(nested, stateId));
288
+ };
289
+ const drainHostCalls = async (calls) => {
290
+ while (calls.size > 0) {
291
+ await Promise.allSettled([...calls]);
292
+ }
293
+ };
294
+ const trackHostCall = (frame, call) => {
295
+ // Cligent's host methods are scoped to the whole Boss turn, while an
296
+ // XState invocation can carry a narrower sibling-cancellation signal.
297
+ // Keep both frame and turn ownership after XState stops awaiting the
298
+ // promise so the host cannot outlive frame disposal or turn settlement.
299
+ const turnCalls = activeTurnHostCalls;
300
+ let tracked;
301
+ tracked = call.finally(() => {
302
+ frame.inFlightHostCalls.delete(tracked);
303
+ turnCalls?.delete(tracked);
304
+ });
305
+ frame.inFlightHostCalls.add(tracked);
306
+ turnCalls?.add(tracked);
307
+ return tracked;
308
+ };
309
+ const callCaptainQueued = (frame, context, prompt, options, signal) => {
310
+ const queued = captainQueue.add(async () => {
311
+ signal.throwIfAborted();
312
+ const result = await trackHostCall(frame, context.callCaptain(prompt, options));
313
+ signal.throwIfAborted();
314
+ return result;
315
+ });
316
+ return trackHostCall(frame, queued);
317
+ };
318
+ const mirrorSubRuntimeTelemetry = async (frame, payload) => {
239
319
  const record = payloadRecord(payload);
240
- const stateId = mirroredStateId(payload);
241
- if (stateId === undefined)
242
- return;
243
- const countLabel = stateCountLabel(stateId, active.entry);
244
- if (activeTurnSummary && countLabel) {
245
- activeTurnSummary.stateCounts.set(countLabel, (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1);
246
- }
247
- latestSubRuntimeStateId = stateId;
248
- pendingBossQuestion = record?.pendingBossQuestion;
249
- lastError = normalizeErrorCompact(record?.lastError);
250
- if (stateId === active.entry.finalStateId) {
251
- finalDisposalRequested = active;
320
+ const state = playbookState(record?.state);
321
+ if (!record || !state)
252
322
  return;
323
+ const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
324
+ frame.state = state;
325
+ if (activeTurnSummary?.owner === frame) {
326
+ for (const stateId of state.activeStateIds) {
327
+ const newlyActive = !previousActiveIds.has(stateId);
328
+ const structuredEntry = stateValueContains(record.to, stateId) &&
329
+ !stateValueContains(record.from, stateId);
330
+ if (!newlyActive && !structuredEntry)
331
+ continue;
332
+ const countLabel = stateCountLabel(stateId, frame.entry);
333
+ if (countLabel) {
334
+ activeTurnSummary.stateCounts.set(countLabel, (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1);
335
+ }
336
+ }
253
337
  }
254
- if (stateId === active.entry.idleStateId ||
255
- active.entry.parkStateIds.includes(stateId)) {
256
- await setMode('engaged.parked', `sub-runtime:${stateId}`);
338
+ if (leafFrame() === frame) {
339
+ pendingBossQuestions =
340
+ record.pendingBossQuestions ?? record.pendingBossQuestion;
341
+ lastError = normalizeErrorCompact(record.lastError);
342
+ if (state.quiescent && state.tags.includes('playbook.parked')) {
343
+ await setMode('engaged.parked', `sub-runtime:${state.stateId ?? 'structured'}`);
344
+ }
257
345
  }
258
346
  };
259
- const createPorts = () => ({
260
- callPlayer: async (playerId, prompt, _signal) => {
347
+ let callNestedPlaybook;
348
+ const createPorts = (frame) => ({
349
+ callPlayer: async (playerId, prompt, signal, options) => {
261
350
  if (!activeContext) {
262
351
  throw new Error('callPlayer invoked outside a Boss turn');
263
352
  }
264
- const hostPlayerId = active
265
- ? active.enablement.hostPlayerId(playerId)
266
- : playerId;
267
- const result = await activeContext.callPlayer(hostPlayerId, prompt);
268
- if (activeTurnSummary) {
353
+ const context = activeContext;
354
+ signal.throwIfAborted();
355
+ const hostPlayerId = frame.enablement.hostPlayerId(playerId);
356
+ const result = await trackHostCall(frame, context.callPlayer(hostPlayerId, prompt, {
357
+ resume: options.resume,
358
+ }));
359
+ // CaptainContext is turn-scoped and cannot accept a narrower XState
360
+ // invocation signal. Recheck after the host call so a sibling
361
+ // cancellation is still reported as aborted and cannot rotate a
362
+ // stopped branch's player token in the linked runtime.
363
+ signal.throwIfAborted();
364
+ if (activeTurnSummary?.owner === frame) {
269
365
  activeTurnSummary.counts.interruptions++;
270
366
  }
271
367
  return {
272
368
  status: result.status,
273
- finalText: result.finalText,
274
- error: result.error,
369
+ ...(result.resumeToken !== undefined
370
+ ? { resumeToken: result.resumeToken }
371
+ : {}),
372
+ ...(result.finalText !== undefined
373
+ ? { finalText: result.finalText }
374
+ : {}),
375
+ ...(result.error !== undefined ? { error: result.error } : {}),
275
376
  };
276
377
  },
277
- callJudge: async (prompt, _signal) => {
378
+ callCaptain: async (prompt, signal, options) => {
379
+ if (!activeContext) {
380
+ throw new Error('callCaptain invoked outside a Boss turn');
381
+ }
382
+ const result = await callCaptainQueued(frame, activeContext, prompt, {
383
+ visibility: options.visibility,
384
+ resume: options.resume,
385
+ ...(options.allowedTools === undefined
386
+ ? {}
387
+ : { allowedTools: options.allowedTools }),
388
+ }, signal);
389
+ return {
390
+ status: result.status,
391
+ ...(result.finalText !== undefined
392
+ ? { finalText: result.finalText }
393
+ : {}),
394
+ ...(result.error !== undefined ? { error: result.error } : {}),
395
+ };
396
+ },
397
+ callJudge: async (prompt, signal) => {
278
398
  if (!activeContext) {
279
399
  throw new Error('callJudge invoked outside a Boss turn');
280
400
  }
281
- const result = await activeContext.callCaptain(prompt, {
282
- visibility: 'hidden',
283
- });
401
+ const result = await callCaptainQueued(frame, activeContext, prompt, { visibility: 'hidden', resume: false, allowedTools: [] }, signal);
284
402
  if (result.status !== 'ok') {
285
403
  throw new Error(result.error ?? `callCaptain status "${result.status}"`);
286
404
  }
@@ -289,18 +407,34 @@ export function createPlaybookCaptainShell(options, deps = {}) {
289
407
  }
290
408
  const guard = guardFromJudgeReply(result.finalText);
291
409
  if (guard &&
292
- active?.entry.summaryPolicy?.copyPasteGuardNames.includes(guard) &&
293
- activeTurnSummary) {
410
+ activeTurnSummary?.owner === frame &&
411
+ frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)) {
294
412
  activeTurnSummary.counts.copyPastes++;
295
413
  }
296
414
  return result.finalText;
297
415
  },
416
+ callPlaybook: (request, signal) => {
417
+ const opening = callNestedPlaybook(frame, request, signal);
418
+ let exposed;
419
+ const registerOpeningCleanup = () => {
420
+ registerPlaybookAbortCleanup(signal, exposed);
421
+ };
422
+ exposed = opening.finally(() => {
423
+ signal.removeEventListener('abort', registerOpeningCleanup);
424
+ });
425
+ signal.addEventListener('abort', registerOpeningCleanup, { once: true });
426
+ if (signal.aborted)
427
+ registerOpeningCleanup();
428
+ return exposed;
429
+ },
298
430
  emitStatus: async (message, data) => {
431
+ if (frame.internal)
432
+ return;
299
433
  await requireSession().emitStatus(message, data);
300
434
  },
301
435
  emitTelemetry: async (event) => {
302
436
  if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
303
- await mirrorSubRuntimeTelemetry(event.payload);
437
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
304
438
  }
305
439
  await requireSession().emitTelemetry(event);
306
440
  },
@@ -313,60 +447,651 @@ export function createPlaybookCaptainShell(options, deps = {}) {
313
447
  const ids = enablement.visiblePlayerIds;
314
448
  if (!ids || ids.length === 0 || !activeContext)
315
449
  return;
316
- await activeContext.setVisiblePlayers(ids);
450
+ try {
451
+ await activeContext.setVisiblePlayers(ids);
452
+ }
453
+ catch (error) {
454
+ throw new VisibilityControlError(error);
455
+ }
317
456
  };
318
- const engage = async (entry) => {
319
- if (active?.entry.id === entry.id)
320
- return active;
321
- const enablement = enablementById.get(entry.id);
457
+ const allocateSessionId = () => {
458
+ const sessionId = createSessionId();
459
+ if (!UUID_PATTERN.test(sessionId)) {
460
+ throw new Error(`playbook session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`);
461
+ }
462
+ if (issuedSessionIds.has(sessionId)) {
463
+ throw new Error(`playbook session id collision: ${sessionId}`);
464
+ }
465
+ issuedSessionIds.add(sessionId);
466
+ return sessionId;
467
+ };
468
+ const normalizeErrorFull = (value) => {
469
+ const compact = normalizeErrorCompact(value) ?? {
470
+ name: 'Error',
471
+ message: String(value),
472
+ };
473
+ const stack = value instanceof Error
474
+ ? value.stack
475
+ : typeof value === 'object' && value !== null
476
+ ? value.stack
477
+ : undefined;
478
+ return typeof stack === 'string' ? { ...compact, stack } : compact;
479
+ };
480
+ const makeFrame = (enablement, parent, internal = false) => {
481
+ const entry = enablement.entry;
482
+ const sessionId = allocateSessionId();
322
483
  const runtime = entry.createRuntime({
323
484
  captainOptions: enablement.optionInput,
324
485
  players: enablement.boundPlayers,
325
486
  });
326
- active = { entry, enablement, runtime };
327
- latestSubRuntimeStateId = undefined;
328
- pendingBossQuestion = undefined;
487
+ return {
488
+ entry,
489
+ enablement,
490
+ runtime,
491
+ sessionId,
492
+ rootSessionId: parent?.frame.rootSessionId ?? sessionId,
493
+ depth: parent ? parent.frame.depth + 1 : 0,
494
+ ...(parent ? { parent } : {}),
495
+ inFlightHostCalls: new Set(),
496
+ internal,
497
+ };
498
+ };
499
+ const initFrame = async (frame) => {
500
+ await frame.runtime.init({
501
+ sessionId: frame.sessionId,
502
+ playbookId: frame.entry.id,
503
+ rootSessionId: frame.rootSessionId,
504
+ ...(frame.parent
505
+ ? {
506
+ parentSessionId: frame.parent.frame.sessionId,
507
+ parentCallId: frame.parent.callId,
508
+ }
509
+ : {}),
510
+ depth: frame.depth,
511
+ ports: createPorts(frame),
512
+ });
513
+ };
514
+ const clearLeafLedger = () => {
515
+ pendingBossQuestions = undefined;
329
516
  lastError = undefined;
330
- finalDisposalRequested = undefined;
331
- await setMode('engaged.parked', 'engage', entry.id);
332
- await runtime.init(createPorts());
333
- await requireSession().emitStatus(`◇ /${enablement.command} started`);
334
- return active;
335
- };
336
- const submitToActive = async (engagement, text, context) => {
337
- await requestVisibility(engagement.enablement);
338
- const policy = engagement.entry.summaryPolicy;
517
+ };
518
+ const engageEnablement = async (enablement, internal) => {
519
+ const entry = enablement.entry;
520
+ const existing = rootFrame();
521
+ if (existing?.entry.id === entry.id && frames.length === 1) {
522
+ return existing;
523
+ }
524
+ if (existing) {
525
+ throw new Error('cannot engage a second root playbook');
526
+ }
527
+ const frame = makeFrame(enablement, undefined, internal);
528
+ frames.push(frame);
529
+ clearLeafLedger();
530
+ try {
531
+ await setMode('engaged.parked', 'engage', entry.id, frame.sessionId);
532
+ await initFrame(frame);
533
+ if (!internal) {
534
+ await requireSession().emitStatus(`◇ ${frameLabel(frame)} started`);
535
+ }
536
+ return frame;
537
+ }
538
+ catch (error) {
539
+ if (leafFrame() === frame)
540
+ frames.pop();
541
+ clearLeafLedger();
542
+ try {
543
+ await frame.runtime.dispose();
544
+ }
545
+ catch {
546
+ // Preserve the initialization failure while still making a
547
+ // best-effort attempt to release partially acquired resources.
548
+ }
549
+ try {
550
+ await setMode('chat', 'engage.failed');
551
+ }
552
+ catch {
553
+ // setMode updates the authoritative mode before telemetry; preserve
554
+ // the initialization failure if that recovery emission also fails.
555
+ mode = 'chat';
556
+ }
557
+ throw error;
558
+ }
559
+ };
560
+ const engage = async (entry) => engageEnablement(enablementById.get(entry.id), false);
561
+ const createInternalCaptainEnablement = () => {
562
+ const catalog = Object.freeze(entries.map((entry) => Object.freeze({
563
+ id: entry.id,
564
+ command: enablementById.get(entry.id).command,
565
+ intent: entry.intent,
566
+ })));
567
+ const entry = {
568
+ id: INTERNAL_CAPTAIN_ID,
569
+ command: INTERNAL_CAPTAIN_ID,
570
+ intent: 'internal orchestration policy',
571
+ requiredRoleIds: [],
572
+ validateOptions: () => undefined,
573
+ createRuntime: () => createCaptainRuntime({ enabledPlaybooks: catalog }),
574
+ };
575
+ return {
576
+ entry,
577
+ command: INTERNAL_CAPTAIN_ID,
578
+ optionInput: undefined,
579
+ boundPlayers: [],
580
+ hostPlayerId(localRole) {
581
+ throw new Error(`internal Captain has no player binding for ${JSON.stringify(localRole)}`);
582
+ },
583
+ };
584
+ };
585
+ const engageInternalCaptain = async () => {
586
+ if (!internalCaptainEnablement) {
587
+ throw new Error('internal Captain enablement is unavailable before init');
588
+ }
589
+ return engageEnablement(internalCaptainEnablement, true);
590
+ };
591
+ const disposeFrame = (frame) => {
592
+ if (frame.disposePromise)
593
+ return frame.disposePromise;
594
+ const operation = (async () => {
595
+ if (frame.invocationSignal && frame.abortListener) {
596
+ frame.invocationSignal.removeEventListener('abort', frame.abortListener);
597
+ }
598
+ frame.invocationSignal = undefined;
599
+ frame.abortListener = undefined;
600
+ let disposeError;
601
+ try {
602
+ await frame.runtime.dispose();
603
+ }
604
+ catch (error) {
605
+ disposeError = error;
606
+ }
607
+ await drainHostCalls(frame.inFlightHostCalls);
608
+ if (disposeError !== undefined)
609
+ throw disposeError;
610
+ })();
611
+ frame.disposePromise = operation;
612
+ return operation;
613
+ };
614
+ const removeTopFrame = (frame, reason) => {
615
+ if (frame.removal) {
616
+ return {
617
+ claimed: false,
618
+ reason: frame.removal.reason,
619
+ promise: frame.removal.promise,
620
+ };
621
+ }
622
+ const operation = (async () => {
623
+ if (leafFrame() !== frame) {
624
+ throw new Error('nested playbook stack is not LIFO');
625
+ }
626
+ let removalError;
627
+ try {
628
+ await disposeFrame(frame);
629
+ }
630
+ catch (error) {
631
+ removalError = error;
632
+ }
633
+ finally {
634
+ if (leafFrame() === frame) {
635
+ frames.pop();
636
+ if (frame.parent) {
637
+ pendingChildParents.delete(frame.parent.frame);
638
+ }
639
+ pendingChildParents.delete(frame);
640
+ }
641
+ else if (frames.includes(frame)) {
642
+ const stackError = new Error('nested playbook stack changed during frame removal');
643
+ removalError =
644
+ removalError === undefined
645
+ ? stackError
646
+ : new AggregateError([removalError, stackError], 'nested playbook frame removal failed');
647
+ }
648
+ }
649
+ if (removalError !== undefined)
650
+ throw removalError;
651
+ })();
652
+ frame.removal = { reason, promise: operation };
653
+ return { claimed: true, reason, promise: operation };
654
+ };
655
+ const unwindFramesFrom = async (frame, reason = 'stack') => {
656
+ const index = frames.indexOf(frame);
657
+ if (index < 0)
658
+ return;
659
+ const failures = [];
660
+ while (frames.length > index) {
661
+ const current = leafFrame();
662
+ const removal = removeTopFrame(current, reason);
663
+ try {
664
+ await removal.promise;
665
+ }
666
+ catch (error) {
667
+ failures.push(error);
668
+ }
669
+ if (frames.includes(current)) {
670
+ failures.push(new Error('nested playbook frame remained after removal attempt'));
671
+ break;
672
+ }
673
+ }
674
+ clearLeafLedger();
675
+ if (failures.length === 1)
676
+ throw failures[0];
677
+ if (failures.length > 1) {
678
+ throw new AggregateError(failures, 'nested playbook stack disposal failed');
679
+ }
680
+ };
681
+ const popChild = async (frame, status) => {
682
+ if (!frame.parent || (leafFrame() !== frame && !frame.removal)) {
683
+ throw new Error('nested playbook stack is not LIFO');
684
+ }
685
+ const parent = frame.parent.frame;
686
+ const removal = removeTopFrame(frame, 'return');
687
+ if (!removal.claimed) {
688
+ await removal.promise;
689
+ return false;
690
+ }
691
+ let cleanupError;
692
+ try {
693
+ await removal.promise;
694
+ }
695
+ catch (error) {
696
+ cleanupError = error;
697
+ }
698
+ const message = status === 'returned'
699
+ ? `◇ ${frameLabel(frame)} returned to ${frameLabel(parent)}`
700
+ : `◇ ${frameLabel(frame)} stopped; returning to ${frameLabel(parent)}`;
701
+ try {
702
+ await requireSession().emitStatus(message);
703
+ }
704
+ catch (error) {
705
+ cleanupError =
706
+ cleanupError === undefined
707
+ ? error
708
+ : new AggregateError([cleanupError, error], 'nested playbook return cleanup failed');
709
+ }
710
+ let visibilityError;
711
+ try {
712
+ await requestVisibility(parent.enablement);
713
+ }
714
+ catch (error) {
715
+ visibilityError = error;
716
+ }
717
+ if (visibilityError !== undefined) {
718
+ if (cleanupError !== undefined) {
719
+ throw new VisibilityControlError(new AggregateError([cleanupError, visibilityError], 'nested playbook return and visibility failed'));
720
+ }
721
+ throw visibilityError;
722
+ }
723
+ if (cleanupError !== undefined)
724
+ throw cleanupError;
725
+ return true;
726
+ };
727
+ const disposeStack = async (reason) => {
728
+ const root = rootFrame();
729
+ if (!root)
730
+ return;
731
+ const rootId = root.entry.id;
732
+ const rootSessionId = root.sessionId;
733
+ const failures = [];
734
+ disposing = true;
735
+ try {
736
+ if (reason !== 'dispose') {
737
+ try {
738
+ await setMode('chat', reason, rootId, rootSessionId);
739
+ }
740
+ catch (error) {
741
+ failures.push(error);
742
+ mode = 'chat';
743
+ }
744
+ }
745
+ else {
746
+ mode = 'chat';
747
+ }
748
+ try {
749
+ await unwindFramesFrom(root);
750
+ }
751
+ catch (error) {
752
+ failures.push(error);
753
+ }
754
+ }
755
+ finally {
756
+ disposing = false;
757
+ pendingChildParents.clear();
758
+ clearLeafLedger();
759
+ }
760
+ if (!root.internal) {
761
+ try {
762
+ if (reason === 'dismiss') {
763
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} stopped`);
764
+ }
765
+ else if (reason === 'final') {
766
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} finished`);
767
+ }
768
+ }
769
+ catch (error) {
770
+ failures.push(error);
771
+ }
772
+ }
773
+ if (failures.length === 1)
774
+ throw failures[0];
775
+ if (failures.length > 1) {
776
+ throw new AggregateError(failures, 'playbook stack disposal failed');
777
+ }
778
+ };
779
+ const callResultFor = (frame, result) => {
780
+ if (result.outcome === 'terminal') {
781
+ return {
782
+ status: 'ok',
783
+ playbookId: frame.entry.id,
784
+ childSessionId: frame.sessionId,
785
+ state: result.state,
786
+ ...(result.output !== undefined ? { output: result.output } : {}),
787
+ };
788
+ }
789
+ if (result.outcome === 'aborted') {
790
+ return {
791
+ status: 'aborted',
792
+ playbookId: frame.entry.id,
793
+ childSessionId: frame.sessionId,
794
+ state: result.state,
795
+ ...(result.error ? { error: result.error } : {}),
796
+ };
797
+ }
798
+ throw new Error(`playbook ${frame.entry.id} has not returned`);
799
+ };
800
+ const assertRetainableResult = (frame, result) => {
801
+ if (result.outcome === 'suspended')
802
+ return;
803
+ if (result.state.quiescent &&
804
+ result.state.tags.includes('playbook.parked')) {
805
+ return;
806
+ }
807
+ throw new Error(`playbook ${frame.entry.id} returned outcome "${result.outcome}" ` +
808
+ 'without a quiescent playbook.parked state');
809
+ };
810
+ const driveFrame = async (frame, text, context, signal = context.signal) => {
811
+ if (leafFrame() !== frame) {
812
+ throw new Error('only the active leaf may receive Boss input');
813
+ }
814
+ await requestVisibility(frame.enablement);
815
+ await setMode('engaged.driving', 'submit');
816
+ const result = await frame.runtime.handleBossInput({
817
+ text,
818
+ signal,
819
+ });
820
+ frame.state = result.state;
821
+ return result;
822
+ };
823
+ async function resumeParent(child, callResult, context, status = 'returned') {
824
+ const parentLink = child.parent;
825
+ if (!parentLink)
826
+ throw new Error('root playbook has no caller');
827
+ const parent = parentLink.frame;
828
+ const invocationSignal = child.invocationSignal;
829
+ let effectiveResult = callResult;
830
+ let ownsReturn = false;
831
+ let visibilityControlError;
832
+ try {
833
+ ownsReturn = await popChild(child, status);
834
+ }
835
+ catch (error) {
836
+ ownsReturn = child.removal?.reason === 'return';
837
+ if (error instanceof VisibilityControlError) {
838
+ visibilityControlError = error;
839
+ }
840
+ else {
841
+ effectiveResult = {
842
+ status: context.signal.aborted ? 'aborted' : 'error',
843
+ playbookId: child.entry.id,
844
+ childSessionId: child.sessionId,
845
+ ...(child.state ? { state: child.state } : {}),
846
+ error: normalizeErrorFull(error),
847
+ };
848
+ }
849
+ }
850
+ if (!ownsReturn ||
851
+ disposing ||
852
+ invocationSignal?.aborted ||
853
+ !frames.includes(parent)) {
854
+ return;
855
+ }
856
+ let result;
857
+ try {
858
+ result = await parent.runtime.resumePlaybookCall({
859
+ callId: parentLink.callId,
860
+ result: effectiveResult,
861
+ signal: context.signal,
862
+ });
863
+ }
864
+ catch (error) {
865
+ if (disposing || invocationSignal?.aborted)
866
+ return;
867
+ await returnBoundaryFailure(parent, error, context);
868
+ return;
869
+ }
870
+ parent.state = result.state;
871
+ await processFrameResult(parent, result, context);
872
+ if (visibilityControlError !== undefined)
873
+ throw visibilityControlError;
874
+ }
875
+ async function returnBoundaryFailure(frame, error, context) {
876
+ if (!frame.parent)
877
+ throw error;
878
+ await resumeParent(frame, {
879
+ status: context.signal.aborted ? 'aborted' : 'error',
880
+ playbookId: frame.entry.id,
881
+ childSessionId: frame.sessionId,
882
+ ...(frame.state ? { state: frame.state } : {}),
883
+ error: normalizeErrorFull(error),
884
+ }, context);
885
+ }
886
+ async function processFrameResult(frame, result, context) {
887
+ if (result.outcome === 'terminal') {
888
+ if (frame.parent) {
889
+ await resumeParent(frame, callResultFor(frame, result), context);
890
+ }
891
+ else {
892
+ await disposeStack('final');
893
+ }
894
+ return;
895
+ }
896
+ if (result.outcome === 'aborted' && frame.parent) {
897
+ await resumeParent(frame, callResultFor(frame, result), context);
898
+ return;
899
+ }
900
+ assertRetainableResult(frame, result);
901
+ if (leafFrame()) {
902
+ await setMode('engaged.parked', `turn:${result.outcome}`);
903
+ }
904
+ }
905
+ const disposeAbandonedChild = async (child) => {
906
+ if (disposing || !frames.includes(child) || !child.parent)
907
+ return;
908
+ if (child.removal) {
909
+ await child.removal.promise;
910
+ return;
911
+ }
912
+ const parent = child.parent.frame;
913
+ let cleanupError;
914
+ try {
915
+ await unwindFramesFrom(child, 'abandoned');
916
+ }
917
+ catch (error) {
918
+ cleanupError = error;
919
+ }
920
+ if (frames.includes(parent)) {
921
+ await requestVisibility(parent.enablement);
922
+ }
923
+ if (cleanupError !== undefined)
924
+ throw cleanupError;
925
+ };
926
+ callNestedPlaybook = async (parent, request, invocationSignal) => {
927
+ if (!activeContext) {
928
+ throw new Error('callPlaybook invoked outside a Boss turn');
929
+ }
930
+ invocationSignal.throwIfAborted();
931
+ if (leafFrame() !== parent) {
932
+ throw new Error('only the active leaf may call a child playbook');
933
+ }
934
+ if (pendingChildParents.has(parent)) {
935
+ throw new Error('playbook frame already has an outstanding child');
936
+ }
937
+ if (typeof request.callId !== 'string' || request.callId.trim() === '') {
938
+ throw new Error('nested playbook call id must be a non-empty string');
939
+ }
940
+ if (typeof request.playbookId !== 'string' ||
941
+ request.playbookId.trim() === '') {
942
+ throw new Error('nested playbook id must be a non-empty string');
943
+ }
944
+ if (typeof request.text !== 'string') {
945
+ throw new Error('nested playbook input text must be a string');
946
+ }
947
+ if (request.playbookId === INTERNAL_CAPTAIN_ID) {
948
+ throw new Error('the internal Captain playbook cannot call itself');
949
+ }
950
+ const entry = byId.get(request.playbookId);
951
+ if (!entry) {
952
+ throw new Error(`playbook "${request.playbookId}" is not enabled`);
953
+ }
954
+ if (frames.some((frame) => frame.entry.id === request.playbookId)) {
955
+ throw new Error(`nested playbook cycle: ${[
956
+ ...frames.map((frame) => frame.entry.id),
957
+ request.playbookId,
958
+ ].join(' -> ')}`);
959
+ }
960
+ pendingChildParents.add(parent);
961
+ let child;
962
+ try {
963
+ child = makeFrame(enablementById.get(entry.id), {
964
+ frame: parent,
965
+ callId: request.callId,
966
+ });
967
+ }
968
+ catch (error) {
969
+ pendingChildParents.delete(parent);
970
+ throw error;
971
+ }
972
+ frames.push(child);
973
+ clearLeafLedger();
974
+ let calledStatusEmitted = false;
975
+ let returnStatusHandled = false;
976
+ try {
977
+ await initFrame(child);
978
+ invocationSignal.throwIfAborted();
979
+ await requireSession().emitStatus(`◇ ${frameLabel(child)} called by ${frameLabel(parent)}`);
980
+ calledStatusEmitted = true;
981
+ const result = await driveFrame(child, request.text, activeContext, AbortSignal.any([invocationSignal, activeContext.signal]));
982
+ if (result.outcome === 'terminal' || result.outcome === 'aborted') {
983
+ const callResult = callResultFor(child, result);
984
+ returnStatusHandled = true;
985
+ const returned = await popChild(child, result.outcome === 'aborted' ? 'stopped' : 'returned');
986
+ if (!returned) {
987
+ throw new Error('nested playbook return lost its active frame');
988
+ }
989
+ return { state: 'settled', result: callResult };
990
+ }
991
+ assertRetainableResult(child, result);
992
+ if (invocationSignal.aborted) {
993
+ const callResult = {
994
+ status: 'aborted',
995
+ playbookId: child.entry.id,
996
+ childSessionId: child.sessionId,
997
+ state: result.state,
998
+ };
999
+ returnStatusHandled = true;
1000
+ const returned = await popChild(child, 'stopped');
1001
+ if (!returned) {
1002
+ throw new Error('nested playbook abort lost its active frame');
1003
+ }
1004
+ return { state: 'settled', result: callResult };
1005
+ }
1006
+ const abortListener = () => {
1007
+ registerPlaybookAbortCleanup(invocationSignal, disposeAbandonedChild(child));
1008
+ };
1009
+ child.invocationSignal = invocationSignal;
1010
+ child.abortListener = abortListener;
1011
+ invocationSignal.addEventListener('abort', abortListener, { once: true });
1012
+ return { state: 'suspended', childSessionId: child.sessionId };
1013
+ }
1014
+ catch (error) {
1015
+ let boundaryError = error;
1016
+ let visibilityControlFailure = error instanceof VisibilityControlError;
1017
+ if (frames.includes(child)) {
1018
+ try {
1019
+ await unwindFramesFrom(child, 'stack');
1020
+ }
1021
+ catch (cleanupError) {
1022
+ boundaryError = new AggregateError([error, cleanupError], 'nested playbook call and cleanup failed');
1023
+ }
1024
+ }
1025
+ pendingChildParents.delete(parent);
1026
+ if (calledStatusEmitted && !returnStatusHandled) {
1027
+ try {
1028
+ await requireSession().emitStatus(`◇ ${frameLabel(child)} stopped; returning to ${frameLabel(parent)}`);
1029
+ }
1030
+ catch (statusError) {
1031
+ boundaryError = new AggregateError([boundaryError, statusError], 'nested playbook failure status emission failed');
1032
+ }
1033
+ }
1034
+ if (frames.includes(parent)) {
1035
+ try {
1036
+ await requestVisibility(parent.enablement);
1037
+ }
1038
+ catch (visibilityError) {
1039
+ visibilityControlFailure = true;
1040
+ boundaryError = new AggregateError([boundaryError, visibilityError], 'nested playbook call return failed');
1041
+ }
1042
+ }
1043
+ if (visibilityControlFailure)
1044
+ throw boundaryError;
1045
+ return {
1046
+ state: 'settled',
1047
+ result: {
1048
+ status: invocationSignal.aborted ? 'aborted' : 'error',
1049
+ playbookId: request.playbookId,
1050
+ childSessionId: child.sessionId,
1051
+ error: normalizeErrorFull(boundaryError),
1052
+ },
1053
+ };
1054
+ }
1055
+ };
1056
+ const submitToActive = async (frame, text, context) => {
1057
+ const policy = frame.entry.summaryPolicy;
339
1058
  const summaryCounts = {
340
1059
  interruptions: 0,
341
1060
  copyPastes: 0,
342
1061
  };
343
1062
  const summaryStateCounts = new Map();
344
- let shouldSummarize = false;
345
1063
  activeTurnSummary = policy
346
- ? { counts: summaryCounts, stateCounts: summaryStateCounts }
1064
+ ? {
1065
+ owner: frame,
1066
+ counts: summaryCounts,
1067
+ stateCounts: summaryStateCounts,
1068
+ }
347
1069
  : undefined;
348
- await setMode('engaged.driving', 'submit');
1070
+ let completed = false;
349
1071
  try {
350
- await engagement.runtime.handleBossInput({
351
- text,
352
- signal: context.signal,
353
- });
354
- shouldSummarize = policy !== undefined;
1072
+ const result = await driveFrame(frame, text, context);
1073
+ await processFrameResult(frame, result, context);
1074
+ completed = true;
1075
+ }
1076
+ catch (error) {
1077
+ if (frame.parent && frames.includes(frame)) {
1078
+ await returnBoundaryFailure(frame, error, context);
1079
+ completed = true;
1080
+ }
1081
+ else {
1082
+ throw error;
1083
+ }
355
1084
  }
356
1085
  finally {
357
1086
  activeTurnSummary = undefined;
358
- if (active === engagement && finalDisposalRequested === engagement) {
359
- finalDisposalRequested = undefined;
360
- await disposeActive('final');
361
- }
362
- else if (active === engagement && mode === 'engaged.driving') {
1087
+ if (leafFrame() && mode === 'engaged.driving') {
363
1088
  await setMode('engaged.parked', 'turn.settled');
364
1089
  }
365
1090
  }
366
- if (shouldSummarize && policy) {
1091
+ if (completed && policy) {
367
1092
  const progressRounds = summaryProgressRoundCount(summaryStateCounts);
368
- await callVisibleTurnSummary(context, {
369
- playbookId: engagement.entry.id,
1093
+ await callVisibleTurnSummary(frame, context, {
1094
+ playbookId: frame.entry.id,
370
1095
  submittedText: text,
371
1096
  counts: summaryCounts,
372
1097
  progressPhrase: summaryProgressPhrase(summaryStateCounts),
@@ -375,68 +1100,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
375
1100
  });
376
1101
  }
377
1102
  };
378
- const disposeActive = async (reason) => {
379
- const engagement = active;
380
- if (!engagement)
381
- return;
382
- const playbookId = engagement.entry.id;
383
- const commandLabel = `/${engagement.enablement.command}`;
384
- active = undefined;
385
- finalDisposalRequested = undefined;
386
- if (reason === 'dispose') {
387
- mode = 'chat';
388
- await engagement.runtime.dispose();
389
- latestSubRuntimeStateId = undefined;
390
- pendingBossQuestion = undefined;
391
- lastError = undefined;
392
- return;
393
- }
394
- await setMode('chat', reason, playbookId);
395
- await engagement.runtime.dispose();
396
- if (reason === 'dismiss') {
397
- await requireSession().emitStatus(`◇ ${commandLabel} stopped`);
398
- }
399
- else if (reason === 'final') {
400
- await requireSession().emitStatus(`◇ ${commandLabel} finished`);
401
- }
402
- latestSubRuntimeStateId = undefined;
403
- pendingBossQuestion = undefined;
404
- lastError = undefined;
405
- };
406
- const callVisibleChat = async (context, message) => {
407
- const result = await context.callCaptain(visibleChatEnvelope(message));
1103
+ const callVisibleChat = async (frame, context, message) => {
1104
+ const result = await callCaptainQueued(frame, context, visibleChatEnvelope(message), { visibility: 'visible', resume: false, allowedTools: [] }, context.signal);
408
1105
  if (result.status !== 'ok') {
409
1106
  throw new Error(result.error ?? `callCaptain status "${result.status}"`);
410
1107
  }
411
1108
  };
412
- const callVisibleTurnSummary = async (context, input) => {
413
- const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
1109
+ const callVisibleTurnSummary = async (frame, context, input) => {
1110
+ const result = await callCaptainQueued(frame, context, visibleTurnSummaryEnvelope(input), { visibility: 'visible', resume: false, allowedTools: [] }, context.signal);
414
1111
  if (result.status !== 'ok') {
415
1112
  throw new Error(result.error ?? `callCaptain status "${result.status}"`);
416
1113
  }
417
1114
  };
418
- const hiddenRouterEnvelope = (prompt) => [
419
- 'You are the Playbook Captain shell router.',
1115
+ const hiddenLifecycleEnvelope = (prompt) => [
1116
+ 'You are the Playbook Captain shell lifecycle classifier.',
420
1117
  'This is hidden control work. Return only one JSON object and no prose.',
421
1118
  'Allowed decisions:',
422
- '{"decision":"chat","text":"visible clarification or chat reply"}',
423
- '{"decision":"dispatch","playbookId":"<registered id>","text":"Boss text for that playbook"}',
424
- '{"decision":"sub","text":"Boss text for the active playbook"}',
425
- '{"decision":"dismiss","text":"optional visible dismissal reply"}',
426
- 'Use chat for near-miss command-like input or low-confidence playbook selection.',
427
- 'Treat unregistered slash-prefixed input as ordinary router input.',
428
- `Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
429
- `Registry:\n${JSON.stringify(entries.map((entry) => ({
430
- id: entry.id,
431
- command: enablementById.get(entry.id)?.command ?? entry.command,
432
- intent: entry.intent,
433
- })))}`,
1119
+ '{"decision":"deliver"}',
1120
+ '{"decision":"dismiss"}',
1121
+ 'Choose dismiss only when Boss explicitly asks to stop or dismiss the current active engagement.',
1122
+ 'Choose deliver for every task instruction, answer, clarification, continuation, command-like near miss, or ambiguous message.',
1123
+ 'Do not rewrite, summarize, or copy the Boss message into the result.',
434
1124
  `Boss message:\n${prompt}`,
435
1125
  ].join('\n\n');
436
- const routerClarification = async (context) => {
437
- await callVisibleChat(context, "I'm not sure whether this should be Captain chat or a /code task. Please clarify.");
438
- };
439
- const parseRouterDecision = (finalText) => {
1126
+ const parseLifecycleDecision = (finalText) => {
440
1127
  let parsed;
441
1128
  try {
442
1129
  parsed = JSON.parse(finalText);
@@ -451,87 +1138,57 @@ export function createPlaybookCaptainShell(options, deps = {}) {
451
1138
  }
452
1139
  const record = parsed;
453
1140
  const decision = record.decision;
454
- if (decision === 'chat') {
455
- return typeof record.text === 'string' && record.text.trim()
456
- ? { decision, text: record.text.trim() }
457
- : undefined;
458
- }
459
- if (decision === 'dispatch') {
460
- return typeof record.playbookId === 'string' &&
461
- byId.has(record.playbookId) &&
462
- typeof record.text === 'string' &&
463
- record.text.trim()
464
- ? {
465
- decision,
466
- playbookId: record.playbookId,
467
- text: record.text.trim(),
468
- }
469
- : undefined;
470
- }
471
- if (decision === 'sub') {
472
- return typeof record.text === 'string' && record.text.trim()
473
- ? { decision, text: record.text.trim() }
474
- : undefined;
475
- }
476
- if (decision === 'dismiss') {
477
- return typeof record.text === 'string' && record.text.trim()
478
- ? { decision, text: record.text.trim() }
479
- : { decision };
480
- }
1141
+ if (decision === 'deliver')
1142
+ return { decision };
1143
+ if (decision === 'dismiss')
1144
+ return { decision };
481
1145
  return undefined;
482
1146
  };
483
- const routeHidden = async (turn, context) => {
484
- const result = await context.callCaptain(hiddenRouterEnvelope(turn.prompt), { visibility: 'hidden' });
485
- if (result.status !== 'ok' || result.finalText === undefined) {
486
- await routerClarification(context);
487
- return;
1147
+ const routeEngaged = async (turn, context) => {
1148
+ const leaf = leafFrame();
1149
+ if (!leaf) {
1150
+ throw new Error('engaged lifecycle routing requires an active leaf');
488
1151
  }
489
- const decision = parseRouterDecision(result.finalText);
490
- if (!decision) {
491
- await routerClarification(context);
492
- return;
1152
+ let decision;
1153
+ try {
1154
+ const result = await callCaptainQueued(leaf, context, hiddenLifecycleEnvelope(turn.prompt), { visibility: 'hidden', resume: false, allowedTools: [] }, context.signal);
1155
+ if (result.status === 'ok' && result.finalText !== undefined) {
1156
+ decision = parseLifecycleDecision(result.finalText);
1157
+ }
493
1158
  }
494
- lastRouteDecision = decision.decision;
495
- if (decision.decision === 'chat') {
496
- await callVisibleChat(context, decision.text);
497
- return;
1159
+ catch {
1160
+ // Lifecycle classification is advisory. Delivery is fail-open so an
1161
+ // unavailable classifier can never consume a parked leaf's Boss reply.
498
1162
  }
499
- if (decision.decision === 'dispatch') {
500
- const entry = byId.get(decision.playbookId);
501
- if (!entry || (active && active.entry.id !== entry.id)) {
502
- await routerClarification(context);
503
- return;
504
- }
505
- const engagement = await engage(entry);
506
- await submitToActive(engagement, decision.text, context);
1163
+ if (decision?.decision !== 'dismiss') {
1164
+ lastRouteDecision = 'deliver';
1165
+ await submitToActive(leaf, turn.prompt, context);
507
1166
  return;
508
1167
  }
509
- if (decision.decision === 'sub') {
510
- if (!active) {
511
- await routerClarification(context);
512
- return;
513
- }
514
- await submitToActive(active, decision.text, context);
515
- return;
1168
+ lastRouteDecision = 'dismiss';
1169
+ if (leaf.parent) {
1170
+ await resumeParent(leaf, {
1171
+ status: 'aborted',
1172
+ playbookId: leaf.entry.id,
1173
+ childSessionId: leaf.sessionId,
1174
+ ...(leaf.state ? { state: leaf.state } : {}),
1175
+ }, context, 'stopped');
516
1176
  }
517
- if (!active) {
518
- await routerClarification(context);
519
- return;
1177
+ else {
1178
+ await disposeStack('dismiss');
520
1179
  }
521
- const dismissedCommandLabel = `/${active.enablement.command}`;
522
- await disposeActive('dismiss');
523
- await callVisibleChat(context, decision.text ?? `${dismissedCommandLabel} stopped.`);
524
1180
  };
525
1181
  const handleRegisteredCommand = async (entry, text, context) => {
526
1182
  const enablement = enablementById.get(entry.id);
527
- if (active && active.entry.id !== entry.id) {
528
- await callVisibleChat(context, `/${active.enablement.command} is already running. Finish or stop it before starting /${enablement.command}.`);
1183
+ const leaf = leafFrame();
1184
+ if (leaf && leaf.entry.id !== entry.id) {
1185
+ await callVisibleChat(leaf, context, `${frameLabel(leaf)} is already running. Finish or stop it before starting /${enablement.command}.`);
529
1186
  return;
530
1187
  }
531
- const engagement = await engage(entry);
1188
+ const engagement = leaf ?? (await engage(entry));
532
1189
  if (text.length === 0) {
533
1190
  await requestVisibility(engagement.enablement);
534
- await callVisibleChat(context, `Ask what task to run with /${enablement.command}.`);
1191
+ await callVisibleChat(engagement, context, `Ask what task to run with /${enablement.command}.`);
535
1192
  return;
536
1193
  }
537
1194
  await submitToActive(engagement, text, context);
@@ -548,10 +1205,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
548
1205
  for (const enablement of enablementById.values()) {
549
1206
  enablement.entry.validateOptions(enablement.optionInput);
550
1207
  }
1208
+ internalCaptainEnablement = createInternalCaptainEnablement();
551
1209
  await setMode('chat', 'init');
552
1210
  },
553
1211
  async handleBossTurn(turn, context) {
554
1212
  requireSession();
1213
+ if (activeTurnHostCalls !== undefined) {
1214
+ throw new Error('cannot handle concurrent Boss turns');
1215
+ }
1216
+ const turnHostCalls = new Set();
1217
+ activeTurnHostCalls = turnHostCalls;
555
1218
  activeContext = context;
556
1219
  try {
557
1220
  const command = parseRegisteredCommand(turn.prompt);
@@ -562,15 +1225,31 @@ export function createPlaybookCaptainShell(options, deps = {}) {
562
1225
  return;
563
1226
  }
564
1227
  }
565
- await routeHidden(turn, context);
1228
+ const leaf = leafFrame();
1229
+ if (leaf) {
1230
+ await routeEngaged(turn, context);
1231
+ return;
1232
+ }
1233
+ if (turn.prompt.trim().length === 0)
1234
+ return;
1235
+ const captain = await engageInternalCaptain();
1236
+ await submitToActive(captain, turn.prompt, context);
566
1237
  }
567
1238
  finally {
1239
+ await drainHostCalls(turnHostCalls);
1240
+ if (activeTurnHostCalls === turnHostCalls) {
1241
+ activeTurnHostCalls = undefined;
1242
+ }
568
1243
  activeContext = undefined;
569
1244
  }
570
1245
  },
1246
+ async prepareDispose() {
1247
+ activeContext = undefined;
1248
+ await disposeStack('dispose');
1249
+ },
571
1250
  async dispose() {
572
1251
  activeContext = undefined;
573
- await disposeActive('dispose');
1252
+ await disposeStack('dispose');
574
1253
  },
575
1254
  };
576
1255
  }