@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.
- package/README.md +190 -151
- package/package.json +50 -6
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
- package/reference/sdlc/code.playbook/bin/run.js +999 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
- package/reference/sdlc/code.playbook/code.playbook.js +199 -488
- package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
- package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
- package/reference/sdlc/code.playbook/code.registry.js +0 -3
- package/reference/sdlc/code.playbook/code.registry.ts +0 -6
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
- package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1165 -89
- package/slc/optimize.md +92 -0
- package/slc/text2gears.md +255 -7
- package/src/runtime.d.ts +146 -3
- package/src/runtime.ts +201 -2
- package/src/xstate-playbook-runtime.d.ts +201 -0
- package/src/xstate-playbook-runtime.js +2058 -0
- package/src/xstate-playbook-runtime.ts +2792 -0
- package/src/xstate-runtime.d.ts +95 -0
- package/src/xstate-runtime.js +1258 -0
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
114
|
+
latestSubRuntimeState?: PlaybookState;
|
|
115
|
+
pendingBossQuestions?: unknown;
|
|
72
116
|
lastError?: { name: string; message: string };
|
|
73
|
-
lastRouteDecision?:
|
|
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
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
379
|
+
const frames: EngagementFrame[] = [];
|
|
314
380
|
let mode: ShellMode = 'chat';
|
|
315
|
-
let
|
|
316
|
-
let pendingBossQuestion: unknown;
|
|
381
|
+
let pendingBossQuestions: unknown;
|
|
317
382
|
let lastError: { name: string; message: string } | undefined;
|
|
318
|
-
let lastRouteDecision:
|
|
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 =
|
|
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
|
-
...(
|
|
335
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
395
|
-
const record = payloadRecord(
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
402
|
-
if (
|
|
403
|
-
const record = payloadRecord(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const result = await
|
|
441
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
731
|
+
try {
|
|
732
|
+
await activeContext.setVisiblePlayers(ids);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
throw new VisibilityControlError(error);
|
|
735
|
+
}
|
|
497
736
|
};
|
|
498
737
|
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
1441
|
+
frame: EngagementFrame,
|
|
521
1442
|
text: string,
|
|
522
1443
|
context: CaptainContext,
|
|
523
1444
|
): Promise<void> => {
|
|
524
|
-
|
|
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
|
-
? {
|
|
1452
|
+
? {
|
|
1453
|
+
owner: frame,
|
|
1454
|
+
counts: summaryCounts,
|
|
1455
|
+
stateCounts: summaryStateCounts,
|
|
1456
|
+
}
|
|
534
1457
|
: undefined;
|
|
535
|
-
|
|
1458
|
+
let completed = false;
|
|
536
1459
|
try {
|
|
537
|
-
await
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 (
|
|
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 (
|
|
1476
|
+
if (completed && policy) {
|
|
552
1477
|
const progressRounds = summaryProgressRoundCount(summaryStateCounts);
|
|
553
|
-
await callVisibleTurnSummary(context, {
|
|
554
|
-
playbookId:
|
|
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
|
|
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
|
|
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
|
|
1530
|
+
const hiddenLifecycleEnvelope = (prompt: string): string =>
|
|
625
1531
|
[
|
|
626
|
-
'You are the Playbook Captain shell
|
|
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":"
|
|
630
|
-
'{"decision":"
|
|
631
|
-
'
|
|
632
|
-
'
|
|
633
|
-
'
|
|
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
|
|
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
|
-
):
|
|
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 === '
|
|
674
|
-
|
|
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
|
|
1566
|
+
const routeEngaged = async (
|
|
704
1567
|
turn: BossTurn,
|
|
705
1568
|
context: CaptainContext,
|
|
706
1569
|
): Promise<void> => {
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
1620
|
+
const leaf = leafFrame();
|
|
1621
|
+
if (leaf && leaf.entry.id !== entry.id) {
|
|
767
1622
|
await callVisibleChat(
|
|
1623
|
+
leaf,
|
|
768
1624
|
context,
|
|
769
|
-
|
|
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
|
-
|
|
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
|
|
1705
|
+
await disposeStack('dispose');
|
|
827
1706
|
},
|
|
828
1707
|
};
|
|
829
1708
|
}
|