@sublang/playbook 7.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 +17 -4
- package/docs/cli.md +74 -29
- package/docs/configuration.md +209 -112
- package/docs/embedding.md +71 -25
- package/package.json +4 -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 +1078 -116
- package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
- package/reference/sdlc/code.playbook/bin/run.js +283 -298
- package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
- 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 +39 -14
- package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
- package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
- 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 +171 -134
- package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
- 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 +113 -93
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +20 -16
- package/src/runtime.ts +19 -23
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +241 -149
- package/src/xstate-playbook-runtime.ts +331 -178
- package/src/xstate-runtime.js +63 -24
- package/src/xstate-runtime.ts +96 -28
|
@@ -3,6 +3,7 @@
|
|
|
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
8
|
mkdtempSync,
|
|
8
9
|
realpathSync,
|
|
@@ -10,8 +11,9 @@ import {
|
|
|
10
11
|
writeFileSync,
|
|
11
12
|
} from 'node:fs';
|
|
12
13
|
import { homedir, tmpdir } from 'node:os';
|
|
13
|
-
import { dirname, join } from 'node:path';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { launchManagedTmuxPlay } from '@sublang/cligent/tmux-play';
|
|
15
17
|
import { stringify as stringifyYaml } from 'yaml';
|
|
16
18
|
import {
|
|
17
19
|
adapterSdkFailureLines,
|
|
@@ -23,11 +25,26 @@ import {
|
|
|
23
25
|
adaptersFromLaunchPlan,
|
|
24
26
|
extractWithFlags,
|
|
25
27
|
loadLaunchPlan,
|
|
28
|
+
loadSelectedLaunchPlanDataOnly,
|
|
26
29
|
projectTmuxConfig,
|
|
27
30
|
resolveUserConfigPath,
|
|
28
31
|
checkReadiness,
|
|
29
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';
|
|
30
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';
|
|
31
48
|
|
|
32
49
|
// Preserve the established import surface while the CLI itself delegates to
|
|
33
50
|
// the host-neutral launch-config module.
|
|
@@ -164,25 +181,72 @@ export async function runPlaybookCli(options = {}) {
|
|
|
164
181
|
// PBCLI-1: explicit `--config <path>` launches that raw tmux-play config
|
|
165
182
|
// directly, bypassing seeding, composition, and the readiness gate.
|
|
166
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
|
+
}
|
|
167
190
|
return await launchTmuxPlay(spawnFn, [tmuxPlayBin, ...argv], stderr);
|
|
168
191
|
}
|
|
169
192
|
|
|
193
|
+
let interactiveArgs;
|
|
194
|
+
try {
|
|
195
|
+
interactiveArgs = parseInteractiveArgs(forwardArgv);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
198
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
199
|
+
}
|
|
200
|
+
|
|
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),
|
|
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 };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
170
227
|
let plan;
|
|
171
228
|
try {
|
|
172
|
-
plan =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
});
|
|
186
250
|
} catch (error) {
|
|
187
251
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
188
252
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -190,7 +254,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
190
254
|
|
|
191
255
|
// PBCLI-5: `--list` prints each configured playbook's id, effective
|
|
192
256
|
// command, and intent without launching tmux-play.
|
|
193
|
-
if (
|
|
257
|
+
if (interactiveArgs.list) {
|
|
194
258
|
for (const pb of Object.values(plan.catalog)) {
|
|
195
259
|
stdout.write(`/${pb.command} ${pb.id} — ${pb.intent}\n`);
|
|
196
260
|
}
|
|
@@ -234,31 +298,243 @@ export async function runPlaybookCli(options = {}) {
|
|
|
234
298
|
return { code: READINESS_FAILURE_EXIT_CODE };
|
|
235
299
|
}
|
|
236
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
|
+
|
|
237
357
|
const { dir: tempDir, path: composedPath } = writeComposedConfig(
|
|
238
358
|
projectTmuxConfig(plan),
|
|
239
359
|
);
|
|
360
|
+
let prepared;
|
|
240
361
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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,
|
|
245
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 };
|
|
246
426
|
} finally {
|
|
247
427
|
rmSync(tempDir, { recursive: true, force: true });
|
|
248
428
|
}
|
|
249
429
|
}
|
|
250
430
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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');
|
|
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
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
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
|
+
}
|
|
498
|
+
throw new Error(
|
|
499
|
+
`unsupported managed interactive option ${JSON.stringify(arg)}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (sessionId !== undefined && cwd !== undefined) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
'interactive --cwd cannot combine with --session; the stored working directory is authoritative',
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (sessionId !== undefined && list) {
|
|
508
|
+
throw new Error('--session cannot combine with --list');
|
|
509
|
+
}
|
|
510
|
+
if (list && cwd !== undefined) {
|
|
511
|
+
throw new Error('--cwd applies to a fresh launch and cannot combine with --list');
|
|
512
|
+
}
|
|
513
|
+
return Object.freeze({
|
|
514
|
+
sessionId,
|
|
515
|
+
cwd,
|
|
516
|
+
list,
|
|
517
|
+
themeDiagnostics: false,
|
|
518
|
+
diagnosticArgv: Object.freeze([]),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
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.
|
|
254
529
|
export async function runPlaybookCliEntry(options = {}) {
|
|
255
530
|
const processLike = options.processLike ?? process;
|
|
256
531
|
const entryArgv = options.argv ?? processLike.argv?.slice(2) ?? [];
|
|
257
|
-
if (entryArgv[0] !== 'run') {
|
|
532
|
+
if (entryArgv[0] !== 'run' && !isManagedInteractiveInvocation(entryArgv)) {
|
|
258
533
|
return runPlaybookCli(options);
|
|
259
534
|
}
|
|
260
535
|
const controller = new AbortController();
|
|
261
536
|
let receivedSignal;
|
|
537
|
+
let signalOwnershipTransferred = false;
|
|
262
538
|
const handlers = {};
|
|
263
539
|
const removeHandlers = () => {
|
|
264
540
|
for (const [signal, handler] of Object.entries(handlers)) {
|
|
@@ -283,13 +559,30 @@ export async function runPlaybookCliEntry(options = {}) {
|
|
|
283
559
|
const result = await runPlaybookCli({
|
|
284
560
|
...options,
|
|
285
561
|
signal: controller.signal,
|
|
562
|
+
onBeforeManagedAttach: () => {
|
|
563
|
+
signalOwnershipTransferred = true;
|
|
564
|
+
removeHandlers();
|
|
565
|
+
options.onBeforeManagedAttach?.();
|
|
566
|
+
},
|
|
286
567
|
});
|
|
287
|
-
return receivedSignal === undefined
|
|
568
|
+
return receivedSignal === undefined || signalOwnershipTransferred
|
|
569
|
+
? result
|
|
570
|
+
: { signal: receivedSignal };
|
|
288
571
|
} finally {
|
|
289
572
|
removeHandlers();
|
|
290
573
|
}
|
|
291
574
|
}
|
|
292
575
|
|
|
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)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
293
586
|
function writeComposedConfig(composed) {
|
|
294
587
|
const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
|
|
295
588
|
const path = join(dir, 'tmux-play.config.yaml');
|
|
@@ -301,6 +594,149 @@ function hasExplicitConfig(argv) {
|
|
|
301
594
|
return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
|
|
302
595
|
}
|
|
303
596
|
|
|
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) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`${managed} selects a managed Captain session and cannot combine with a raw --config launch`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
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`);
|
|
619
|
+
}
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function createInteractiveStore(options, env, home) {
|
|
624
|
+
return (
|
|
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
|
+
})
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
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
|
+
}
|
|
666
|
+
|
|
667
|
+
async function awaitManagedPreparation(start, signal) {
|
|
668
|
+
throwIfSignalAborted(signal);
|
|
669
|
+
const launch = Promise.resolve().then(start);
|
|
670
|
+
if (signal === undefined) return launch;
|
|
671
|
+
|
|
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;
|
|
688
|
+
|
|
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
|
+
);
|
|
699
|
+
}
|
|
700
|
+
await cancelPreparedAfterFailure(latePrepared, abortError);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function cancelPreparedIfAborted(prepared, signal) {
|
|
704
|
+
if (!signal?.aborted) return;
|
|
705
|
+
await cancelPreparedAfterFailure(
|
|
706
|
+
prepared,
|
|
707
|
+
signal.reason ?? new Error('operation aborted'),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
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');
|
|
715
|
+
}
|
|
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
|
+
);
|
|
723
|
+
}
|
|
724
|
+
throw primary;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function aggregateOperationalFailures(primary, secondary, summary) {
|
|
728
|
+
return new AggregateError(
|
|
729
|
+
[primary, secondary],
|
|
730
|
+
`${summary}: ${errorMessage(secondary)}`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function throwIfSignalAborted(signal) {
|
|
735
|
+
if (signal?.aborted) {
|
|
736
|
+
throw signal.reason ?? new Error('operation aborted');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
304
740
|
function helpText({
|
|
305
741
|
userConfigPath,
|
|
306
742
|
failingAdapters = [],
|
|
@@ -316,8 +752,11 @@ function helpText({
|
|
|
316
752
|
...sdkFailureLines,
|
|
317
753
|
...failures,
|
|
318
754
|
'Usage:',
|
|
319
|
-
' playbook [--
|
|
320
|
-
'
|
|
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...]',
|
|
321
760
|
' playbook run [--with <path>]... [--no-provision] [--json]',
|
|
322
761
|
' [--verbose] [--] [input]',
|
|
323
762
|
' playbook run (--continue | --session <id>) [reply]',
|
|
@@ -327,14 +766,21 @@ function helpText({
|
|
|
327
766
|
'',
|
|
328
767
|
`Default config: ${userConfigPath}`,
|
|
329
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.',
|
|
330
774
|
' --with <path> overlays a top-level config fragment (same format as',
|
|
331
|
-
' the default config)
|
|
332
|
-
' 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',
|
|
333
777
|
' default config file is never modified.',
|
|
334
778
|
' --no-provision keeps configured filesystem registries read-only;',
|
|
335
779
|
' any missing engine links remain a launch error.',
|
|
336
780
|
' `playbook run --verbose` prints Captain telemetry topics to stderr.',
|
|
337
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.',
|
|
338
784
|
'',
|
|
339
785
|
'Adapter setup:',
|
|
340
786
|
' claude: npm install -g @anthropic-ai/claude-agent-sdk, then run',
|
|
@@ -345,11 +791,20 @@ function helpText({
|
|
|
345
791
|
' vendors your config actually names.',
|
|
346
792
|
'',
|
|
347
793
|
'Agent swap recipe:',
|
|
348
|
-
' - set
|
|
349
|
-
'
|
|
350
|
-
'
|
|
351
|
-
'
|
|
352
|
-
'
|
|
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.',
|
|
353
808
|
'',
|
|
354
809
|
].join('\n');
|
|
355
810
|
}
|