@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.
Files changed (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. 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
- parse as parseYaml,
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 one-shot path; it
62
- // never seeds, composes, resolves tmux-play, or launches it.
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
- // PBCLI-29: the run host reads the same user config as the launcher,
70
- // honoring any injected env, home, or explicit path.
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
- seedUserConfigIfMissing(userConfigPath, stderr);
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
- migrateUserConfigIfRetired(userConfigPath, stderr);
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
- let composed;
137
- try {
138
- let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
139
- if (withPaths.length > 0 && !isObject(top)) {
140
- throw new Error(
141
- `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
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
- for (const overlayPath of withPaths) {
145
- top = mergeConfigs(top, loadOverlayFragment(overlayPath));
146
- }
147
- composed = await composeGenericConfig(top, loadModule, userConfigPath);
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 (argv.includes('--list')) {
156
- for (const pb of composed.playbooks) {
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 reads the adapters of the composed config.
163
- const declaredAdapters = adaptersFromComposedConfig(composed.config);
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
- composed.config,
358
+ projectTmuxConfig(plan),
200
359
  );
360
+ let prepared;
201
361
  try {
202
- return await launchTmuxPlay(
203
- spawnFn,
204
- [tmuxPlayBin, '--config', composedPath, ...forwardArgv],
205
- stderr,
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
- // PBCLI-26: split `--with <path>` pairs out of the argument vector so
213
- // they are consumed by the launcher rather than forwarded to tmux-play.
214
- function extractWithFlags(argv) {
215
- const withPaths = [];
216
- const rest = [];
217
- for (let i = 0; i < argv.length; i += 1) {
218
- const arg = argv[i];
219
- if (arg === '--with') {
220
- const value = argv[i + 1];
221
- if (value === undefined || value === '') {
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
- // PBCLI-25: an overlay fragment is a top-level-format YAML map.
238
- function loadOverlayFragment(overlayPath) {
239
- const resolved = resolve(overlayPath);
240
- let text;
241
- try {
242
- text = readFileSync(resolved, 'utf8');
243
- } catch (error) {
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
- `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
499
+ `unsupported managed interactive option ${JSON.stringify(arg)}`,
246
500
  );
247
501
  }
248
- let fragment;
249
- try {
250
- fragment = parseYaml(text);
251
- } catch (error) {
502
+ if (sessionId !== undefined && cwd !== undefined) {
252
503
  throw new Error(
253
- `cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
504
+ 'interactive --cwd cannot combine with --session; the stored working directory is authoritative',
254
505
  );
255
506
  }
256
- if (!isObject(fragment)) {
257
- throw new Error(`--with overlay ${overlayPath} must be a YAML map`);
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
- let migrated;
307
- try {
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
- if (migrated === undefined) return;
317
- const backupPath = freeBackupPath(userConfigPath);
318
- writeFileSync(backupPath, text, { mode: 0o600 });
319
- writeFileSync(userConfigPath, migrated);
320
- stderr.write(
321
- `playbook: migrated ${userConfigPath} to inline agent settings ` +
322
- `(the top-level "profiles" map was removed in 3.0.0); ` +
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
- function freeBackupPath(userConfigPath) {
328
- const first = `${userConfigPath}.bak`;
329
- if (!existsSync(first)) return first;
330
- for (let n = 2; ; n += 1) {
331
- const candidate = `${userConfigPath}.bak.${n}`;
332
- if (!existsSync(candidate)) return candidate;
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
- // DR-021 §3: rewrite a config written for the retired profiles model in
337
- // place, inlining each agent's settings and keeping the original beside it.
338
- // Edits go through the YAML Document API so the user's comments survive;
339
- // only the profiles block and its own commentary are removed. Returns the
340
- // migrated text, or undefined when there is nothing to migrate.
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
- const profileSettings = (name) =>
360
- profiles && typeof profiles.get === 'function'
361
- ? profiles.get(name)
362
- : undefined;
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
- changed = true;
399
- }
551
+ receivedSignal = signal;
552
+ controller.abort(new Error(`received ${signal}`));
553
+ };
400
554
  }
401
-
402
- if (profiles !== undefined) {
403
- // The comment block above `profiles` usually carries the file's own
404
- // header, which must outlive the removed section: keep every paragraph
405
- // except the last, which documents profiles themselves.
406
- const index = contents.items.findIndex(
407
- (item) => String(item.key) === 'profiles',
408
- );
409
- const lead = index === -1 ? undefined : contents.items[index]?.key
410
- ?.commentBefore;
411
- doc.delete('profiles');
412
- const header = keptHeaderComment(lead);
413
- const next = contents.items[0];
414
- if (header !== undefined && next?.key) {
415
- next.key.commentBefore =
416
- next.key.commentBefore === undefined
417
- ? header
418
- : `${header}\n\n${next.key.commentBefore}`;
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
- const MIGRATION_NOTE =
430
- ' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
431
- ' each agent now carries its settings inline. The pre-migration file is\n' +
432
- ' kept beside this one as a .bak. Comments below may still describe the\n' +
433
- ' retired profiles model.';
434
-
435
- // Move a scalar agent's own comments onto the block that replaces it, so
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
- // Drop the trailing paragraph — the one describing the profiles block —
456
- // and keep the rest of the leading comment (SPDX header, file overview).
457
- function keptHeaderComment(comment) {
458
- if (typeof comment !== 'string' || comment.trim() === '') return undefined;
459
- const paragraphs = comment.split('\n\n');
460
- const kept = paragraphs.slice(0, -1).join('\n\n');
461
- return kept.trim() === '' ? undefined : kept;
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
- // A `profile` key that survives migration — introduced by a `--with`
465
- // overlay rather than the user's own config — is still rejected.
466
- function assertNoRetiredProfiles(top, configPath) {
467
- const where = configPath ? ` in ${configPath}` : '';
468
- if (top.profiles !== undefined) {
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
- `top-level "profiles" was removed${where}: write each agent's settings ` +
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
- const blocks = [['captain', top.captain]];
476
- const playbooksCfg = isObject(top.playbooks) ? top.playbooks : {};
477
- for (const [id, block] of Object.entries(playbooksCfg)) {
478
- const playersMap = isObject(block) && isObject(block.players)
479
- ? block.players
480
- : {};
481
- for (const [role, agent] of Object.entries(playersMap)) {
482
- blocks.push([`playbooks.${id}.players.${role}`, agent]);
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 isValidRegistryEntry(value) {
496
- if (!isObject(value)) return false;
623
+ function createInteractiveStore(options, env, home) {
497
624
  return (
498
- typeof value.id === 'string' &&
499
- typeof value.command === 'string' &&
500
- typeof value.intent === 'string' &&
501
- Array.isArray(value.requiredRoleIds) &&
502
- typeof value.validateOptions === 'function' &&
503
- typeof value.createRuntime === 'function'
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
- // PBCLI-8/9/10: normalize the top-level `playbooks` config into
508
- // a tmux-play config (Captain = the shell adapter; `captain.options.playbooks`
509
- // the normalized enablement; a launch-time namespaced `<id>-<role>` roster;
510
- // launcher-owned `layout.initialVisible`).
511
- export async function composeGenericConfig(top, loadModule, configPath) {
512
- assertNoRetiredProfiles(top, configPath);
513
-
514
- const playbooksCfg = requireObject(top.playbooks, 'playbooks');
515
- const ids = Object.keys(playbooksCfg);
516
- if (ids.length === 0) {
517
- throw new Error('playbooks must enable at least one playbook');
518
- }
519
-
520
- const captain = {
521
- from: PLAYBOOK_CAPTAIN_MODULE,
522
- ...resolveAgent(top.captain, 'captain'),
523
- };
524
- if (captain.adapter === undefined) {
525
- throw new Error('captain must resolve an adapter');
526
- }
527
-
528
- const optionsPlaybooks = {};
529
- const roster = [];
530
- const listing = [];
531
- const seenCommands = new Map();
532
- const seenIds = new Set();
533
- let firstVisible;
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
- // PBCLI-9: reject the reserved role before the coverage checks below, so
585
- // an entry requiring `captain` names the real fault rather than a missing
586
- // players entry.
587
- if (entry.requiredRoleIds.includes(RESERVED_CAPTAIN_ROLE_ID)) {
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
- const playersMap = requireObject(block.players, `playbooks.${id}.players`);
595
- const roles = Object.keys(playersMap);
596
- if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
597
- throw new Error(
598
- `playbooks.${id}.players.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
599
- `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
600
- 'tmux-play Captain',
601
- );
602
- }
603
- if (roles.length === 0) {
604
- throw new Error(`playbooks.${id} resolves no visible local role`);
605
- }
606
- for (const required of entry.requiredRoleIds) {
607
- if (!roles.includes(required)) {
608
- throw new Error(
609
- `playbooks.${id} required role "${required}" has no players entry`,
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
- const optionSlice = {};
631
- for (const key of Object.keys(block)) {
632
- if (!PLAYBOOK_LAUNCHER_KEYS.includes(key)) {
633
- optionSlice[key] = block[key];
634
- }
635
- }
636
- optionsPlaybooks[id] = {
637
- from,
638
- ...(typeof block.command === 'string' && block.command.length > 0
639
- ? { command: block.command }
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
- export function adaptersFromComposedConfig(config) {
668
- const adapters = new Set();
669
- if (config?.captain?.adapter) adapters.add(config.captain.adapter);
670
- for (const player of config?.players ?? []) {
671
- if (player?.adapter) adapters.add(player.adapter);
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
- export function checkReadiness(adapters, env = process.env, home = homedir()) {
677
- const failingAdapters = [];
678
- const unknownAdapters = [];
679
- for (const adapter of adapters) {
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
- unknownAdapters.push(adapter);
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
- return { failingAdapters, unknownAdapters };
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 seedUserConfigIfMissing(userConfigPath, stderr) {
705
- if (existsSync(userConfigPath)) return;
706
- mkdirSync(dirname(userConfigPath), { recursive: true });
707
- copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
708
- stderr.write(`playbook: created config at ${userConfigPath}\n`);
727
+ function aggregateOperationalFailures(primary, secondary, summary) {
728
+ return new AggregateError(
729
+ [primary, secondary],
730
+ `${summary}: ${errorMessage(secondary)}`,
731
+ );
709
732
  }
710
733
 
711
- function hasExplicitConfig(argv) {
712
- return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
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 [--list] [--with <path>]... [--config <path>] [tmux-play options]',
731
- ' playbook run <from> [task] [options] # non-interactive one-shot',
732
- ' playbook run resume <session-id> [reply] # answer a parked run',
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) over the default config for this launch only —',
739
- ' maps merge recursively, other values replace, later files win. The',
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 each agent inline: the top-level captain and every',
752
- ' playbooks.<id>.players.<role> takes an adapter shorthand',
753
- ' (claude, codex) or a block with adapter/model/effort/permissions',
754
- ' - the launcher injects captain.from and the namespaced <id>-<role>',
755
- ' host players',
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 runPlaybookCli();
862
+ const result = await runPlaybookCliEntry();
826
863
  if (result.signal) process.kill(process.pid, result.signal);
827
- else process.exit(result.code ?? 0);
864
+ // Let Node drain a long piped Captain reply or diagnostic naturally.
865
+ else process.exitCode = result.code ?? 0;
828
866
  }