@sublang/playbook 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +243 -193
- package/package.json +52 -17
- 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 +580 -0
- package/reference/sdlc/code.playbook/bin/run.js +893 -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 +470 -182
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
- package/reference/sdlc/code.playbook/code.playbook.js +1098 -202
- package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
- package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
- package/reference/sdlc/code.playbook/code.registry.js +49 -34
- package/reference/sdlc/code.playbook/code.registry.ts +75 -41
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
- package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
- package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -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 +1097 -80
- package/slc/optimize.md +88 -0
- package/slc/text2gears.md +247 -5
- package/src/runtime.d.ts +145 -3
- package/src/runtime.ts +200 -2
- package/src/xstate-runtime.d.ts +94 -0
- package/src/xstate-runtime.js +1247 -0
- package/src/xstate-runtime.ts +1802 -0
- package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
- package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
- package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
- package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
- package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
- package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
- package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
|
@@ -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,57 +11,119 @@ 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;
|
|
21
29
|
players: readonly RegistryPlayer[];
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
export interface PlaybookCaptainDeps {
|
|
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;
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export interface PlaybookCaptainRegistryEntry {
|
|
25
45
|
id: string;
|
|
26
46
|
command: string;
|
|
27
47
|
intent: string;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
copyPasteGuardNames: readonly string[];
|
|
31
|
-
stateCountLabels?: Readonly<Record<string, string>>;
|
|
48
|
+
requiredRoleIds: readonly string[];
|
|
49
|
+
summaryPolicy?: PlaybookSummaryPolicy;
|
|
32
50
|
validateOptions(captainOptions: unknown): unknown;
|
|
33
51
|
createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
|
|
54
|
+
// Per-enabled-playbook binding the shell resolves at init from
|
|
55
|
+
// `captain.options.playbooks`: each playbook binds its local roles to
|
|
56
|
+
// `<id>-<role>` host players and carries the generated visible set.
|
|
57
|
+
interface Enablement {
|
|
58
|
+
entry: PlaybookCaptainRegistryEntry;
|
|
59
|
+
command: string;
|
|
60
|
+
optionInput: unknown;
|
|
61
|
+
boundPlayers: readonly RegistryPlayer[];
|
|
62
|
+
hostPlayerId: (localRole: string) => string;
|
|
63
|
+
visiblePlayerIds?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface EngagementFrame {
|
|
37
67
|
entry: PlaybookCaptainRegistryEntry;
|
|
68
|
+
enablement: Enablement;
|
|
38
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;
|
|
39
87
|
}
|
|
40
88
|
|
|
41
|
-
type
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
|
46
102
|
|
|
47
103
|
type DisposalReason = 'dismiss' | 'final' | 'dispose';
|
|
48
104
|
|
|
49
105
|
interface ControlLedger {
|
|
50
106
|
activePlaybookId?: string;
|
|
107
|
+
activeSessionId?: string;
|
|
108
|
+
rootPlaybookId?: string;
|
|
109
|
+
rootSessionId?: string;
|
|
110
|
+
stackDepth: number;
|
|
111
|
+
stackPath: readonly string[];
|
|
51
112
|
mode: ShellMode;
|
|
52
113
|
latestSubRuntimeStateId?: string;
|
|
53
|
-
|
|
114
|
+
latestSubRuntimeState?: PlaybookState;
|
|
115
|
+
pendingBossQuestions?: unknown;
|
|
54
116
|
lastError?: { name: string; message: string };
|
|
55
|
-
lastRouteDecision?:
|
|
117
|
+
lastRouteDecision?: LifecycleDecision['decision'];
|
|
56
118
|
}
|
|
57
119
|
|
|
58
120
|
type ShellMode = 'chat' | 'engaged.driving' | 'engaged.parked';
|
|
59
121
|
|
|
60
122
|
const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
|
|
61
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;
|
|
62
127
|
|
|
63
128
|
interface TurnSummaryCounts {
|
|
64
129
|
interruptions: number;
|
|
@@ -66,14 +131,11 @@ interface TurnSummaryCounts {
|
|
|
66
131
|
}
|
|
67
132
|
|
|
68
133
|
interface ActiveTurnSummary {
|
|
134
|
+
owner: EngagementFrame;
|
|
69
135
|
counts: TurnSummaryCounts;
|
|
70
136
|
stateCounts: Map<string, number>;
|
|
71
137
|
}
|
|
72
138
|
|
|
73
|
-
export const playbookCaptainRegistry: readonly PlaybookCaptainRegistryEntry[] = [
|
|
74
|
-
codePlaybookRegistryEntry,
|
|
75
|
-
];
|
|
76
|
-
|
|
77
139
|
function parseRegisteredCommand(
|
|
78
140
|
prompt: string,
|
|
79
141
|
): { command: string; text: string } | undefined {
|
|
@@ -84,14 +146,10 @@ function parseRegisteredCommand(
|
|
|
84
146
|
return { command: match[1], text: (match[2] ?? '').trim() };
|
|
85
147
|
}
|
|
86
148
|
|
|
87
|
-
function playbookCommandLabel(entry: PlaybookCaptainRegistryEntry): string {
|
|
88
|
-
return `/${entry.command}`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
149
|
function visibleChatEnvelope(message: string): string {
|
|
92
150
|
return [
|
|
93
151
|
'You are the Playbook Captain shell.',
|
|
94
|
-
'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.',
|
|
95
153
|
message,
|
|
96
154
|
].join('\n\n');
|
|
97
155
|
}
|
|
@@ -101,58 +159,37 @@ function visibleTurnSummaryEnvelope(input: {
|
|
|
101
159
|
submittedText: string;
|
|
102
160
|
counts: TurnSummaryCounts;
|
|
103
161
|
progressPhrase: string;
|
|
104
|
-
|
|
162
|
+
progressRounds: number;
|
|
163
|
+
savedLine: string;
|
|
105
164
|
}): string {
|
|
106
|
-
const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
|
|
107
165
|
return [
|
|
108
166
|
'You are the Playbook Captain shell.',
|
|
109
|
-
'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.',
|
|
110
168
|
'Write a brief, clearly formatted turn-summary block for Boss.',
|
|
111
169
|
'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
|
|
112
170
|
'State only what was done or what changed; do not explain how it was done.',
|
|
113
171
|
'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
|
|
114
172
|
'If progress detail is useful, use only the aggregate progress phrase supplied below.',
|
|
115
|
-
|
|
116
|
-
`Then write the saved-counts line exactly: ${savedLine}`,
|
|
173
|
+
"Do not mention counts for states the active playbook's summary policy does not label.",
|
|
174
|
+
`Then write the saved-counts line exactly: ${input.savedLine}`,
|
|
117
175
|
'Use the exact counts supplied; do not change them.',
|
|
118
|
-
'Do not repeat the exact
|
|
176
|
+
'Do not repeat the exact progress round count outside the saved-counts line.',
|
|
119
177
|
`Playbook: ${input.playbookId}`,
|
|
120
178
|
`Submitted Boss text:\n${input.submittedText}`,
|
|
121
179
|
`Progress counts:\n${input.progressPhrase}`,
|
|
122
180
|
`Counts:\n${JSON.stringify({
|
|
123
181
|
...input.counts,
|
|
124
|
-
|
|
182
|
+
progressRounds: input.progressRounds,
|
|
125
183
|
})}`,
|
|
126
184
|
].join('\n\n');
|
|
127
185
|
}
|
|
128
186
|
|
|
129
|
-
function countNoun(count: number, singular: string, plural = `${singular}s`): string {
|
|
130
|
-
return `${count} ${count === 1 ? singular : plural}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function savedCountsLine(
|
|
134
|
-
counts: TurnSummaryCounts,
|
|
135
|
-
reviewRebuttalRounds: number,
|
|
136
|
-
): string {
|
|
137
|
-
return [
|
|
138
|
-
'Saved you',
|
|
139
|
-
countNoun(counts.interruptions, 'interruption'),
|
|
140
|
-
'and',
|
|
141
|
-
countNoun(counts.copyPastes, 'copy-paste'),
|
|
142
|
-
'across',
|
|
143
|
-
countNoun(reviewRebuttalRounds, 'round'),
|
|
144
|
-
'of reviews/rebuttals.',
|
|
145
|
-
].join(' ');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
187
|
function stateCountLabel(
|
|
149
188
|
stateId: string,
|
|
150
189
|
entry: PlaybookCaptainRegistryEntry,
|
|
151
190
|
): string | undefined {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
|
|
191
|
+
const registryLabel =
|
|
192
|
+
entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
|
|
156
193
|
return registryLabel || undefined;
|
|
157
194
|
}
|
|
158
195
|
|
|
@@ -163,14 +200,18 @@ function pluralizeStateCount(label: string, count: number): string {
|
|
|
163
200
|
return `${count} ${label}s`;
|
|
164
201
|
}
|
|
165
202
|
|
|
166
|
-
function summaryProgressPhrase(
|
|
203
|
+
function summaryProgressPhrase(
|
|
204
|
+
stateCounts: ReadonlyMap<string, number>,
|
|
205
|
+
): string {
|
|
167
206
|
if (stateCounts.size === 0) return 'none';
|
|
168
207
|
return [...stateCounts.entries()]
|
|
169
208
|
.map(([label, count]) => pluralizeStateCount(label, count))
|
|
170
209
|
.join(', ');
|
|
171
210
|
}
|
|
172
211
|
|
|
173
|
-
function summaryProgressRoundCount(
|
|
212
|
+
function summaryProgressRoundCount(
|
|
213
|
+
stateCounts: ReadonlyMap<string, number>,
|
|
214
|
+
): number {
|
|
174
215
|
return [...stateCounts.values()].reduce((total, count) => total + count, 0);
|
|
175
216
|
}
|
|
176
217
|
|
|
@@ -178,38 +219,179 @@ function guardFromJudgeReply(finalText: string): string | undefined {
|
|
|
178
219
|
return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
|
|
179
220
|
}
|
|
180
221
|
|
|
181
|
-
function
|
|
182
|
-
|
|
183
|
-
): {
|
|
222
|
+
function isValidRegistryEntry(
|
|
223
|
+
value: unknown,
|
|
224
|
+
): value is PlaybookCaptainRegistryEntry {
|
|
225
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
226
|
+
const e = value as Record<string, unknown>;
|
|
227
|
+
return (
|
|
228
|
+
typeof e.id === 'string' &&
|
|
229
|
+
typeof e.command === 'string' &&
|
|
230
|
+
typeof e.intent === 'string' &&
|
|
231
|
+
Array.isArray(e.requiredRoleIds) &&
|
|
232
|
+
typeof e.validateOptions === 'function' &&
|
|
233
|
+
typeof e.createRuntime === 'function'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readPlaybooksConfig(
|
|
238
|
+
options: unknown,
|
|
239
|
+
): Record<string, unknown> | undefined {
|
|
240
|
+
if (typeof options !== 'object' || options === null) return undefined;
|
|
241
|
+
const pb = (options as Record<string, unknown>).playbooks;
|
|
242
|
+
if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
return pb as Record<string, unknown>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface BuiltRegistry {
|
|
184
249
|
entries: readonly PlaybookCaptainRegistryEntry[];
|
|
185
250
|
byCommand: Map<string, PlaybookCaptainRegistryEntry>;
|
|
186
251
|
byId: Map<string, PlaybookCaptainRegistryEntry>;
|
|
187
|
-
|
|
252
|
+
enablementById: Map<string, Enablement>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Resolve the active registry at init from `captain.options.playbooks`
|
|
256
|
+
// (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
|
|
257
|
+
// module and bound to namespaced `<id>-<role>` host players.
|
|
258
|
+
async function buildEnablements(
|
|
259
|
+
options: unknown,
|
|
260
|
+
players: readonly RegistryPlayer[],
|
|
261
|
+
loadModule: (specifier: string) => Promise<unknown>,
|
|
262
|
+
): Promise<BuiltRegistry> {
|
|
263
|
+
const entries: PlaybookCaptainRegistryEntry[] = [];
|
|
188
264
|
const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
189
265
|
const byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
190
|
-
|
|
191
|
-
|
|
266
|
+
const enablementById = new Map<string, Enablement>();
|
|
267
|
+
|
|
268
|
+
const config = readPlaybooksConfig(options);
|
|
269
|
+
if (config === undefined) {
|
|
270
|
+
throw new Error('captain.options.playbooks is required');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const ids = Object.keys(config);
|
|
274
|
+
if (ids.length === 0) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
'captain.options.playbooks must enable at least one playbook',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
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
|
+
}
|
|
285
|
+
const block = config[id];
|
|
286
|
+
if (typeof block !== 'object' || block === null || Array.isArray(block)) {
|
|
287
|
+
throw new Error(`captain.options.playbooks.${id} must be an object`);
|
|
288
|
+
}
|
|
289
|
+
const record = block as Record<string, unknown>;
|
|
290
|
+
const from = record.from;
|
|
291
|
+
if (typeof from !== 'string' || from.length === 0) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`captain.options.playbooks.${id}.from must be a module specifier`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
let mod: unknown;
|
|
297
|
+
try {
|
|
298
|
+
mod = await loadModule(from);
|
|
299
|
+
} catch (cause) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`captain.options.playbooks.${id}.from "${from}" failed to import: ${String(
|
|
302
|
+
(cause as { message?: unknown })?.message ?? cause,
|
|
303
|
+
)}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const entry = (mod as { default?: unknown })?.default;
|
|
307
|
+
if (!isValidRegistryEntry(entry)) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (entry.id !== id) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (byId.has(entry.id)) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`captain.options.playbooks has a duplicate playbook id "${entry.id}"`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const command =
|
|
323
|
+
typeof record.command === 'string' && record.command.length > 0
|
|
324
|
+
? record.command
|
|
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
|
+
}
|
|
331
|
+
if (byCommand.has(command)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`captain.options.playbooks has a duplicate effective command "${command}"`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const boundPlayers = entry.requiredRoleIds.map((role) => {
|
|
337
|
+
const host = players.find((p) => p.id === `${entry.id}-${role}`);
|
|
338
|
+
return {
|
|
339
|
+
id: role,
|
|
340
|
+
...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
|
|
341
|
+
...(host?.model !== undefined ? { model: host.model } : {}),
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
entries.push(entry);
|
|
192
345
|
byId.set(entry.id, entry);
|
|
346
|
+
byCommand.set(command, entry);
|
|
347
|
+
enablementById.set(entry.id, {
|
|
348
|
+
entry,
|
|
349
|
+
command,
|
|
350
|
+
optionInput: record.options,
|
|
351
|
+
boundPlayers,
|
|
352
|
+
hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
|
|
353
|
+
visiblePlayerIds: entry.requiredRoleIds.map(
|
|
354
|
+
(role) => `${entry.id}-${role}`,
|
|
355
|
+
),
|
|
356
|
+
});
|
|
193
357
|
}
|
|
194
|
-
return { entries
|
|
358
|
+
return { entries, byCommand, byId, enablementById };
|
|
195
359
|
}
|
|
196
360
|
|
|
197
361
|
export function createPlaybookCaptainShell(
|
|
198
362
|
options: unknown,
|
|
199
|
-
|
|
363
|
+
deps: PlaybookCaptainDeps = {},
|
|
200
364
|
): Captain {
|
|
201
|
-
const
|
|
365
|
+
const loadModule =
|
|
366
|
+
deps.loadModule ?? ((specifier: string) => import(specifier));
|
|
367
|
+
const createSessionId = deps.createSessionId ?? randomUUID;
|
|
368
|
+
const createCaptainRuntime: NonNullable<
|
|
369
|
+
PlaybookCaptainDeps['createCaptainRuntime']
|
|
370
|
+
> = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
|
|
371
|
+
let entries: readonly PlaybookCaptainRegistryEntry[] = [];
|
|
372
|
+
let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
373
|
+
let byId = new Map<string, PlaybookCaptainRegistryEntry>();
|
|
374
|
+
let enablementById = new Map<string, Enablement>();
|
|
375
|
+
let internalCaptainEnablement: Enablement | undefined;
|
|
202
376
|
let session: CaptainSession | undefined;
|
|
203
377
|
let players: readonly RegistryPlayer[] = [];
|
|
204
378
|
let activeContext: CaptainContext | undefined;
|
|
205
|
-
|
|
379
|
+
const frames: EngagementFrame[] = [];
|
|
206
380
|
let mode: ShellMode = 'chat';
|
|
207
|
-
let
|
|
208
|
-
let pendingBossQuestion: unknown;
|
|
381
|
+
let pendingBossQuestions: unknown;
|
|
209
382
|
let lastError: { name: string; message: string } | undefined;
|
|
210
|
-
let lastRouteDecision:
|
|
211
|
-
let finalDisposalRequested: ActiveEngagement | undefined;
|
|
383
|
+
let lastRouteDecision: LifecycleDecision['decision'] | undefined;
|
|
212
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}`;
|
|
213
395
|
|
|
214
396
|
const requireSession = (): CaptainSession => {
|
|
215
397
|
if (!session) {
|
|
@@ -219,12 +401,27 @@ export function createPlaybookCaptainShell(
|
|
|
219
401
|
};
|
|
220
402
|
|
|
221
403
|
const ledgerSnapshot = (
|
|
222
|
-
playbookId: string | undefined =
|
|
404
|
+
playbookId: string | undefined = leafFrame()?.entry.id,
|
|
405
|
+
activeSessionId: string | undefined = leafFrame()?.sessionId,
|
|
223
406
|
): ControlLedger => ({
|
|
224
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),
|
|
225
417
|
mode,
|
|
226
|
-
...(
|
|
227
|
-
|
|
418
|
+
...(leafFrame()?.state?.stateId
|
|
419
|
+
? { latestSubRuntimeStateId: leafFrame()!.state!.stateId }
|
|
420
|
+
: {}),
|
|
421
|
+
...(leafFrame()?.state
|
|
422
|
+
? { latestSubRuntimeState: leafFrame()!.state }
|
|
423
|
+
: {}),
|
|
424
|
+
...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
|
|
228
425
|
...(lastError ? { lastError } : {}),
|
|
229
426
|
...(lastRouteDecision ? { lastRouteDecision } : {}),
|
|
230
427
|
});
|
|
@@ -233,7 +430,8 @@ export function createPlaybookCaptainShell(
|
|
|
233
430
|
from: ShellMode,
|
|
234
431
|
to: ShellMode,
|
|
235
432
|
event: string,
|
|
236
|
-
playbookId: string | undefined =
|
|
433
|
+
playbookId: string | undefined = leafFrame()?.entry.id,
|
|
434
|
+
activeSessionId: string | undefined = leafFrame()?.sessionId,
|
|
237
435
|
): Promise<void> => {
|
|
238
436
|
await requireSession().emitTelemetry({
|
|
239
437
|
topic: SHELL_FSM_TOPIC,
|
|
@@ -241,7 +439,7 @@ export function createPlaybookCaptainShell(
|
|
|
241
439
|
from,
|
|
242
440
|
to,
|
|
243
441
|
event,
|
|
244
|
-
ledger: ledgerSnapshot(playbookId),
|
|
442
|
+
ledger: ledgerSnapshot(playbookId, activeSessionId),
|
|
245
443
|
},
|
|
246
444
|
});
|
|
247
445
|
};
|
|
@@ -249,12 +447,19 @@ export function createPlaybookCaptainShell(
|
|
|
249
447
|
const setMode = async (
|
|
250
448
|
nextMode: ShellMode,
|
|
251
449
|
event: string,
|
|
252
|
-
playbookId: string | undefined =
|
|
450
|
+
playbookId: string | undefined = leafFrame()?.entry.id,
|
|
451
|
+
activeSessionId: string | undefined = leafFrame()?.sessionId,
|
|
253
452
|
): Promise<void> => {
|
|
254
453
|
if (mode === nextMode) return;
|
|
255
454
|
const from = mode;
|
|
256
455
|
mode = nextMode;
|
|
257
|
-
await emitShellTelemetry(
|
|
456
|
+
await emitShellTelemetry(
|
|
457
|
+
from,
|
|
458
|
+
nextMode,
|
|
459
|
+
event,
|
|
460
|
+
playbookId,
|
|
461
|
+
activeSessionId,
|
|
462
|
+
);
|
|
258
463
|
};
|
|
259
464
|
|
|
260
465
|
const normalizeErrorCompact = (
|
|
@@ -283,67 +488,193 @@ export function createPlaybookCaptainShell(
|
|
|
283
488
|
? (payload as Record<string, unknown>)
|
|
284
489
|
: undefined;
|
|
285
490
|
|
|
286
|
-
const
|
|
287
|
-
const record = payloadRecord(
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
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;
|
|
291
506
|
};
|
|
292
507
|
|
|
293
|
-
const
|
|
294
|
-
if (
|
|
295
|
-
const record = payloadRecord(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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]);
|
|
305
522
|
}
|
|
523
|
+
};
|
|
306
524
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
+
};
|
|
310
543
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
+
}
|
|
314
588
|
}
|
|
315
589
|
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
+
}
|
|
322
600
|
}
|
|
323
601
|
};
|
|
324
602
|
|
|
325
|
-
|
|
326
|
-
|
|
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) => {
|
|
327
611
|
if (!activeContext) {
|
|
328
612
|
throw new Error('callPlayer invoked outside a Boss turn');
|
|
329
613
|
}
|
|
330
|
-
const
|
|
331
|
-
|
|
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) {
|
|
332
629
|
activeTurnSummary.counts.interruptions++;
|
|
333
630
|
}
|
|
334
631
|
return {
|
|
335
632
|
status: result.status,
|
|
336
|
-
|
|
337
|
-
|
|
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 } : {}),
|
|
640
|
+
};
|
|
641
|
+
},
|
|
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 } : {}),
|
|
338
665
|
};
|
|
339
666
|
},
|
|
340
|
-
callJudge: async (prompt,
|
|
667
|
+
callJudge: async (prompt, signal) => {
|
|
341
668
|
if (!activeContext) {
|
|
342
669
|
throw new Error('callJudge invoked outside a Boss turn');
|
|
343
670
|
}
|
|
344
|
-
const result = await
|
|
345
|
-
|
|
346
|
-
|
|
671
|
+
const result = await callCaptainQueued(
|
|
672
|
+
frame,
|
|
673
|
+
activeContext,
|
|
674
|
+
prompt,
|
|
675
|
+
{ visibility: 'hidden', resume: false, allowedTools: [] },
|
|
676
|
+
signal,
|
|
677
|
+
);
|
|
347
678
|
if (result.status !== 'ok') {
|
|
348
679
|
throw new Error(
|
|
349
680
|
result.error ?? `callCaptain status "${result.status}"`,
|
|
@@ -355,14 +686,28 @@ export function createPlaybookCaptainShell(
|
|
|
355
686
|
const guard = guardFromJudgeReply(result.finalText);
|
|
356
687
|
if (
|
|
357
688
|
guard &&
|
|
358
|
-
|
|
359
|
-
|
|
689
|
+
activeTurnSummary?.owner === frame &&
|
|
690
|
+
frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
|
|
360
691
|
) {
|
|
361
692
|
activeTurnSummary.counts.copyPastes++;
|
|
362
693
|
}
|
|
363
694
|
return result.finalText;
|
|
364
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
|
+
},
|
|
365
709
|
emitStatus: async (message, data) => {
|
|
710
|
+
if (frame.internal) return;
|
|
366
711
|
await requireSession().emitStatus(
|
|
367
712
|
message,
|
|
368
713
|
data as Record<string, unknown> | undefined,
|
|
@@ -370,168 +715,834 @@ export function createPlaybookCaptainShell(
|
|
|
370
715
|
},
|
|
371
716
|
emitTelemetry: async (event) => {
|
|
372
717
|
if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
|
|
373
|
-
await mirrorSubRuntimeTelemetry(event.payload);
|
|
718
|
+
await mirrorSubRuntimeTelemetry(frame, event.payload);
|
|
374
719
|
}
|
|
375
720
|
await requireSession().emitTelemetry(event);
|
|
376
721
|
},
|
|
377
722
|
});
|
|
378
723
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
724
|
+
// CAPTAIN-22: before dispatching to a playbook, request tmux-play
|
|
725
|
+
// visibility for that playbook's generated host players. A pane
|
|
726
|
+
// reconciliation failure is display-only in tmux-play and does not
|
|
727
|
+
// reject; the legacy path carries no generated set and skips this.
|
|
728
|
+
const requestVisibility = async (enablement: Enablement): Promise<void> => {
|
|
729
|
+
const ids = enablement.visiblePlayerIds;
|
|
730
|
+
if (!ids || ids.length === 0 || !activeContext) return;
|
|
731
|
+
try {
|
|
732
|
+
await activeContext.setVisiblePlayers(ids);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
throw new VisibilityControlError(error);
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
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();
|
|
383
775
|
const runtime = entry.createRuntime({
|
|
384
|
-
captainOptions:
|
|
385
|
-
players,
|
|
776
|
+
captainOptions: enablement.optionInput,
|
|
777
|
+
players: enablement.boundPlayers,
|
|
778
|
+
});
|
|
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),
|
|
386
805
|
});
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const clearLeafLedger = (): void => {
|
|
809
|
+
pendingBossQuestions = undefined;
|
|
390
810
|
lastError = undefined;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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',
|
|
396
1143
|
);
|
|
397
|
-
|
|
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
|
+
}
|
|
398
1438
|
};
|
|
399
1439
|
|
|
400
1440
|
const submitToActive = async (
|
|
401
|
-
|
|
1441
|
+
frame: EngagementFrame,
|
|
402
1442
|
text: string,
|
|
403
1443
|
context: CaptainContext,
|
|
404
1444
|
): Promise<void> => {
|
|
1445
|
+
const policy = frame.entry.summaryPolicy;
|
|
405
1446
|
const summaryCounts: TurnSummaryCounts = {
|
|
406
1447
|
interruptions: 0,
|
|
407
1448
|
copyPastes: 0,
|
|
408
1449
|
};
|
|
409
1450
|
const summaryStateCounts = new Map<string, number>();
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1451
|
+
activeTurnSummary = policy
|
|
1452
|
+
? {
|
|
1453
|
+
owner: frame,
|
|
1454
|
+
counts: summaryCounts,
|
|
1455
|
+
stateCounts: summaryStateCounts,
|
|
1456
|
+
}
|
|
1457
|
+
: undefined;
|
|
1458
|
+
let completed = false;
|
|
416
1459
|
try {
|
|
417
|
-
await
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
}
|
|
422
1470
|
} finally {
|
|
423
1471
|
activeTurnSummary = undefined;
|
|
424
|
-
if (
|
|
425
|
-
finalDisposalRequested = undefined;
|
|
426
|
-
await disposeActive('final');
|
|
427
|
-
} else if (active === engagement && mode === 'engaged.driving') {
|
|
1472
|
+
if (leafFrame() && mode === 'engaged.driving') {
|
|
428
1473
|
await setMode('engaged.parked', 'turn.settled');
|
|
429
1474
|
}
|
|
430
1475
|
}
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
1476
|
+
if (completed && policy) {
|
|
1477
|
+
const progressRounds = summaryProgressRoundCount(summaryStateCounts);
|
|
1478
|
+
await callVisibleTurnSummary(frame, context, {
|
|
1479
|
+
playbookId: frame.entry.id,
|
|
434
1480
|
submittedText: text,
|
|
435
1481
|
counts: summaryCounts,
|
|
436
1482
|
progressPhrase: summaryProgressPhrase(summaryStateCounts),
|
|
437
|
-
|
|
1483
|
+
progressRounds,
|
|
1484
|
+
savedLine: policy.savedCountsLine(summaryCounts, progressRounds),
|
|
438
1485
|
});
|
|
439
1486
|
}
|
|
440
1487
|
};
|
|
441
1488
|
|
|
442
|
-
const disposeActive = async (
|
|
443
|
-
reason: DisposalReason,
|
|
444
|
-
): Promise<void> => {
|
|
445
|
-
const engagement = active;
|
|
446
|
-
if (!engagement) return;
|
|
447
|
-
const playbookId = engagement.entry.id;
|
|
448
|
-
const commandLabel = playbookCommandLabel(engagement.entry);
|
|
449
|
-
active = undefined;
|
|
450
|
-
finalDisposalRequested = undefined;
|
|
451
|
-
if (reason === 'dispose') {
|
|
452
|
-
mode = 'chat';
|
|
453
|
-
await engagement.runtime.dispose();
|
|
454
|
-
latestSubRuntimeStateId = undefined;
|
|
455
|
-
pendingBossQuestion = undefined;
|
|
456
|
-
lastError = undefined;
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
await setMode('chat', reason, playbookId);
|
|
460
|
-
await engagement.runtime.dispose();
|
|
461
|
-
if (reason === 'dismiss') {
|
|
462
|
-
await requireSession().emitStatus(`◇ ${commandLabel} stopped`);
|
|
463
|
-
} else if (reason === 'final') {
|
|
464
|
-
await requireSession().emitStatus(`◇ ${commandLabel} finished`);
|
|
465
|
-
}
|
|
466
|
-
latestSubRuntimeStateId = undefined;
|
|
467
|
-
pendingBossQuestion = undefined;
|
|
468
|
-
lastError = undefined;
|
|
469
|
-
};
|
|
470
|
-
|
|
471
1489
|
const callVisibleChat = async (
|
|
1490
|
+
frame: EngagementFrame,
|
|
472
1491
|
context: CaptainContext,
|
|
473
1492
|
message: string,
|
|
474
1493
|
): Promise<void> => {
|
|
475
|
-
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
|
+
);
|
|
476
1501
|
if (result.status !== 'ok') {
|
|
477
|
-
throw new Error(
|
|
478
|
-
result.error ?? `callCaptain status "${result.status}"`,
|
|
479
|
-
);
|
|
1502
|
+
throw new Error(result.error ?? `callCaptain status "${result.status}"`);
|
|
480
1503
|
}
|
|
481
1504
|
};
|
|
482
1505
|
|
|
483
1506
|
const callVisibleTurnSummary = async (
|
|
1507
|
+
frame: EngagementFrame,
|
|
484
1508
|
context: CaptainContext,
|
|
485
1509
|
input: {
|
|
486
1510
|
playbookId: string;
|
|
487
1511
|
submittedText: string;
|
|
488
1512
|
counts: TurnSummaryCounts;
|
|
489
1513
|
progressPhrase: string;
|
|
490
|
-
|
|
1514
|
+
progressRounds: number;
|
|
1515
|
+
savedLine: string;
|
|
491
1516
|
},
|
|
492
1517
|
): Promise<void> => {
|
|
493
|
-
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
|
+
);
|
|
494
1525
|
if (result.status !== 'ok') {
|
|
495
|
-
throw new Error(
|
|
496
|
-
result.error ?? `callCaptain status "${result.status}"`,
|
|
497
|
-
);
|
|
1526
|
+
throw new Error(result.error ?? `callCaptain status "${result.status}"`);
|
|
498
1527
|
}
|
|
499
1528
|
};
|
|
500
1529
|
|
|
501
|
-
const
|
|
1530
|
+
const hiddenLifecycleEnvelope = (prompt: string): string =>
|
|
502
1531
|
[
|
|
503
|
-
'You are the Playbook Captain shell
|
|
1532
|
+
'You are the Playbook Captain shell lifecycle classifier.',
|
|
504
1533
|
'This is hidden control work. Return only one JSON object and no prose.',
|
|
505
1534
|
'Allowed decisions:',
|
|
506
|
-
'{"decision":"
|
|
507
|
-
'{"decision":"
|
|
508
|
-
'
|
|
509
|
-
'
|
|
510
|
-
'
|
|
511
|
-
'Treat unregistered slash-prefixed input as ordinary router input.',
|
|
512
|
-
`Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
|
|
513
|
-
`Registry:\n${JSON.stringify(
|
|
514
|
-
entries.map((entry) => ({
|
|
515
|
-
id: entry.id,
|
|
516
|
-
command: entry.command,
|
|
517
|
-
intent: entry.intent,
|
|
518
|
-
})),
|
|
519
|
-
)}`,
|
|
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.',
|
|
520
1540
|
`Boss message:\n${prompt}`,
|
|
521
1541
|
].join('\n\n');
|
|
522
1542
|
|
|
523
|
-
const
|
|
524
|
-
context: CaptainContext,
|
|
525
|
-
): Promise<void> => {
|
|
526
|
-
await callVisibleChat(
|
|
527
|
-
context,
|
|
528
|
-
"I'm not sure whether this should be Captain chat or a /code task. Please clarify.",
|
|
529
|
-
);
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const parseRouterDecision = (
|
|
1543
|
+
const parseLifecycleDecision = (
|
|
533
1544
|
finalText: string,
|
|
534
|
-
):
|
|
1545
|
+
): LifecycleDecision | undefined => {
|
|
535
1546
|
let parsed: unknown;
|
|
536
1547
|
try {
|
|
537
1548
|
parsed = JSON.parse(finalText);
|
|
@@ -547,91 +1558,57 @@ export function createPlaybookCaptainShell(
|
|
|
547
1558
|
}
|
|
548
1559
|
const record = parsed as Record<string, unknown>;
|
|
549
1560
|
const decision = record.decision;
|
|
550
|
-
if (decision === '
|
|
551
|
-
|
|
552
|
-
? { decision, text: record.text.trim() }
|
|
553
|
-
: undefined;
|
|
554
|
-
}
|
|
555
|
-
if (decision === 'dispatch') {
|
|
556
|
-
return typeof record.playbookId === 'string' &&
|
|
557
|
-
byId.has(record.playbookId) &&
|
|
558
|
-
typeof record.text === 'string' &&
|
|
559
|
-
record.text.trim()
|
|
560
|
-
? {
|
|
561
|
-
decision,
|
|
562
|
-
playbookId: record.playbookId,
|
|
563
|
-
text: record.text.trim(),
|
|
564
|
-
}
|
|
565
|
-
: undefined;
|
|
566
|
-
}
|
|
567
|
-
if (decision === 'sub') {
|
|
568
|
-
return typeof record.text === 'string' && record.text.trim()
|
|
569
|
-
? { decision, text: record.text.trim() }
|
|
570
|
-
: undefined;
|
|
571
|
-
}
|
|
572
|
-
if (decision === 'dismiss') {
|
|
573
|
-
return typeof record.text === 'string' && record.text.trim()
|
|
574
|
-
? { decision, text: record.text.trim() }
|
|
575
|
-
: { decision };
|
|
576
|
-
}
|
|
1561
|
+
if (decision === 'deliver') return { decision };
|
|
1562
|
+
if (decision === 'dismiss') return { decision };
|
|
577
1563
|
return undefined;
|
|
578
1564
|
};
|
|
579
1565
|
|
|
580
|
-
const
|
|
1566
|
+
const routeEngaged = async (
|
|
581
1567
|
turn: BossTurn,
|
|
582
1568
|
context: CaptainContext,
|
|
583
1569
|
): Promise<void> => {
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
);
|
|
588
|
-
if (result.status !== 'ok' || result.finalText === undefined) {
|
|
589
|
-
await routerClarification(context);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
const decision = parseRouterDecision(result.finalText);
|
|
593
|
-
if (!decision) {
|
|
594
|
-
await routerClarification(context);
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
lastRouteDecision = decision.decision;
|
|
598
|
-
|
|
599
|
-
if (decision.decision === 'chat') {
|
|
600
|
-
await callVisibleChat(context, decision.text);
|
|
601
|
-
return;
|
|
1570
|
+
const leaf = leafFrame();
|
|
1571
|
+
if (!leaf) {
|
|
1572
|
+
throw new Error('engaged lifecycle routing requires an active leaf');
|
|
602
1573
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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);
|
|
609
1585
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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.
|
|
613
1589
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
await routerClarification(context);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
await submitToActive(active, decision.text, context);
|
|
1590
|
+
if (decision?.decision !== 'dismiss') {
|
|
1591
|
+
lastRouteDecision = 'deliver';
|
|
1592
|
+
await submitToActive(leaf, turn.prompt, context);
|
|
621
1593
|
return;
|
|
622
1594
|
}
|
|
623
1595
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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');
|
|
627
1611
|
}
|
|
628
|
-
|
|
629
|
-
const dismissedCommandLabel = playbookCommandLabel(active.entry);
|
|
630
|
-
await disposeActive('dismiss');
|
|
631
|
-
await callVisibleChat(
|
|
632
|
-
context,
|
|
633
|
-
decision.text ?? `${dismissedCommandLabel} stopped.`,
|
|
634
|
-
);
|
|
635
1612
|
};
|
|
636
1613
|
|
|
637
1614
|
const handleRegisteredCommand = async (
|
|
@@ -639,19 +1616,24 @@ export function createPlaybookCaptainShell(
|
|
|
639
1616
|
text: string,
|
|
640
1617
|
context: CaptainContext,
|
|
641
1618
|
): Promise<void> => {
|
|
642
|
-
|
|
1619
|
+
const enablement = enablementById.get(entry.id)!;
|
|
1620
|
+
const leaf = leafFrame();
|
|
1621
|
+
if (leaf && leaf.entry.id !== entry.id) {
|
|
643
1622
|
await callVisibleChat(
|
|
1623
|
+
leaf,
|
|
644
1624
|
context,
|
|
645
|
-
|
|
1625
|
+
`${frameLabel(leaf)} is already running. Finish or stop it before starting /${enablement.command}.`,
|
|
646
1626
|
);
|
|
647
1627
|
return;
|
|
648
1628
|
}
|
|
649
1629
|
|
|
650
|
-
const engagement = await engage(entry);
|
|
1630
|
+
const engagement = leaf ?? (await engage(entry));
|
|
651
1631
|
if (text.length === 0) {
|
|
1632
|
+
await requestVisibility(engagement.enablement);
|
|
652
1633
|
await callVisibleChat(
|
|
1634
|
+
engagement,
|
|
653
1635
|
context,
|
|
654
|
-
`Ask what task to run with /${
|
|
1636
|
+
`Ask what task to run with /${enablement.command}.`,
|
|
655
1637
|
);
|
|
656
1638
|
return;
|
|
657
1639
|
}
|
|
@@ -663,9 +1645,15 @@ export function createPlaybookCaptainShell(
|
|
|
663
1645
|
async init(initSession: CaptainSession): Promise<void> {
|
|
664
1646
|
session = initSession;
|
|
665
1647
|
players = initSession.players;
|
|
666
|
-
|
|
667
|
-
|
|
1648
|
+
const built = await buildEnablements(options, players, loadModule);
|
|
1649
|
+
entries = built.entries;
|
|
1650
|
+
byCommand = built.byCommand;
|
|
1651
|
+
byId = built.byId;
|
|
1652
|
+
enablementById = built.enablementById;
|
|
1653
|
+
for (const enablement of enablementById.values()) {
|
|
1654
|
+
enablement.entry.validateOptions(enablement.optionInput);
|
|
668
1655
|
}
|
|
1656
|
+
internalCaptainEnablement = createInternalCaptainEnablement();
|
|
669
1657
|
await setMode('chat', 'init');
|
|
670
1658
|
},
|
|
671
1659
|
|
|
@@ -674,6 +1662,11 @@ export function createPlaybookCaptainShell(
|
|
|
674
1662
|
context: CaptainContext,
|
|
675
1663
|
): Promise<void> {
|
|
676
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;
|
|
677
1670
|
activeContext = context;
|
|
678
1671
|
try {
|
|
679
1672
|
const command = parseRegisteredCommand(turn.prompt);
|
|
@@ -685,15 +1678,31 @@ export function createPlaybookCaptainShell(
|
|
|
685
1678
|
}
|
|
686
1679
|
}
|
|
687
1680
|
|
|
688
|
-
|
|
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);
|
|
689
1689
|
} finally {
|
|
1690
|
+
await drainHostCalls(turnHostCalls);
|
|
1691
|
+
if (activeTurnHostCalls === turnHostCalls) {
|
|
1692
|
+
activeTurnHostCalls = undefined;
|
|
1693
|
+
}
|
|
690
1694
|
activeContext = undefined;
|
|
691
1695
|
}
|
|
692
1696
|
},
|
|
693
1697
|
|
|
1698
|
+
async prepareDispose(): Promise<void> {
|
|
1699
|
+
activeContext = undefined;
|
|
1700
|
+
await disposeStack('dispose');
|
|
1701
|
+
},
|
|
1702
|
+
|
|
694
1703
|
async dispose(): Promise<void> {
|
|
695
1704
|
activeContext = undefined;
|
|
696
|
-
await
|
|
1705
|
+
await disposeStack('dispose');
|
|
697
1706
|
},
|
|
698
1707
|
};
|
|
699
1708
|
}
|