@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.
Files changed (61) hide show
  1. package/README.md +17 -4
  2. package/docs/cli.md +74 -29
  3. package/docs/configuration.md +209 -112
  4. package/docs/embedding.md +71 -25
  5. package/package.json +4 -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 +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  18. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  22. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  48. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +27 -23
  53. package/slc/link.md +113 -93
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +20 -16
  56. package/src/runtime.ts +19 -23
  57. package/src/xstate-playbook-runtime.d.ts +21 -17
  58. package/src/xstate-playbook-runtime.js +241 -149
  59. package/src/xstate-playbook-runtime.ts +331 -178
  60. package/src/xstate-runtime.js +63 -24
  61. 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 = await loadLaunchPlan({
173
- userConfigPath,
174
- overlayPaths: withPaths,
175
- loadModule,
176
- prepareRegistryModule:
177
- options.prepareRegistryModule ??
178
- prepareConfiguredRegistries({
179
- enabled: !noProvision,
180
- stderr,
181
- hostRoots: options.hostRoots,
182
- commandName: 'playbook',
183
- }),
184
- onNotice: (line) => stderr.write(line),
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 (argv.includes('--list')) {
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
- return await launchTmuxPlay(
242
- spawnFn,
243
- [tmuxPlayBin, '--config', composedPath, ...forwardArgv],
244
- 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,
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
- // PBCLI-23/24: the executable converts termination signals into an abort of
252
- // the active headless host, lets its uncertain marker and lease cleanup
253
- // finish, then asks the caller to re-raise the original signal.
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 ? result : { signal: receivedSignal };
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 [--list] [--with <path>]... [--no-provision]',
320
- ' [--config <path>] [tmux-play options]',
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) over the default config for a fresh launch only —',
332
- ' 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',
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 each agent inline: the top-level captain and every',
349
- ' playbooks.<id>.players.<role> takes an adapter shorthand',
350
- ' (claude, codex) or a block with adapter/model/effort/permissions',
351
- ' - the launcher injects captain.from and the namespaced <id>-<role>',
352
- ' 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.',
353
808
  '',
354
809
  ].join('\n');
355
810
  }