@sublang/playbook 6.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
|
@@ -3,13 +3,9 @@
|
|
|
3
3
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
4
4
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
6
7
|
import {
|
|
7
|
-
constants,
|
|
8
|
-
copyFileSync,
|
|
9
|
-
existsSync,
|
|
10
|
-
mkdirSync,
|
|
11
8
|
mkdtempSync,
|
|
12
|
-
readFileSync,
|
|
13
9
|
realpathSync,
|
|
14
10
|
rmSync,
|
|
15
11
|
writeFileSync,
|
|
@@ -17,34 +13,61 @@ import {
|
|
|
17
13
|
import { homedir, tmpdir } from 'node:os';
|
|
18
14
|
import { dirname, join, resolve } from 'node:path';
|
|
19
15
|
import { fileURLToPath } from 'node:url';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
parseDocument as parseYamlDocument,
|
|
23
|
-
stringify as stringifyYaml,
|
|
24
|
-
} from 'yaml';
|
|
16
|
+
import { launchManagedTmuxPlay } from '@sublang/cligent/tmux-play';
|
|
17
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
25
18
|
import {
|
|
26
19
|
adapterSdkFailureLines,
|
|
27
20
|
checkAdapterSdks,
|
|
28
21
|
mappedSdksFor,
|
|
29
22
|
probeAdapterSdk,
|
|
30
23
|
} from './adapter-sdk.js';
|
|
24
|
+
import {
|
|
25
|
+
adaptersFromLaunchPlan,
|
|
26
|
+
extractWithFlags,
|
|
27
|
+
loadLaunchPlan,
|
|
28
|
+
loadSelectedLaunchPlanDataOnly,
|
|
29
|
+
projectTmuxConfig,
|
|
30
|
+
resolveUserConfigPath,
|
|
31
|
+
checkReadiness,
|
|
32
|
+
} from './launch-config.js';
|
|
33
|
+
import {
|
|
34
|
+
createManagedInteractiveSessionCommand,
|
|
35
|
+
MANAGED_INTERACTIVE_PAYLOAD_KIND,
|
|
36
|
+
MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
|
|
37
|
+
} from './interactive-session.js';
|
|
38
|
+
import { prepareConfiguredRegistries } from './provision.js';
|
|
39
|
+
import {
|
|
40
|
+
executionConfigFromPlan,
|
|
41
|
+
} from './run.js';
|
|
42
|
+
import {
|
|
43
|
+
assertCaptainSessionExecutionCompatible,
|
|
44
|
+
createCaptainSessionStore,
|
|
45
|
+
SESSION_ID_PATTERN,
|
|
46
|
+
validateCaptainSessionRecord,
|
|
47
|
+
} from './session-store.js';
|
|
48
|
+
|
|
49
|
+
// Preserve the established import surface while the CLI itself delegates to
|
|
50
|
+
// the host-neutral launch-config module.
|
|
51
|
+
export {
|
|
52
|
+
PLAYBOOK_CAPTAIN_MODULE,
|
|
53
|
+
adaptersFromComposedConfig,
|
|
54
|
+
adaptersFromLaunchPlan,
|
|
55
|
+
canonicalizeRegistrySpecifier,
|
|
56
|
+
checkReadiness,
|
|
57
|
+
composeGenericConfig,
|
|
58
|
+
deriveLaunchReadiness,
|
|
59
|
+
extractWithFlags,
|
|
60
|
+
loadLaunchPlan,
|
|
61
|
+
loadOverlayFragment,
|
|
62
|
+
mergeConfigs,
|
|
63
|
+
migrateRetiredProfiles,
|
|
64
|
+
normalizeLaunchPlan,
|
|
65
|
+
projectTmuxConfig,
|
|
66
|
+
resolveAgent,
|
|
67
|
+
resolveConfigHome,
|
|
68
|
+
resolveUserConfigPath,
|
|
69
|
+
} from './launch-config.js';
|
|
31
70
|
|
|
32
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
|
-
const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
|
|
34
|
-
|
|
35
|
-
// PBCLI-1/8: the launcher composes a tmux-play config whose Captain is the
|
|
36
|
-
// Playbook Captain shell adapter module.
|
|
37
|
-
export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
|
|
38
|
-
// PBCLI-12: known adapter shorthands — the adapters with readiness
|
|
39
|
-
// predicates.
|
|
40
|
-
const ADAPTER_SHORTHANDS = ['claude', 'codex'];
|
|
41
|
-
// PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
|
|
42
|
-
// key belongs to that playbook's option slice.
|
|
43
|
-
const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'players'];
|
|
44
|
-
const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
|
|
45
|
-
// PBCLI-9: the bare `captain` id names the tmux-play host Captain, so no
|
|
46
|
-
// playbook-local role may take it.
|
|
47
|
-
const RESERVED_CAPTAIN_ROLE_ID = 'captain';
|
|
48
71
|
const READINESS_FAILURE_EXIT_CODE = 2;
|
|
49
72
|
const COMPOSITION_FAILURE_EXIT_CODE = 1;
|
|
50
73
|
|
|
@@ -58,22 +81,52 @@ export async function runPlaybookCli(options = {}) {
|
|
|
58
81
|
const userConfigPath =
|
|
59
82
|
options.userConfigPath ?? resolveUserConfigPath(env, home);
|
|
60
83
|
|
|
61
|
-
// PBCLI-18: `playbook run ...` is the non-interactive
|
|
62
|
-
//
|
|
84
|
+
// PBCLI-18: `playbook run ...` is the non-interactive presentation of the
|
|
85
|
+
// same generic-config Captain session. It never resolves or launches the
|
|
86
|
+
// tmux presenter, but it receives the launch inputs shared with this host.
|
|
63
87
|
if (argv[0] === 'run') {
|
|
64
88
|
const { runPlaybookRun } = await import('./run.js');
|
|
65
89
|
return await runPlaybookRun({
|
|
66
90
|
argv: argv.slice(1),
|
|
67
91
|
stdout,
|
|
68
92
|
stderr,
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
env,
|
|
94
|
+
homeDir: home,
|
|
71
95
|
userConfigPath,
|
|
96
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
72
97
|
...(options.loadModule ? { loadModule: options.loadModule } : {}),
|
|
73
|
-
...(options.createAgent ? { createAgent: options.createAgent } : {}),
|
|
74
98
|
...(options.readStdin ? { readStdin: options.readStdin } : {}),
|
|
75
|
-
...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
|
|
76
99
|
...(options.hostRoots ? { hostRoots: options.hostRoots } : {}),
|
|
100
|
+
...(options.prepareRegistryModule
|
|
101
|
+
? { prepareRegistryModule: options.prepareRegistryModule }
|
|
102
|
+
: {}),
|
|
103
|
+
...(options.adapterImports
|
|
104
|
+
? { adapterImports: options.adapterImports }
|
|
105
|
+
: {}),
|
|
106
|
+
...(options.createCaptainRuntime
|
|
107
|
+
? { createCaptainRuntime: options.createCaptainRuntime }
|
|
108
|
+
: {}),
|
|
109
|
+
...(options.createCaptainSessionId
|
|
110
|
+
? { createCaptainSessionId: options.createCaptainSessionId }
|
|
111
|
+
: {}),
|
|
112
|
+
...(options.createLogicalSessionId
|
|
113
|
+
? { createLogicalSessionId: options.createLogicalSessionId }
|
|
114
|
+
: {}),
|
|
115
|
+
...(options.createHostRuntime
|
|
116
|
+
? { createHostRuntime: options.createHostRuntime }
|
|
117
|
+
: {}),
|
|
118
|
+
...(options.sessionStore
|
|
119
|
+
? { sessionStore: options.sessionStore }
|
|
120
|
+
: {}),
|
|
121
|
+
...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
|
|
122
|
+
...(options.now ? { now: options.now } : {}),
|
|
123
|
+
...(options.createSessionTempId
|
|
124
|
+
? { createSessionTempId: options.createSessionTempId }
|
|
125
|
+
: {}),
|
|
126
|
+
...(options.createAttemptId
|
|
127
|
+
? { createAttemptId: options.createAttemptId }
|
|
128
|
+
: {}),
|
|
129
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
77
130
|
// PBCLI-39: the run path gates on SDK availability too.
|
|
78
131
|
...(options.probeAdapterSdk
|
|
79
132
|
? { probeAdapterSdk: options.probeAdapterSdk }
|
|
@@ -115,36 +168,85 @@ export async function runPlaybookCli(options = {}) {
|
|
|
115
168
|
);
|
|
116
169
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
117
170
|
}
|
|
171
|
+
const noProvision = forwardArgv.includes('--no-provision');
|
|
172
|
+
forwardArgv = forwardArgv.filter((arg) => arg !== '--no-provision');
|
|
173
|
+
if (noProvision && hasExplicitConfig(argv)) {
|
|
174
|
+
stderr.write(
|
|
175
|
+
'playbook: --no-provision applies to configured registry preparation ' +
|
|
176
|
+
'and cannot combine with a raw --config launch\n',
|
|
177
|
+
);
|
|
178
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
179
|
+
}
|
|
118
180
|
|
|
119
181
|
// PBCLI-1: explicit `--config <path>` launches that raw tmux-play config
|
|
120
182
|
// directly, bypassing seeding, composition, and the readiness gate.
|
|
121
183
|
if (hasExplicitConfig(argv)) {
|
|
184
|
+
try {
|
|
185
|
+
assertRawConfigHasNoManagedSelector(argv);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
188
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
189
|
+
}
|
|
122
190
|
return await launchTmuxPlay(spawnFn, [tmuxPlayBin, ...argv], stderr);
|
|
123
191
|
}
|
|
124
192
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// DR-021 §3: an existing profiles-based config is rewritten in place once,
|
|
128
|
-
// with the original kept beside it, so the user launches without editing.
|
|
193
|
+
let interactiveArgs;
|
|
129
194
|
try {
|
|
130
|
-
|
|
195
|
+
interactiveArgs = parseInteractiveArgs(forwardArgv);
|
|
131
196
|
} catch (error) {
|
|
132
197
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
133
198
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
134
199
|
}
|
|
135
200
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
201
|
+
const launchCwd = resolve(
|
|
202
|
+
options.cwd ?? process.cwd(),
|
|
203
|
+
interactiveArgs.cwd ?? '.',
|
|
204
|
+
);
|
|
205
|
+
// PBCLI-49: selected planning is deliberately provisional. It narrows the
|
|
206
|
+
// current config before preparation; the pane child later acquires the
|
|
207
|
+
// lease and repeats the authoritative read before any host/import work.
|
|
208
|
+
let store;
|
|
209
|
+
let selectedRecord;
|
|
210
|
+
if (interactiveArgs.sessionId !== undefined) {
|
|
211
|
+
try {
|
|
212
|
+
store = createInteractiveStore(options, env, home);
|
|
213
|
+
selectedRecord = validateCaptainSessionRecord(
|
|
214
|
+
await store.read(interactiveArgs.sessionId),
|
|
142
215
|
);
|
|
216
|
+
if (selectedRecord.state !== 'settled') {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Captain session ${JSON.stringify(interactiveArgs.sessionId)} has an uncertain turn; recover it with playbook run before reopening interactively`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
223
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
143
224
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let plan;
|
|
228
|
+
try {
|
|
229
|
+
plan = selectedRecord
|
|
230
|
+
? await loadSelectedLaunchPlanDataOnly({
|
|
231
|
+
userConfigPath,
|
|
232
|
+
overlayPaths: withPaths,
|
|
233
|
+
structuralProjection: selectedRecord.structuralProjection,
|
|
234
|
+
onNotice: (line) => stderr.write(line),
|
|
235
|
+
})
|
|
236
|
+
: await loadLaunchPlan({
|
|
237
|
+
userConfigPath,
|
|
238
|
+
overlayPaths: withPaths,
|
|
239
|
+
loadModule,
|
|
240
|
+
prepareRegistryModule:
|
|
241
|
+
options.prepareRegistryModule ??
|
|
242
|
+
prepareConfiguredRegistries({
|
|
243
|
+
enabled: !noProvision,
|
|
244
|
+
stderr,
|
|
245
|
+
hostRoots: options.hostRoots,
|
|
246
|
+
commandName: 'playbook',
|
|
247
|
+
}),
|
|
248
|
+
onNotice: (line) => stderr.write(line),
|
|
249
|
+
});
|
|
148
250
|
} catch (error) {
|
|
149
251
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
150
252
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -152,15 +254,16 @@ export async function runPlaybookCli(options = {}) {
|
|
|
152
254
|
|
|
153
255
|
// PBCLI-5: `--list` prints each configured playbook's id, effective
|
|
154
256
|
// command, and intent without launching tmux-play.
|
|
155
|
-
if (
|
|
156
|
-
for (const pb of
|
|
257
|
+
if (interactiveArgs.list) {
|
|
258
|
+
for (const pb of Object.values(plan.catalog)) {
|
|
157
259
|
stdout.write(`/${pb.command} ${pb.id} — ${pb.intent}\n`);
|
|
158
260
|
}
|
|
159
261
|
return { code: 0 };
|
|
160
262
|
}
|
|
161
263
|
|
|
162
|
-
// PBCLI-12: readiness
|
|
163
|
-
|
|
264
|
+
// PBCLI-12/46: readiness derives from the same normalized execution plan
|
|
265
|
+
// that both front ends consume, independent of its tmux projection.
|
|
266
|
+
const declaredAdapters = adaptersFromLaunchPlan(plan);
|
|
164
267
|
const readiness = checkReadiness(declaredAdapters, env, home);
|
|
165
268
|
// PBCLI-39/40: SDK availability is an independent check with its own
|
|
166
269
|
// remedy — a credential and an SDK can be missing at once, and reporting
|
|
@@ -195,521 +298,443 @@ export async function runPlaybookCli(options = {}) {
|
|
|
195
298
|
return { code: READINESS_FAILURE_EXIT_CODE };
|
|
196
299
|
}
|
|
197
300
|
|
|
301
|
+
// tmux-play's diagnostics command is an explicit presentation escape hatch,
|
|
302
|
+
// not a managed logical session. Preserve its established direct child
|
|
303
|
+
// status/signal behavior and do not allocate a durable UUID or lease.
|
|
304
|
+
if (interactiveArgs.themeDiagnostics) {
|
|
305
|
+
const { dir: tempDir, path: composedPath } = writeComposedConfig(
|
|
306
|
+
projectTmuxConfig(plan),
|
|
307
|
+
);
|
|
308
|
+
try {
|
|
309
|
+
return await launchTmuxPlay(
|
|
310
|
+
spawnFn,
|
|
311
|
+
[
|
|
312
|
+
tmuxPlayBin,
|
|
313
|
+
'--config',
|
|
314
|
+
composedPath,
|
|
315
|
+
...interactiveArgs.diagnosticArgv,
|
|
316
|
+
],
|
|
317
|
+
stderr,
|
|
318
|
+
);
|
|
319
|
+
} finally {
|
|
320
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
store ??= createInteractiveStore(options, env, home);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
328
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let sessionId;
|
|
332
|
+
let executionProjection;
|
|
333
|
+
let cwd;
|
|
334
|
+
try {
|
|
335
|
+
if (selectedRecord) {
|
|
336
|
+
sessionId = selectedRecord.sessionId;
|
|
337
|
+
cwd = selectedRecord.cwd;
|
|
338
|
+
executionProjection = assertCaptainSessionExecutionCompatible(
|
|
339
|
+
selectedRecord.structuralProjection,
|
|
340
|
+
executionConfigFromPlan(plan),
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
sessionId = (options.createLogicalSessionId ?? randomUUID)();
|
|
344
|
+
if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId)) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
cwd = launchCwd;
|
|
350
|
+
executionProjection = executionConfigFromPlan(plan);
|
|
351
|
+
}
|
|
352
|
+
} catch (error) {
|
|
353
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
354
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
355
|
+
}
|
|
356
|
+
|
|
198
357
|
const { dir: tempDir, path: composedPath } = writeComposedConfig(
|
|
199
|
-
|
|
358
|
+
projectTmuxConfig(plan),
|
|
200
359
|
);
|
|
360
|
+
let prepared;
|
|
201
361
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
362
|
+
throwIfSignalAborted(options.signal);
|
|
363
|
+
prepared = await awaitManagedPreparation(
|
|
364
|
+
() =>
|
|
365
|
+
(options.launchManagedTmuxPlay ?? launchManagedTmuxPlay)({
|
|
366
|
+
sessionId,
|
|
367
|
+
configPath: composedPath,
|
|
368
|
+
cwd,
|
|
369
|
+
stdout,
|
|
370
|
+
stderr,
|
|
371
|
+
...(options.attach !== undefined ? { attach: options.attach } : {}),
|
|
372
|
+
...(options.adapterImports
|
|
373
|
+
? { adapterImports: options.adapterImports }
|
|
374
|
+
: {}),
|
|
375
|
+
createSessionCommand: (context) =>
|
|
376
|
+
createManagedInteractiveSessionCommand(
|
|
377
|
+
context,
|
|
378
|
+
{
|
|
379
|
+
schemaVersion: MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
|
|
380
|
+
kind: MANAGED_INTERACTIVE_PAYLOAD_KIND,
|
|
381
|
+
mode: selectedRecord ? 'selected' : 'fresh',
|
|
382
|
+
sessionId,
|
|
383
|
+
cwd,
|
|
384
|
+
sessionsDir: store.sessionsDir,
|
|
385
|
+
noProvision,
|
|
386
|
+
executionProjection,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
selfBin:
|
|
390
|
+
options.managedSessionBin ??
|
|
391
|
+
fileURLToPath(
|
|
392
|
+
new URL('./interactive-session.js', import.meta.url),
|
|
393
|
+
),
|
|
394
|
+
...(options.execPath ? { execPath: options.execPath } : {}),
|
|
395
|
+
},
|
|
396
|
+
),
|
|
397
|
+
}),
|
|
398
|
+
options.signal,
|
|
206
399
|
);
|
|
400
|
+
if (prepared?.sessionId !== sessionId) {
|
|
401
|
+
await cancelPreparedAfterFailure(
|
|
402
|
+
prepared,
|
|
403
|
+
new Error('managed tmux-play prepared a mismatched session id'),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
await writeStream(
|
|
408
|
+
stderr,
|
|
409
|
+
`playbook: session ${sessionId}\n`,
|
|
410
|
+
options.signal,
|
|
411
|
+
);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
await cancelPreparedAfterFailure(prepared, error);
|
|
414
|
+
}
|
|
415
|
+
await cancelPreparedIfAborted(prepared, options.signal);
|
|
416
|
+
await prepared.attach({
|
|
417
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
418
|
+
...(options.onBeforeManagedAttach
|
|
419
|
+
? { beforeNativeAttach: options.onBeforeManagedAttach }
|
|
420
|
+
: {}),
|
|
421
|
+
});
|
|
422
|
+
return { code: 0 };
|
|
423
|
+
} catch (error) {
|
|
424
|
+
stderr.write(`playbook: failed to launch managed session: ${errorMessage(error)}\n`);
|
|
425
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
207
426
|
} finally {
|
|
208
427
|
rmSync(tempDir, { recursive: true, force: true });
|
|
209
428
|
}
|
|
210
429
|
}
|
|
211
430
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
throw new Error('--with needs a value');
|
|
223
|
-
}
|
|
224
|
-
withPaths.push(value);
|
|
225
|
-
i += 1;
|
|
226
|
-
} else if (arg.startsWith('--with=')) {
|
|
227
|
-
const value = arg.slice('--with='.length);
|
|
228
|
-
if (!value) throw new Error('--with needs a value');
|
|
229
|
-
withPaths.push(value);
|
|
230
|
-
} else {
|
|
231
|
-
rest.push(arg);
|
|
431
|
+
export function parseInteractiveArgs(argv) {
|
|
432
|
+
if (argv.includes('--theme-diagnostics')) {
|
|
433
|
+
if (argv.filter((arg) => arg === '--theme-diagnostics').length > 1) {
|
|
434
|
+
throw new Error('--theme-diagnostics was repeated');
|
|
435
|
+
}
|
|
436
|
+
if (argv.some((arg) => arg === '--session' || arg.startsWith('--session='))) {
|
|
437
|
+
throw new Error('--session cannot combine with --theme-diagnostics');
|
|
438
|
+
}
|
|
439
|
+
if (argv.includes('--list')) {
|
|
440
|
+
throw new Error('--list cannot combine with --theme-diagnostics');
|
|
232
441
|
}
|
|
442
|
+
const recoveryArg = argv.find(
|
|
443
|
+
(arg) =>
|
|
444
|
+
arg === '--continue' ||
|
|
445
|
+
arg === '--retry-uncertain' ||
|
|
446
|
+
arg === '--discard-uncertain',
|
|
447
|
+
);
|
|
448
|
+
if (recoveryArg !== undefined) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`${recoveryArg} is headless recovery syntax; use playbook run with an explicit session`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return Object.freeze({
|
|
454
|
+
sessionId: undefined,
|
|
455
|
+
cwd: undefined,
|
|
456
|
+
list: false,
|
|
457
|
+
themeDiagnostics: true,
|
|
458
|
+
diagnosticArgv: Object.freeze([...argv]),
|
|
459
|
+
});
|
|
233
460
|
}
|
|
234
|
-
return { withPaths, rest };
|
|
235
|
-
}
|
|
236
461
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
let
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
462
|
+
let sessionId;
|
|
463
|
+
let cwd;
|
|
464
|
+
let list = false;
|
|
465
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
466
|
+
const arg = argv[index];
|
|
467
|
+
if (arg === '--list') {
|
|
468
|
+
if (list) throw new Error('--list was repeated');
|
|
469
|
+
list = true;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (arg === '--session' || arg.startsWith('--session=')) {
|
|
473
|
+
if (sessionId !== undefined) {
|
|
474
|
+
throw new Error('interactive --session selector was repeated or combined');
|
|
475
|
+
}
|
|
476
|
+
sessionId = optionValue(argv, index, '--session');
|
|
477
|
+
if (arg === '--session') index += 1;
|
|
478
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
479
|
+
throw new Error('--session requires a canonical lowercase UUID');
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (arg === '--cwd' || arg.startsWith('--cwd=')) {
|
|
484
|
+
if (cwd !== undefined) throw new Error('interactive --cwd was repeated');
|
|
485
|
+
cwd = optionValue(argv, index, '--cwd');
|
|
486
|
+
if (arg === '--cwd') index += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
arg === '--continue' ||
|
|
491
|
+
arg === '--retry-uncertain' ||
|
|
492
|
+
arg === '--discard-uncertain'
|
|
493
|
+
) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`${arg} is headless recovery syntax; use playbook run with an explicit session`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
244
498
|
throw new Error(
|
|
245
|
-
`
|
|
499
|
+
`unsupported managed interactive option ${JSON.stringify(arg)}`,
|
|
246
500
|
);
|
|
247
501
|
}
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
fragment = parseYaml(text);
|
|
251
|
-
} catch (error) {
|
|
502
|
+
if (sessionId !== undefined && cwd !== undefined) {
|
|
252
503
|
throw new Error(
|
|
253
|
-
|
|
504
|
+
'interactive --cwd cannot combine with --session; the stored working directory is authoritative',
|
|
254
505
|
);
|
|
255
506
|
}
|
|
256
|
-
if (
|
|
257
|
-
throw new Error(
|
|
258
|
-
}
|
|
259
|
-
return fragment;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// PBCLI-25/26: recursive merge for plain maps, replacement for every
|
|
263
|
-
// other value; neither input is mutated. Object.fromEntries defines own
|
|
264
|
-
// data properties, so a hostile fragment key such as __proto__ cannot
|
|
265
|
-
// reach the prototype.
|
|
266
|
-
function mergeConfigs(base, overlay) {
|
|
267
|
-
return Object.fromEntries([
|
|
268
|
-
...Object.entries(base),
|
|
269
|
-
...Object.entries(overlay).map(([key, value]) => [
|
|
270
|
-
key,
|
|
271
|
-
isObject(base[key]) && isObject(value)
|
|
272
|
-
? mergeConfigs(base[key], value)
|
|
273
|
-
: value,
|
|
274
|
-
]),
|
|
275
|
-
]);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function resolveConfigHome(env = process.env, home = homedir()) {
|
|
279
|
-
return env.XDG_CONFIG_HOME || join(home, '.config');
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export function resolveUserConfigPath(env = process.env, home = homedir()) {
|
|
283
|
-
return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// PBCLI-8 (DR-021): a scalar `captain` / `players.<role>` value is an
|
|
287
|
-
// adapter shorthand; a full block is a self-contained tmux-play agent block
|
|
288
|
-
// carrying its own adapter/model/effort/permissions. There is no profile
|
|
289
|
-
// indirection, so retuning one agent cannot change another.
|
|
290
|
-
export function resolveAgent(value, path) {
|
|
291
|
-
if (typeof value === 'string') return { adapter: value };
|
|
292
|
-
if (isObject(value)) return { ...value };
|
|
293
|
-
throw new Error(`${path} must be an adapter shorthand or an agent block`);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// DR-021 §3: migrate the user's config on disk, once, keeping the original.
|
|
297
|
-
// The backup is written before the rewrite and never overwrites an existing
|
|
298
|
-
// file, so a prior backup — or a user's own .bak — cannot be lost.
|
|
299
|
-
function migrateUserConfigIfRetired(userConfigPath, stderr) {
|
|
300
|
-
let text;
|
|
301
|
-
try {
|
|
302
|
-
text = readFileSync(userConfigPath, 'utf8');
|
|
303
|
-
} catch {
|
|
304
|
-
return;
|
|
507
|
+
if (sessionId !== undefined && list) {
|
|
508
|
+
throw new Error('--session cannot combine with --list');
|
|
305
509
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
migrated = migrateRetiredProfiles(text);
|
|
309
|
-
} catch (error) {
|
|
310
|
-
throw new Error(
|
|
311
|
-
`cannot migrate the retired profiles config at ${userConfigPath}: ` +
|
|
312
|
-
`${errorMessage(error)} — edit it by hand: each agent takes its own ` +
|
|
313
|
-
'adapter, model, effort, and permissions',
|
|
314
|
-
);
|
|
510
|
+
if (list && cwd !== undefined) {
|
|
511
|
+
throw new Error('--cwd applies to a fresh launch and cannot combine with --list');
|
|
315
512
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
`the original is at ${backupPath}\n`,
|
|
324
|
-
);
|
|
513
|
+
return Object.freeze({
|
|
514
|
+
sessionId,
|
|
515
|
+
cwd,
|
|
516
|
+
list,
|
|
517
|
+
themeDiagnostics: false,
|
|
518
|
+
diagnosticArgv: Object.freeze([]),
|
|
519
|
+
});
|
|
325
520
|
}
|
|
326
521
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
522
|
+
// PBCLI-23/24/49: the executable converts termination signals into an abort of
|
|
523
|
+
// an active headless turn or a not-yet-attached managed launch, waits for its
|
|
524
|
+
// uncertain marker and lease cleanup, then asks the caller to re-raise the
|
|
525
|
+
// original signal. Cligent invokes the supplied synchronous hand-off only
|
|
526
|
+
// after input activation and immediately before starting the native tmux
|
|
527
|
+
// client; that exact boundary transfers signal ownership to native terminal
|
|
528
|
+
// semantics without leaving an unowned activation interval.
|
|
529
|
+
export async function runPlaybookCliEntry(options = {}) {
|
|
530
|
+
const processLike = options.processLike ?? process;
|
|
531
|
+
const entryArgv = options.argv ?? processLike.argv?.slice(2) ?? [];
|
|
532
|
+
if (entryArgv[0] !== 'run' && !isManagedInteractiveInvocation(entryArgv)) {
|
|
533
|
+
return runPlaybookCli(options);
|
|
333
534
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
export function migrateRetiredProfiles(text) {
|
|
342
|
-
const doc = parseYamlDocument(text);
|
|
343
|
-
const contents = doc.contents;
|
|
344
|
-
if (!contents || !Array.isArray(contents.items)) return undefined;
|
|
345
|
-
const profiles = doc.get('profiles');
|
|
346
|
-
const agentPaths = [['captain']];
|
|
347
|
-
const playbooks = doc.get('playbooks');
|
|
348
|
-
if (playbooks && Array.isArray(playbooks.items)) {
|
|
349
|
-
for (const entry of playbooks.items) {
|
|
350
|
-
const id = String(entry.key);
|
|
351
|
-
const players = doc.getIn(['playbooks', id, 'players']);
|
|
352
|
-
if (!players || !Array.isArray(players.items)) continue;
|
|
353
|
-
for (const player of players.items) {
|
|
354
|
-
agentPaths.push(['playbooks', id, 'players', String(player.key)]);
|
|
355
|
-
}
|
|
535
|
+
const controller = new AbortController();
|
|
536
|
+
let receivedSignal;
|
|
537
|
+
let signalOwnershipTransferred = false;
|
|
538
|
+
const handlers = {};
|
|
539
|
+
const removeHandlers = () => {
|
|
540
|
+
for (const [signal, handler] of Object.entries(handlers)) {
|
|
541
|
+
processLike.off(signal, handler);
|
|
356
542
|
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
let changed = false;
|
|
365
|
-
for (const path of agentPaths) {
|
|
366
|
-
const node = doc.getIn(path, true);
|
|
367
|
-
if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
|
|
368
|
-
// A scalar that named a profile; a bare adapter shorthand stays.
|
|
369
|
-
const settings = profileSettings(node.value);
|
|
370
|
-
if (settings === undefined) continue;
|
|
371
|
-
const inlined = settings.clone();
|
|
372
|
-
// The scalar carried any comment on that line, and replacing the node
|
|
373
|
-
// would drop it. Re-attach it above the block that replaces it.
|
|
374
|
-
carryScalarComment(node, inlined);
|
|
375
|
-
doc.setIn(path, inlined);
|
|
376
|
-
changed = true;
|
|
377
|
-
} else if (node && Array.isArray(node.items)) {
|
|
378
|
-
const named = node.get?.('profile');
|
|
379
|
-
if (named === undefined) continue;
|
|
380
|
-
const settings = profileSettings(named);
|
|
381
|
-
if (settings === undefined) {
|
|
382
|
-
throw new Error(
|
|
383
|
-
`${path.join('.')}.profile names "${String(named)}", which no ` +
|
|
384
|
-
'profiles entry defines',
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
// Fill the block from its profile in place — never rebuild it — so
|
|
388
|
-
// the user's own keys, ordering, and comments survive untouched. The
|
|
389
|
-
// block's own fields stay authoritative, so only absent keys are added.
|
|
390
|
-
node.delete('profile');
|
|
391
|
-
for (const item of settings.items) {
|
|
392
|
-
if (node.has(String(item.key))) continue;
|
|
393
|
-
// Append the whole pair, not a rebuilt key/value: a comment above a
|
|
394
|
-
// setting rides on that setting's key node, so stringifying the key
|
|
395
|
-
// would drop it.
|
|
396
|
-
node.add(item.clone());
|
|
543
|
+
};
|
|
544
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
545
|
+
handlers[signal] = () => {
|
|
546
|
+
if (receivedSignal !== undefined) {
|
|
547
|
+
removeHandlers();
|
|
548
|
+
processLike.kill(processLike.pid, signal);
|
|
549
|
+
return;
|
|
397
550
|
}
|
|
398
|
-
|
|
399
|
-
|
|
551
|
+
receivedSignal = signal;
|
|
552
|
+
controller.abort(new Error(`received ${signal}`));
|
|
553
|
+
};
|
|
400
554
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
changed = true;
|
|
555
|
+
for (const [signal, handler] of Object.entries(handlers)) {
|
|
556
|
+
processLike.on(signal, handler);
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
const result = await runPlaybookCli({
|
|
560
|
+
...options,
|
|
561
|
+
signal: controller.signal,
|
|
562
|
+
onBeforeManagedAttach: () => {
|
|
563
|
+
signalOwnershipTransferred = true;
|
|
564
|
+
removeHandlers();
|
|
565
|
+
options.onBeforeManagedAttach?.();
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
return receivedSignal === undefined || signalOwnershipTransferred
|
|
569
|
+
? result
|
|
570
|
+
: { signal: receivedSignal };
|
|
571
|
+
} finally {
|
|
572
|
+
removeHandlers();
|
|
421
573
|
}
|
|
422
|
-
if (!changed) return undefined;
|
|
423
|
-
// Say what happened at the top of the file the user will open next:
|
|
424
|
-
// some of their remaining comments describe the retired model.
|
|
425
|
-
doc.commentBefore = MIGRATION_NOTE;
|
|
426
|
-
return doc.toString();
|
|
427
574
|
}
|
|
428
575
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
// `captain: base # the judge` keeps its note. The pair's key comments are
|
|
437
|
-
// untouched by the replacement and need no carrying.
|
|
438
|
-
function carryScalarComment(node, inlined) {
|
|
439
|
-
const parts = [node.commentBefore, node.comment].filter(
|
|
440
|
-
(part) => typeof part === 'string' && part.trim() !== '',
|
|
576
|
+
function isManagedInteractiveInvocation(argv) {
|
|
577
|
+
return (
|
|
578
|
+
!argv.includes('--help') &&
|
|
579
|
+
!argv.includes('-h') &&
|
|
580
|
+
!argv.includes('--list') &&
|
|
581
|
+
!argv.includes('--theme-diagnostics') &&
|
|
582
|
+
!hasExplicitConfig(argv)
|
|
441
583
|
);
|
|
442
|
-
if (parts.length === 0) return;
|
|
443
|
-
const first = inlined.items?.[0]?.key;
|
|
444
|
-
if (!first) return;
|
|
445
|
-
// A flow map carrying a comment renders as a multi-line brace block; the
|
|
446
|
-
// ordinary block form is what the rest of the config looks like.
|
|
447
|
-
inlined.flow = false;
|
|
448
|
-
const carried = parts.join('\n');
|
|
449
|
-
first.commentBefore =
|
|
450
|
-
first.commentBefore === undefined
|
|
451
|
-
? carried
|
|
452
|
-
: `${carried}\n${first.commentBefore}`;
|
|
453
584
|
}
|
|
454
585
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
586
|
+
function writeComposedConfig(composed) {
|
|
587
|
+
const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
|
|
588
|
+
const path = join(dir, 'tmux-play.config.yaml');
|
|
589
|
+
writeFileSync(path, stringifyYaml(composed));
|
|
590
|
+
return { dir, path };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function hasExplicitConfig(argv) {
|
|
594
|
+
return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
|
|
462
595
|
}
|
|
463
596
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
597
|
+
function assertRawConfigHasNoManagedSelector(argv) {
|
|
598
|
+
const managed = argv.find(
|
|
599
|
+
(arg) =>
|
|
600
|
+
arg === '--session' ||
|
|
601
|
+
arg.startsWith('--session=') ||
|
|
602
|
+
arg === '--continue' ||
|
|
603
|
+
arg === '--retry-uncertain' ||
|
|
604
|
+
arg === '--discard-uncertain',
|
|
605
|
+
);
|
|
606
|
+
if (managed !== undefined) {
|
|
469
607
|
throw new Error(
|
|
470
|
-
|
|
471
|
-
'inline under captain and each playbooks.<id>.players.<role> ' +
|
|
472
|
-
'(adapter, model, effort, permissions)',
|
|
608
|
+
`${managed} selects a managed Captain session and cannot combine with a raw --config launch`,
|
|
473
609
|
);
|
|
474
610
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
for (const [path, block] of blocks) {
|
|
486
|
-
if (isObject(block) && block.profile !== undefined) {
|
|
487
|
-
throw new Error(
|
|
488
|
-
`${path}.profile was removed${where}: write the agent's settings ` +
|
|
489
|
-
'inline in that block (adapter, model, effort, permissions)',
|
|
490
|
-
);
|
|
491
|
-
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function optionValue(argv, index, name) {
|
|
614
|
+
const arg = argv[index];
|
|
615
|
+
const value =
|
|
616
|
+
arg === name ? argv[index + 1] : arg.slice(`${name}=`.length);
|
|
617
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
618
|
+
throw new Error(`${name} requires a value`);
|
|
492
619
|
}
|
|
620
|
+
return value;
|
|
493
621
|
}
|
|
494
622
|
|
|
495
|
-
function
|
|
496
|
-
if (!isObject(value)) return false;
|
|
623
|
+
function createInteractiveStore(options, env, home) {
|
|
497
624
|
return (
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
625
|
+
options.sessionStore ??
|
|
626
|
+
createCaptainSessionStore({
|
|
627
|
+
env,
|
|
628
|
+
homeDir: home,
|
|
629
|
+
...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
|
|
630
|
+
...(options.now ? { now: options.now } : {}),
|
|
631
|
+
...(options.createSessionTempId
|
|
632
|
+
? { createTempId: options.createSessionTempId }
|
|
633
|
+
: {}),
|
|
634
|
+
})
|
|
504
635
|
);
|
|
505
636
|
}
|
|
506
637
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
for (const id of ids) {
|
|
536
|
-
if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
|
|
537
|
-
throw new Error(
|
|
538
|
-
`playbooks.${id} collides with the reserved internal Captain id`,
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
|
|
542
|
-
const from = block.from;
|
|
543
|
-
if (typeof from !== 'string' || from.length === 0) {
|
|
544
|
-
throw new Error(`playbooks.${id}.from must be a module specifier`);
|
|
545
|
-
}
|
|
546
|
-
let mod;
|
|
547
|
-
try {
|
|
548
|
-
mod = await loadModule(from);
|
|
549
|
-
} catch (cause) {
|
|
550
|
-
throw new Error(
|
|
551
|
-
`playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
const entry = mod?.default;
|
|
555
|
-
if (!isValidRegistryEntry(entry)) {
|
|
556
|
-
throw new Error(
|
|
557
|
-
`playbooks.${id}.from "${from}" exposes no valid registry entry`,
|
|
558
|
-
);
|
|
559
|
-
}
|
|
560
|
-
if (entry.id !== id) {
|
|
561
|
-
throw new Error(
|
|
562
|
-
`playbooks.${id} key must equal the module manifest id "${entry.id}"`,
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
if (seenIds.has(entry.id)) {
|
|
566
|
-
throw new Error(`duplicate playbook id "${entry.id}"`);
|
|
567
|
-
}
|
|
568
|
-
seenIds.add(entry.id);
|
|
569
|
-
|
|
570
|
-
const command =
|
|
571
|
-
typeof block.command === 'string' && block.command.length > 0
|
|
572
|
-
? block.command
|
|
573
|
-
: entry.command;
|
|
574
|
-
if (command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
|
|
575
|
-
throw new Error(
|
|
576
|
-
`playbooks.${id}.command collides with the reserved internal Captain command`,
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
if (seenCommands.has(command)) {
|
|
580
|
-
throw new Error(`duplicate effective command "${command}"`);
|
|
581
|
-
}
|
|
582
|
-
seenCommands.set(command, id);
|
|
638
|
+
async function writeStream(stream, text, signal) {
|
|
639
|
+
throwIfSignalAborted(signal);
|
|
640
|
+
const ready = stream.write(text);
|
|
641
|
+
if (ready !== false || typeof stream.once !== 'function') return;
|
|
642
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
643
|
+
const cleanup = () => {
|
|
644
|
+
stream.off?.('drain', onDrain);
|
|
645
|
+
stream.off?.('error', onError);
|
|
646
|
+
signal?.removeEventListener('abort', onAbort);
|
|
647
|
+
};
|
|
648
|
+
const onDrain = () => {
|
|
649
|
+
cleanup();
|
|
650
|
+
resolvePromise();
|
|
651
|
+
};
|
|
652
|
+
const onError = (error) => {
|
|
653
|
+
cleanup();
|
|
654
|
+
rejectPromise(error);
|
|
655
|
+
};
|
|
656
|
+
const onAbort = () => {
|
|
657
|
+
cleanup();
|
|
658
|
+
rejectPromise(signal.reason ?? new Error('operation aborted'));
|
|
659
|
+
};
|
|
660
|
+
stream.once('drain', onDrain);
|
|
661
|
+
stream.once('error', onError);
|
|
662
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
663
|
+
if (signal?.aborted) onAbort();
|
|
664
|
+
});
|
|
665
|
+
}
|
|
583
666
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
throw new Error(
|
|
589
|
-
`playbooks.${id} requires local role "${RESERVED_CAPTAIN_ROLE_ID}", ` +
|
|
590
|
-
'which is reserved for the tmux-play Captain',
|
|
591
|
-
);
|
|
592
|
-
}
|
|
667
|
+
async function awaitManagedPreparation(start, signal) {
|
|
668
|
+
throwIfSignalAborted(signal);
|
|
669
|
+
const launch = Promise.resolve().then(start);
|
|
670
|
+
if (signal === undefined) return launch;
|
|
593
671
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
const generated = [];
|
|
614
|
-
for (const role of roles) {
|
|
615
|
-
const agent = resolveAgent(
|
|
616
|
-
playersMap[role],
|
|
617
|
-
`playbooks.${id}.players.${role}`,
|
|
618
|
-
);
|
|
619
|
-
if (agent.adapter === undefined) {
|
|
620
|
-
throw new Error(
|
|
621
|
-
`playbooks.${id}.players.${role} must resolve an adapter`,
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
const hostId = `${id}-${role}`;
|
|
625
|
-
roster.push({ id: hostId, ...agent });
|
|
626
|
-
generated.push(hostId);
|
|
627
|
-
}
|
|
628
|
-
if (firstVisible === undefined) firstVisible = generated;
|
|
672
|
+
let onAbort;
|
|
673
|
+
const aborted = new Promise((resolvePromise) => {
|
|
674
|
+
onAbort = () => resolvePromise({ type: 'aborted' });
|
|
675
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
676
|
+
if (signal.aborted) onAbort();
|
|
677
|
+
});
|
|
678
|
+
const outcome = await Promise.race([
|
|
679
|
+
launch.then(
|
|
680
|
+
(value) => ({ type: 'prepared', value }),
|
|
681
|
+
(error) => ({ type: 'failed', error }),
|
|
682
|
+
),
|
|
683
|
+
aborted,
|
|
684
|
+
]);
|
|
685
|
+
signal.removeEventListener('abort', onAbort);
|
|
686
|
+
if (outcome.type === 'prepared') return outcome.value;
|
|
687
|
+
if (outcome.type === 'failed') throw outcome.error;
|
|
629
688
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
: {}),
|
|
641
|
-
options: optionSlice,
|
|
642
|
-
};
|
|
643
|
-
listing.push({ id, command, intent: entry.intent });
|
|
689
|
+
const abortError = signal.reason ?? new Error('operation aborted');
|
|
690
|
+
let latePrepared;
|
|
691
|
+
try {
|
|
692
|
+
latePrepared = await launch;
|
|
693
|
+
} catch (launchError) {
|
|
694
|
+
throw aggregateOperationalFailures(
|
|
695
|
+
abortError,
|
|
696
|
+
launchError,
|
|
697
|
+
'managed tmux-play preparation failed while retiring an aborted launch',
|
|
698
|
+
);
|
|
644
699
|
}
|
|
645
|
-
|
|
646
|
-
// DR-013 A1: the shell cannot see its own captain's adapter through the
|
|
647
|
-
// tmux-play CaptainContext, so the launcher — which resolved it — passes it
|
|
648
|
-
// through. The shell needs it to decide whether an explicit empty tool
|
|
649
|
-
// allowlist can be enforced or must degrade to prompt-level restriction.
|
|
650
|
-
captain.options = {
|
|
651
|
-
playbooks: optionsPlaybooks,
|
|
652
|
-
...(typeof captain.adapter === 'string' && captain.adapter.length > 0
|
|
653
|
-
? { captainAdapter: captain.adapter }
|
|
654
|
-
: {}),
|
|
655
|
-
};
|
|
656
|
-
const config = { captain, players: roster };
|
|
657
|
-
// PBCLI-10: carry the user's tmux-play layout window/weight fields through;
|
|
658
|
-
// the launcher owns `layout.initialVisible` (first enabled playbook).
|
|
659
|
-
const layout = isObject(top.layout) ? { ...top.layout } : {};
|
|
660
|
-
layout.initialVisible = firstVisible;
|
|
661
|
-
config.layout = layout;
|
|
662
|
-
if (top.notifications !== undefined) config.notifications = top.notifications;
|
|
663
|
-
if (top.theme !== undefined) config.theme = top.theme;
|
|
664
|
-
return { config, playbooks: listing };
|
|
700
|
+
await cancelPreparedAfterFailure(latePrepared, abortError);
|
|
665
701
|
}
|
|
666
702
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
return [...adapters];
|
|
703
|
+
async function cancelPreparedIfAborted(prepared, signal) {
|
|
704
|
+
if (!signal?.aborted) return;
|
|
705
|
+
await cancelPreparedAfterFailure(
|
|
706
|
+
prepared,
|
|
707
|
+
signal.reason ?? new Error('operation aborted'),
|
|
708
|
+
);
|
|
674
709
|
}
|
|
675
710
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
if (adapter === 'claude') {
|
|
681
|
-
if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
|
|
682
|
-
failingAdapters.push(adapter);
|
|
683
|
-
}
|
|
684
|
-
continue;
|
|
685
|
-
}
|
|
686
|
-
if (adapter === 'codex') {
|
|
687
|
-
if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
|
|
688
|
-
failingAdapters.push(adapter);
|
|
689
|
-
}
|
|
690
|
-
continue;
|
|
711
|
+
async function cancelPreparedAfterFailure(prepared, primary) {
|
|
712
|
+
try {
|
|
713
|
+
if (typeof prepared?.cancel !== 'function') {
|
|
714
|
+
throw new Error('managed tmux-play preparation has no cancellation boundary');
|
|
691
715
|
}
|
|
692
|
-
|
|
716
|
+
await prepared.cancel();
|
|
717
|
+
} catch (cancelError) {
|
|
718
|
+
throw aggregateOperationalFailures(
|
|
719
|
+
primary,
|
|
720
|
+
cancelError,
|
|
721
|
+
`managed session operation failed (${errorMessage(primary)}) and cancellation could not prove ownership retirement`,
|
|
722
|
+
);
|
|
693
723
|
}
|
|
694
|
-
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function writeComposedConfig(composed) {
|
|
698
|
-
const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
|
|
699
|
-
const path = join(dir, 'tmux-play.config.yaml');
|
|
700
|
-
writeFileSync(path, stringifyYaml(composed));
|
|
701
|
-
return { dir, path };
|
|
724
|
+
throw primary;
|
|
702
725
|
}
|
|
703
726
|
|
|
704
|
-
function
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
727
|
+
function aggregateOperationalFailures(primary, secondary, summary) {
|
|
728
|
+
return new AggregateError(
|
|
729
|
+
[primary, secondary],
|
|
730
|
+
`${summary}: ${errorMessage(secondary)}`,
|
|
731
|
+
);
|
|
709
732
|
}
|
|
710
733
|
|
|
711
|
-
function
|
|
712
|
-
|
|
734
|
+
function throwIfSignalAborted(signal) {
|
|
735
|
+
if (signal?.aborted) {
|
|
736
|
+
throw signal.reason ?? new Error('operation aborted');
|
|
737
|
+
}
|
|
713
738
|
}
|
|
714
739
|
|
|
715
740
|
function helpText({
|
|
@@ -727,17 +752,35 @@ function helpText({
|
|
|
727
752
|
...sdkFailureLines,
|
|
728
753
|
...failures,
|
|
729
754
|
'Usage:',
|
|
730
|
-
' playbook [--
|
|
731
|
-
' playbook
|
|
732
|
-
' playbook
|
|
755
|
+
' playbook [--with <path>]... [--no-provision] [--cwd <path>]',
|
|
756
|
+
' playbook --session <id> [--with <path>]... [--no-provision]',
|
|
757
|
+
' playbook --list [--with <path>]... [--no-provision]',
|
|
758
|
+
' playbook --theme-diagnostics [--with <path>]... [--cwd <path>]',
|
|
759
|
+
' playbook --config <path> [tmux-play arguments...]',
|
|
760
|
+
' playbook run [--with <path>]... [--no-provision] [--json]',
|
|
761
|
+
' [--verbose] [--] [input]',
|
|
762
|
+
' playbook run (--continue | --session <id>) [reply]',
|
|
763
|
+
' playbook run --session <id> --retry-uncertain',
|
|
764
|
+
' playbook run --session <id> --discard-uncertain',
|
|
733
765
|
' playbook --help',
|
|
734
766
|
'',
|
|
735
767
|
`Default config: ${userConfigPath}`,
|
|
736
768
|
'',
|
|
769
|
+
' Only a fresh managed launch accepts --cwd. It creates a durable logical',
|
|
770
|
+
' Captain session and reports `playbook: session <id>` before attach.',
|
|
771
|
+
' Reopen that same session with `playbook --session <id>` or submit one',
|
|
772
|
+
' headless turn with `playbook run --session <id> [reply]`; selected',
|
|
773
|
+
' sessions always retain their stored working directory.',
|
|
737
774
|
' --with <path> overlays a top-level config fragment (same format as',
|
|
738
|
-
' the default config)
|
|
739
|
-
' maps merge recursively, other values replace, later files win
|
|
775
|
+
' the default config) for a fresh launch or compatible ordinary reopen —',
|
|
776
|
+
' maps merge recursively, other values replace, later files win, and the',
|
|
740
777
|
' default config file is never modified.',
|
|
778
|
+
' --no-provision keeps configured filesystem registries read-only;',
|
|
779
|
+
' any missing engine links remain a launch error.',
|
|
780
|
+
' `playbook run --verbose` prints Captain telemetry topics to stderr.',
|
|
781
|
+
' `playbook run --help` prints complete continuation and recovery usage.',
|
|
782
|
+
' Raw --config and --theme-diagnostics use cligent\'s stock tmux-play',
|
|
783
|
+
' process boundary and do not create or select a durable Captain session.',
|
|
741
784
|
'',
|
|
742
785
|
'Adapter setup:',
|
|
743
786
|
' claude: npm install -g @anthropic-ai/claude-agent-sdk, then run',
|
|
@@ -748,11 +791,20 @@ function helpText({
|
|
|
748
791
|
' vendors your config actually names.',
|
|
749
792
|
'',
|
|
750
793
|
'Agent swap recipe:',
|
|
751
|
-
' - set
|
|
752
|
-
'
|
|
753
|
-
'
|
|
754
|
-
'
|
|
755
|
-
'
|
|
794
|
+
' - set the top-level captain and each stable players.<id> to an',
|
|
795
|
+
' adapter shorthand (claude, codex) or an inline agent block',
|
|
796
|
+
' - bind every playbooks.<id>.roles.<role> explicitly to a player id;',
|
|
797
|
+
' a scalar names the id, while { player, model?, effort? } may retune',
|
|
798
|
+
' one role; boolean false selects the provider default explicitly',
|
|
799
|
+
' - reusing one id deliberately shares that provider conversation;',
|
|
800
|
+
' distinct ids stay isolated even when their agent blocks are equal',
|
|
801
|
+
' - the launcher injects captain.from and retains referenced player ids',
|
|
802
|
+
' verbatim',
|
|
803
|
+
'',
|
|
804
|
+
'Migration warning:',
|
|
805
|
+
' playbooks.<id>.players is removed and is not auto-migrated. Move each',
|
|
806
|
+
' agent to top-level players, choose ids for sharing or isolation, and',
|
|
807
|
+
' bind every local role under playbooks.<id>.roles.',
|
|
756
808
|
'',
|
|
757
809
|
].join('\n');
|
|
758
810
|
}
|
|
@@ -793,21 +845,6 @@ function resolveTmuxPlayBin() {
|
|
|
793
845
|
return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
|
|
794
846
|
}
|
|
795
847
|
|
|
796
|
-
function isObject(value) {
|
|
797
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
function hasOwn(value, key) {
|
|
801
|
-
return Object.prototype.hasOwnProperty.call(value, key);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
function requireObject(value, path) {
|
|
805
|
-
if (!isObject(value)) {
|
|
806
|
-
throw new Error(`${path} must be an object`);
|
|
807
|
-
}
|
|
808
|
-
return value;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
848
|
function errorMessage(error) {
|
|
812
849
|
return error instanceof Error ? error.message : String(error);
|
|
813
850
|
}
|
|
@@ -822,7 +859,8 @@ function isCliEntry(argv1 = process.argv[1], moduleUrl = import.meta.url) {
|
|
|
822
859
|
}
|
|
823
860
|
|
|
824
861
|
if (isCliEntry()) {
|
|
825
|
-
const result = await
|
|
862
|
+
const result = await runPlaybookCliEntry();
|
|
826
863
|
if (result.signal) process.kill(process.pid, result.signal);
|
|
827
|
-
|
|
864
|
+
// Let Node drain a long piped Captain reply or diagnostic naturally.
|
|
865
|
+
else process.exitCode = result.code ?? 0;
|
|
828
866
|
}
|