@sublang/playbook 10.0.0 → 11.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.
@@ -2,50 +2,47 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
4
4
 
5
- import { spawn } from 'node:child_process';
6
- import { randomUUID } from 'node:crypto';
7
- import {
8
- mkdtempSync,
9
- realpathSync,
10
- rmSync,
11
- writeFileSync,
12
- } from 'node:fs';
13
- import { homedir, tmpdir } from 'node:os';
14
- import { dirname, join, resolve } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { launchManagedTmuxPlay } from '@sublang/cligent/tmux-play';
17
- import { stringify as stringifyYaml } from 'yaml';
5
+ import { spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { launchManagedTmuxPlay } from "@sublang/cligent/tmux-play";
12
+ import { stringify as stringifyYaml } from "yaml";
18
13
  import {
19
14
  adapterSdkFailureLines,
20
15
  checkAdapterSdks,
21
16
  mappedSdksFor,
22
17
  probeAdapterSdk,
23
- } from './adapter-sdk.js';
18
+ } from "./adapter-sdk.js";
24
19
  import {
25
20
  adaptersFromLaunchPlan,
26
21
  extractWithFlags,
27
22
  loadLaunchPlan,
28
23
  loadSelectedLaunchPlanDataOnly,
29
24
  projectTmuxConfig,
25
+ resolveLaunchSessionsDir,
26
+ relocateLegacyUserConfig,
27
+ resolveLegacyUserConfigPath,
30
28
  resolveUserConfigPath,
31
29
  checkReadiness,
32
- } from './launch-config.js';
30
+ } from "./launch-config.js";
33
31
  import {
34
32
  createManagedInteractiveSessionCommand,
35
33
  MANAGED_INTERACTIVE_PAYLOAD_KIND,
36
34
  MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
37
35
  publishManagedInteractiveReadinessWitness,
38
- } from './interactive-session.js';
39
- import { prepareConfiguredRegistries } from './provision.js';
40
- import {
41
- executionConfigFromPlan,
42
- } from './run.js';
36
+ } from "./interactive-session.js";
37
+ import { prepareConfiguredRegistries } from "./provision.js";
38
+ import { executionConfigFromPlan } from "./run.js";
43
39
  import {
44
40
  assertCaptainSessionExecutionCompatible,
41
+ assertCaptainSessionsDirectoryUsable,
45
42
  createCaptainSessionStore,
46
43
  SESSION_ID_PATTERN,
47
44
  validateCaptainSessionRecord,
48
- } from './session-store.js';
45
+ } from "./session-store.js";
49
46
 
50
47
  // Preserve the established import surface while the CLI itself delegates to
51
48
  // the host-neutral launch-config module.
@@ -65,9 +62,8 @@ export {
65
62
  normalizeLaunchPlan,
66
63
  projectTmuxConfig,
67
64
  resolveAgent,
68
- resolveConfigHome,
69
65
  resolveUserConfigPath,
70
- } from './launch-config.js';
66
+ } from "./launch-config.js";
71
67
 
72
68
  const READINESS_FAILURE_EXIT_CODE = 2;
73
69
  const COMPOSITION_FAILURE_EXIT_CODE = 1;
@@ -78,22 +74,26 @@ export async function runPlaybookCli(options = {}) {
78
74
  const stdout = options.stdout ?? process.stdout;
79
75
  const stderr = options.stderr ?? process.stderr;
80
76
  const loadModule = options.loadModule ?? ((specifier) => import(specifier));
81
- const home = options.homeDir ?? env.HOME ?? homedir();
77
+ const home =
78
+ options.homeDir ??
79
+ (typeof env.HOME === "string" && env.HOME.trim().length > 0
80
+ ? env.HOME
81
+ : homedir());
82
82
  const userConfigPath =
83
83
  options.userConfigPath ?? resolveUserConfigPath(env, home);
84
84
 
85
85
  // PBCLI-18: `playbook run ...` is the non-interactive presentation of the
86
86
  // same generic-config Captain session. It never resolves or launches the
87
87
  // tmux presenter, but it receives the launch inputs shared with this host.
88
- if (argv[0] === 'run') {
89
- const { runPlaybookRun } = await import('./run.js');
88
+ if (argv[0] === "run") {
89
+ const { runPlaybookRun } = await import("./run.js");
90
90
  return await runPlaybookRun({
91
91
  argv: argv.slice(1),
92
92
  stdout,
93
93
  stderr,
94
94
  env,
95
95
  homeDir: home,
96
- userConfigPath,
96
+ ...(options.userConfigPath === undefined ? {} : { userConfigPath }),
97
97
  ...(options.cwd ? { cwd: options.cwd } : {}),
98
98
  ...(options.loadModule ? { loadModule: options.loadModule } : {}),
99
99
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
@@ -118,13 +118,10 @@ export async function runPlaybookCli(options = {}) {
118
118
  : {}),
119
119
  ...(options.createEffectLedgerWriteAhead
120
120
  ? {
121
- createEffectLedgerWriteAhead:
122
- options.createEffectLedgerWriteAhead,
121
+ createEffectLedgerWriteAhead: options.createEffectLedgerWriteAhead,
123
122
  }
124
123
  : {}),
125
- ...(options.sessionStore
126
- ? { sessionStore: options.sessionStore }
127
- : {}),
124
+ ...(options.sessionStore ? { sessionStore: options.sessionStore } : {}),
128
125
  ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
129
126
  ...(options.now ? { now: options.now } : {}),
130
127
  ...(options.createSessionTempId
@@ -158,7 +155,7 @@ export async function runPlaybookCli(options = {}) {
158
155
 
159
156
  // PBCLI-6: `--help` / `-h` print help and exit 0 without seeding,
160
157
  // composing, or launching.
161
- if (argv.includes('--help') || argv.includes('-h')) {
158
+ if (argv.includes("--help") || argv.includes("-h")) {
162
159
  stdout.write(helpText({ userConfigPath }));
163
160
  return { code: 0 };
164
161
  }
@@ -176,17 +173,17 @@ export async function runPlaybookCli(options = {}) {
176
173
  }
177
174
  if (withPaths.length > 0 && hasExplicitConfig(argv)) {
178
175
  stderr.write(
179
- 'playbook: --with overlays the top-level config and cannot combine ' +
180
- 'with a raw --config launch\n',
176
+ "playbook: --with overlays the top-level config and cannot combine " +
177
+ "with a raw --config launch\n",
181
178
  );
182
179
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
183
180
  }
184
- const noProvision = forwardArgv.includes('--no-provision');
185
- forwardArgv = forwardArgv.filter((arg) => arg !== '--no-provision');
181
+ const noProvision = forwardArgv.includes("--no-provision");
182
+ forwardArgv = forwardArgv.filter((arg) => arg !== "--no-provision");
186
183
  if (noProvision && hasExplicitConfig(argv)) {
187
184
  stderr.write(
188
- 'playbook: --no-provision applies to configured registry preparation ' +
189
- 'and cannot combine with a raw --config launch\n',
185
+ "playbook: --no-provision applies to configured registry preparation " +
186
+ "and cannot combine with a raw --config launch\n",
190
187
  );
191
188
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
192
189
  }
@@ -211,10 +208,49 @@ export async function runPlaybookCli(options = {}) {
211
208
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
212
209
  }
213
210
 
211
+ // DR-043: only a validated managed launch may relocate the legacy config.
212
+ // Help and raw --config return above without changing either config path;
213
+ // the delegated `run` front end applies the same boundary independently.
214
+ if (options.userConfigPath === undefined) {
215
+ try {
216
+ relocateLegacyUserConfig(
217
+ userConfigPath,
218
+ resolveLegacyUserConfigPath(env, home),
219
+ (line) => stderr.write(line),
220
+ );
221
+ } catch (error) {
222
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
223
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
224
+ }
225
+ }
226
+
214
227
  const launchCwd = resolve(
215
228
  options.cwd ?? process.cwd(),
216
- interactiveArgs.cwd ?? '.',
229
+ interactiveArgs.cwd ?? ".",
217
230
  );
231
+ let resolvedSessionsDir;
232
+ if (options.sessionStore === undefined) {
233
+ try {
234
+ resolvedSessionsDir = resolveLaunchSessionsDir({
235
+ userConfigPath,
236
+ overlayPaths: withPaths,
237
+ env,
238
+ homeDir: home,
239
+ ...(options.sessionsDir !== undefined
240
+ ? { sessionsDir: options.sessionsDir }
241
+ : {}),
242
+ preparePrimary: interactiveArgs.sessionId === undefined,
243
+ onNotice: (line) => stderr.write(line),
244
+ });
245
+ // PBCLI-78: listing validates the locator but never consumes the store.
246
+ if (!interactiveArgs.list) {
247
+ await assertCaptainSessionsDirectoryUsable(resolvedSessionsDir);
248
+ }
249
+ } catch (error) {
250
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
251
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
252
+ }
253
+ }
218
254
  // PBCLI-49: selected planning is deliberately provisional. It narrows the
219
255
  // current config before preparation; the pane child later acquires the
220
256
  // lease and repeats the authoritative read before any host/import work.
@@ -222,16 +258,16 @@ export async function runPlaybookCli(options = {}) {
222
258
  let selectedRecord;
223
259
  if (interactiveArgs.sessionId !== undefined) {
224
260
  try {
225
- store = createInteractiveStore(options, env, home);
261
+ store = createInteractiveStore(options, env, home, resolvedSessionsDir);
226
262
  selectedRecord = validateCaptainSessionRecord(
227
263
  await store.read(interactiveArgs.sessionId),
228
264
  );
229
265
  const needsAbandonmentRecovery =
230
- (selectedRecord.state === 'uncertain' &&
231
- Object.hasOwn(selectedRecord.uncertain, 'abandonment')) ||
232
- (selectedRecord.state === 'settled' &&
233
- Object.hasOwn(selectedRecord, 'settledAbandonment'));
234
- if (selectedRecord.state !== 'settled' && !needsAbandonmentRecovery) {
266
+ (selectedRecord.state === "uncertain" &&
267
+ Object.hasOwn(selectedRecord.uncertain, "abandonment")) ||
268
+ (selectedRecord.state === "settled" &&
269
+ Object.hasOwn(selectedRecord, "settledAbandonment"));
270
+ if (selectedRecord.state !== "settled" && !needsAbandonmentRecovery) {
235
271
  throw new Error(
236
272
  `Captain session ${JSON.stringify(interactiveArgs.sessionId)} has an uncertain turn; recover it with playbook run before reopening interactively`,
237
273
  );
@@ -253,13 +289,13 @@ export async function runPlaybookCli(options = {}) {
253
289
  throw aggregateOperationalFailures(
254
290
  recoveryError,
255
291
  error,
256
- 'Captain session abandonment recovery failed and its lease could not be released',
292
+ "Captain session abandonment recovery failed and its lease could not be released",
257
293
  );
258
294
  }
259
295
  throw error;
260
296
  }
261
297
  if (recoveryError !== undefined) throw recoveryError;
262
- if (selectedRecord.state !== 'settled') {
298
+ if (selectedRecord.state !== "settled") {
263
299
  throw new Error(
264
300
  `Captain session ${JSON.stringify(interactiveArgs.sessionId)} abandonment recovery did not settle its turn`,
265
301
  );
@@ -290,7 +326,7 @@ export async function runPlaybookCli(options = {}) {
290
326
  enabled: !noProvision,
291
327
  stderr,
292
328
  hostRoots: options.hostRoots,
293
- commandName: 'playbook',
329
+ commandName: "playbook",
294
330
  }),
295
331
  onNotice: (line) => stderr.write(line),
296
332
  });
@@ -357,7 +393,7 @@ export async function runPlaybookCli(options = {}) {
357
393
  spawnFn,
358
394
  [
359
395
  tmuxPlayBin,
360
- '--config',
396
+ "--config",
361
397
  composedPath,
362
398
  ...interactiveArgs.diagnosticArgv,
363
399
  ],
@@ -369,7 +405,7 @@ export async function runPlaybookCli(options = {}) {
369
405
  }
370
406
 
371
407
  try {
372
- store ??= createInteractiveStore(options, env, home);
408
+ store ??= createInteractiveStore(options, env, home, resolvedSessionsDir);
373
409
  } catch (error) {
374
410
  stderr.write(`playbook: ${errorMessage(error)}\n`);
375
411
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -388,7 +424,10 @@ export async function runPlaybookCli(options = {}) {
388
424
  );
389
425
  } else {
390
426
  sessionId = (options.createLogicalSessionId ?? randomUUID)();
391
- if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId)) {
427
+ if (
428
+ typeof sessionId !== "string" ||
429
+ !SESSION_ID_PATTERN.test(sessionId)
430
+ ) {
392
431
  throw new Error(
393
432
  `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
394
433
  );
@@ -423,7 +462,7 @@ export async function runPlaybookCli(options = {}) {
423
462
  createSessionCommand: (context) => {
424
463
  if (managedChildWorkDir !== undefined) {
425
464
  throw new Error(
426
- 'managed tmux-play requested more than one session command',
465
+ "managed tmux-play requested more than one session command",
427
466
  );
428
467
  }
429
468
  managedChildWorkDir = context.workDir;
@@ -432,7 +471,7 @@ export async function runPlaybookCli(options = {}) {
432
471
  {
433
472
  schemaVersion: MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
434
473
  kind: MANAGED_INTERACTIVE_PAYLOAD_KIND,
435
- mode: selectedRecord ? 'selected' : 'fresh',
474
+ mode: selectedRecord ? "selected" : "fresh",
436
475
  sessionId,
437
476
  cwd,
438
477
  sessionsDir: store.sessionsDir,
@@ -443,7 +482,7 @@ export async function runPlaybookCli(options = {}) {
443
482
  selfBin:
444
483
  options.managedSessionBin ??
445
484
  fileURLToPath(
446
- new URL('./interactive-session.js', import.meta.url),
485
+ new URL("./interactive-session.js", import.meta.url),
447
486
  ),
448
487
  ...(options.execPath ? { execPath: options.execPath } : {}),
449
488
  },
@@ -455,19 +494,19 @@ export async function runPlaybookCli(options = {}) {
455
494
  if (prepared?.sessionId !== sessionId) {
456
495
  await cancelPreparedAfterFailure(
457
496
  prepared,
458
- new Error('managed tmux-play prepared a mismatched session id'),
497
+ new Error("managed tmux-play prepared a mismatched session id"),
459
498
  );
460
499
  }
461
500
  if (managedChildWorkDir === undefined) {
462
501
  await cancelPreparedAfterFailure(
463
502
  prepared,
464
- new Error('managed tmux-play did not request a session command'),
503
+ new Error("managed tmux-play did not request a session command"),
465
504
  );
466
505
  }
467
506
  if (prepared?.workDir !== managedChildWorkDir) {
468
507
  await cancelPreparedAfterFailure(
469
508
  prepared,
470
- new Error('managed tmux-play prepared a mismatched work directory'),
509
+ new Error("managed tmux-play prepared a mismatched work directory"),
471
510
  );
472
511
  }
473
512
  if (!selectedRecord) {
@@ -498,7 +537,9 @@ export async function runPlaybookCli(options = {}) {
498
537
  });
499
538
  return { code: 0 };
500
539
  } catch (error) {
501
- stderr.write(`playbook: failed to launch managed session: ${errorMessage(error)}\n`);
540
+ stderr.write(
541
+ `playbook: failed to launch managed session: ${errorMessage(error)}\n`,
542
+ );
502
543
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
503
544
  } finally {
504
545
  rmSync(tempDir, { recursive: true, force: true });
@@ -506,21 +547,23 @@ export async function runPlaybookCli(options = {}) {
506
547
  }
507
548
 
508
549
  export function parseInteractiveArgs(argv) {
509
- if (argv.includes('--theme-diagnostics')) {
510
- if (argv.filter((arg) => arg === '--theme-diagnostics').length > 1) {
511
- throw new Error('--theme-diagnostics was repeated');
550
+ if (argv.includes("--theme-diagnostics")) {
551
+ if (argv.filter((arg) => arg === "--theme-diagnostics").length > 1) {
552
+ throw new Error("--theme-diagnostics was repeated");
512
553
  }
513
- if (argv.some((arg) => arg === '--session' || arg.startsWith('--session='))) {
514
- throw new Error('--session cannot combine with --theme-diagnostics');
554
+ if (
555
+ argv.some((arg) => arg === "--session" || arg.startsWith("--session="))
556
+ ) {
557
+ throw new Error("--session cannot combine with --theme-diagnostics");
515
558
  }
516
- if (argv.includes('--list')) {
517
- throw new Error('--list cannot combine with --theme-diagnostics');
559
+ if (argv.includes("--list")) {
560
+ throw new Error("--list cannot combine with --theme-diagnostics");
518
561
  }
519
562
  const recoveryArg = argv.find(
520
563
  (arg) =>
521
- arg === '--continue' ||
522
- arg === '--retry-uncertain' ||
523
- arg === '--discard-uncertain',
564
+ arg === "--continue" ||
565
+ arg === "--retry-uncertain" ||
566
+ arg === "--discard-uncertain",
524
567
  );
525
568
  if (recoveryArg !== undefined) {
526
569
  throw new Error(
@@ -541,32 +584,34 @@ export function parseInteractiveArgs(argv) {
541
584
  let list = false;
542
585
  for (let index = 0; index < argv.length; index += 1) {
543
586
  const arg = argv[index];
544
- if (arg === '--list') {
545
- if (list) throw new Error('--list was repeated');
587
+ if (arg === "--list") {
588
+ if (list) throw new Error("--list was repeated");
546
589
  list = true;
547
590
  continue;
548
591
  }
549
- if (arg === '--session' || arg.startsWith('--session=')) {
592
+ if (arg === "--session" || arg.startsWith("--session=")) {
550
593
  if (sessionId !== undefined) {
551
- throw new Error('interactive --session selector was repeated or combined');
594
+ throw new Error(
595
+ "interactive --session selector was repeated or combined",
596
+ );
552
597
  }
553
- sessionId = optionValue(argv, index, '--session');
554
- if (arg === '--session') index += 1;
598
+ sessionId = optionValue(argv, index, "--session");
599
+ if (arg === "--session") index += 1;
555
600
  if (!SESSION_ID_PATTERN.test(sessionId)) {
556
- throw new Error('--session requires a canonical lowercase UUID');
601
+ throw new Error("--session requires a canonical lowercase UUID");
557
602
  }
558
603
  continue;
559
604
  }
560
- if (arg === '--cwd' || arg.startsWith('--cwd=')) {
561
- if (cwd !== undefined) throw new Error('interactive --cwd was repeated');
562
- cwd = optionValue(argv, index, '--cwd');
563
- if (arg === '--cwd') index += 1;
605
+ if (arg === "--cwd" || arg.startsWith("--cwd=")) {
606
+ if (cwd !== undefined) throw new Error("interactive --cwd was repeated");
607
+ cwd = optionValue(argv, index, "--cwd");
608
+ if (arg === "--cwd") index += 1;
564
609
  continue;
565
610
  }
566
611
  if (
567
- arg === '--continue' ||
568
- arg === '--retry-uncertain' ||
569
- arg === '--discard-uncertain'
612
+ arg === "--continue" ||
613
+ arg === "--retry-uncertain" ||
614
+ arg === "--discard-uncertain"
570
615
  ) {
571
616
  throw new Error(
572
617
  `${arg} is headless recovery syntax; use playbook run with an explicit session`,
@@ -578,14 +623,16 @@ export function parseInteractiveArgs(argv) {
578
623
  }
579
624
  if (sessionId !== undefined && cwd !== undefined) {
580
625
  throw new Error(
581
- 'interactive --cwd cannot combine with --session; the stored working directory is authoritative',
626
+ "interactive --cwd cannot combine with --session; the stored working directory is authoritative",
582
627
  );
583
628
  }
584
629
  if (sessionId !== undefined && list) {
585
- throw new Error('--session cannot combine with --list');
630
+ throw new Error("--session cannot combine with --list");
586
631
  }
587
632
  if (list && cwd !== undefined) {
588
- throw new Error('--cwd applies to a fresh launch and cannot combine with --list');
633
+ throw new Error(
634
+ "--cwd applies to a fresh launch and cannot combine with --list",
635
+ );
589
636
  }
590
637
  return Object.freeze({
591
638
  sessionId,
@@ -606,7 +653,7 @@ export function parseInteractiveArgs(argv) {
606
653
  export async function runPlaybookCliEntry(options = {}) {
607
654
  const processLike = options.processLike ?? process;
608
655
  const entryArgv = options.argv ?? processLike.argv?.slice(2) ?? [];
609
- if (entryArgv[0] !== 'run' && !isManagedInteractiveInvocation(entryArgv)) {
656
+ if (entryArgv[0] !== "run" && !isManagedInteractiveInvocation(entryArgv)) {
610
657
  return runPlaybookCli(options);
611
658
  }
612
659
  const controller = new AbortController();
@@ -618,7 +665,7 @@ export async function runPlaybookCliEntry(options = {}) {
618
665
  processLike.off(signal, handler);
619
666
  }
620
667
  };
621
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
668
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
622
669
  handlers[signal] = () => {
623
670
  if (receivedSignal !== undefined) {
624
671
  removeHandlers();
@@ -652,33 +699,33 @@ export async function runPlaybookCliEntry(options = {}) {
652
699
 
653
700
  function isManagedInteractiveInvocation(argv) {
654
701
  return (
655
- !argv.includes('--help') &&
656
- !argv.includes('-h') &&
657
- !argv.includes('--list') &&
658
- !argv.includes('--theme-diagnostics') &&
702
+ !argv.includes("--help") &&
703
+ !argv.includes("-h") &&
704
+ !argv.includes("--list") &&
705
+ !argv.includes("--theme-diagnostics") &&
659
706
  !hasExplicitConfig(argv)
660
707
  );
661
708
  }
662
709
 
663
710
  function writeComposedConfig(composed) {
664
- const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
665
- const path = join(dir, 'tmux-play.config.yaml');
711
+ const dir = mkdtempSync(join(tmpdir(), "playbook-"));
712
+ const path = join(dir, "tmux-play.config.yaml");
666
713
  writeFileSync(path, stringifyYaml(composed));
667
714
  return { dir, path };
668
715
  }
669
716
 
670
717
  function hasExplicitConfig(argv) {
671
- return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
718
+ return argv.some((arg) => arg === "--config" || arg.startsWith("--config="));
672
719
  }
673
720
 
674
721
  function assertRawConfigHasNoManagedSelector(argv) {
675
722
  const managed = argv.find(
676
723
  (arg) =>
677
- arg === '--session' ||
678
- arg.startsWith('--session=') ||
679
- arg === '--continue' ||
680
- arg === '--retry-uncertain' ||
681
- arg === '--discard-uncertain',
724
+ arg === "--session" ||
725
+ arg.startsWith("--session=") ||
726
+ arg === "--continue" ||
727
+ arg === "--retry-uncertain" ||
728
+ arg === "--discard-uncertain",
682
729
  );
683
730
  if (managed !== undefined) {
684
731
  throw new Error(
@@ -689,21 +736,20 @@ function assertRawConfigHasNoManagedSelector(argv) {
689
736
 
690
737
  function optionValue(argv, index, name) {
691
738
  const arg = argv[index];
692
- const value =
693
- arg === name ? argv[index + 1] : arg.slice(`${name}=`.length);
694
- if (typeof value !== 'string' || value.length === 0) {
739
+ const value = arg === name ? argv[index + 1] : arg.slice(`${name}=`.length);
740
+ if (typeof value !== "string" || value.length === 0) {
695
741
  throw new Error(`${name} requires a value`);
696
742
  }
697
743
  return value;
698
744
  }
699
745
 
700
- function createInteractiveStore(options, env, home) {
746
+ function createInteractiveStore(options, env, home, sessionsDir) {
701
747
  return (
702
748
  options.sessionStore ??
703
749
  createCaptainSessionStore({
704
750
  env,
705
751
  homeDir: home,
706
- ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
752
+ sessionsDir,
707
753
  ...(options.now ? { now: options.now } : {}),
708
754
  ...(options.createSessionTempId
709
755
  ? { createTempId: options.createSessionTempId }
@@ -715,12 +761,12 @@ function createInteractiveStore(options, env, home) {
715
761
  async function writeStream(stream, text, signal) {
716
762
  throwIfSignalAborted(signal);
717
763
  const ready = stream.write(text);
718
- if (ready !== false || typeof stream.once !== 'function') return;
764
+ if (ready !== false || typeof stream.once !== "function") return;
719
765
  await new Promise((resolvePromise, rejectPromise) => {
720
766
  const cleanup = () => {
721
- stream.off?.('drain', onDrain);
722
- stream.off?.('error', onError);
723
- signal?.removeEventListener('abort', onAbort);
767
+ stream.off?.("drain", onDrain);
768
+ stream.off?.("error", onError);
769
+ signal?.removeEventListener("abort", onAbort);
724
770
  };
725
771
  const onDrain = () => {
726
772
  cleanup();
@@ -732,11 +778,11 @@ async function writeStream(stream, text, signal) {
732
778
  };
733
779
  const onAbort = () => {
734
780
  cleanup();
735
- rejectPromise(signal.reason ?? new Error('operation aborted'));
781
+ rejectPromise(signal.reason ?? new Error("operation aborted"));
736
782
  };
737
- stream.once('drain', onDrain);
738
- stream.once('error', onError);
739
- signal?.addEventListener('abort', onAbort, { once: true });
783
+ stream.once("drain", onDrain);
784
+ stream.once("error", onError);
785
+ signal?.addEventListener("abort", onAbort, { once: true });
740
786
  if (signal?.aborted) onAbort();
741
787
  });
742
788
  }
@@ -748,22 +794,22 @@ async function awaitManagedPreparation(start, signal) {
748
794
 
749
795
  let onAbort;
750
796
  const aborted = new Promise((resolvePromise) => {
751
- onAbort = () => resolvePromise({ type: 'aborted' });
752
- signal.addEventListener('abort', onAbort, { once: true });
797
+ onAbort = () => resolvePromise({ type: "aborted" });
798
+ signal.addEventListener("abort", onAbort, { once: true });
753
799
  if (signal.aborted) onAbort();
754
800
  });
755
801
  const outcome = await Promise.race([
756
802
  launch.then(
757
- (value) => ({ type: 'prepared', value }),
758
- (error) => ({ type: 'failed', error }),
803
+ (value) => ({ type: "prepared", value }),
804
+ (error) => ({ type: "failed", error }),
759
805
  ),
760
806
  aborted,
761
807
  ]);
762
- signal.removeEventListener('abort', onAbort);
763
- if (outcome.type === 'prepared') return outcome.value;
764
- if (outcome.type === 'failed') throw outcome.error;
808
+ signal.removeEventListener("abort", onAbort);
809
+ if (outcome.type === "prepared") return outcome.value;
810
+ if (outcome.type === "failed") throw outcome.error;
765
811
 
766
- const abortError = signal.reason ?? new Error('operation aborted');
812
+ const abortError = signal.reason ?? new Error("operation aborted");
767
813
  let latePrepared;
768
814
  try {
769
815
  latePrepared = await launch;
@@ -771,7 +817,7 @@ async function awaitManagedPreparation(start, signal) {
771
817
  throw aggregateOperationalFailures(
772
818
  abortError,
773
819
  launchError,
774
- 'managed tmux-play preparation failed while retiring an aborted launch',
820
+ "managed tmux-play preparation failed while retiring an aborted launch",
775
821
  );
776
822
  }
777
823
  await cancelPreparedAfterFailure(latePrepared, abortError);
@@ -781,14 +827,16 @@ async function cancelPreparedIfAborted(prepared, signal) {
781
827
  if (!signal?.aborted) return;
782
828
  await cancelPreparedAfterFailure(
783
829
  prepared,
784
- signal.reason ?? new Error('operation aborted'),
830
+ signal.reason ?? new Error("operation aborted"),
785
831
  );
786
832
  }
787
833
 
788
834
  async function cancelPreparedAfterFailure(prepared, primary) {
789
835
  try {
790
- if (typeof prepared?.cancel !== 'function') {
791
- throw new Error('managed tmux-play preparation has no cancellation boundary');
836
+ if (typeof prepared?.cancel !== "function") {
837
+ throw new Error(
838
+ "managed tmux-play preparation has no cancellation boundary",
839
+ );
792
840
  }
793
841
  await prepared.cancel();
794
842
  } catch (cancelError) {
@@ -810,7 +858,7 @@ function aggregateOperationalFailures(primary, secondary, summary) {
810
858
 
811
859
  function throwIfSignalAborted(signal) {
812
860
  if (signal?.aborted) {
813
- throw signal.reason ?? new Error('operation aborted');
861
+ throw signal.reason ?? new Error("operation aborted");
814
862
  }
815
863
  }
816
864
 
@@ -821,76 +869,77 @@ function helpText({
821
869
  }) {
822
870
  const failures =
823
871
  failingAdapters.length > 0
824
- ? [`Adapters not ready: ${failingAdapters.join(', ')}`, '']
872
+ ? [`Adapters not ready: ${failingAdapters.join(", ")}`, ""]
825
873
  : [];
826
874
  return [
827
875
  // PBCLI-40: the SDK remedy leads, because an unusable adapter cannot be
828
876
  // fixed by the credential advice further down.
829
877
  ...sdkFailureLines,
830
878
  ...failures,
831
- 'Usage:',
832
- ' playbook [--with <path>]... [--no-provision] [--cwd <path>]',
833
- ' playbook --session <id> [--with <path>]... [--no-provision]',
834
- ' playbook --list [--with <path>]... [--no-provision]',
835
- ' playbook --theme-diagnostics [--with <path>]... [--cwd <path>]',
836
- ' playbook --config <path> [tmux-play arguments...]',
837
- ' playbook run [--with <path>]... [--no-provision] [--json]',
838
- ' [--verbose] [--] [input]',
839
- ' playbook run (--continue | --session <id>) [reply]',
840
- ' playbook run --session <id> --retry-uncertain',
841
- ' playbook run --session <id> --discard-uncertain',
842
- ' playbook --help',
843
- '',
879
+ "Usage:",
880
+ " playbook [--with <path>]... [--no-provision] [--cwd <path>]",
881
+ " playbook --session <id> [--with <path>]... [--no-provision]",
882
+ " playbook --list [--with <path>]... [--no-provision]",
883
+ " playbook --theme-diagnostics [--with <path>]... [--cwd <path>]",
884
+ " playbook --config <path> [tmux-play arguments...]",
885
+ " playbook run [--with <path>]... [--no-provision] [--json]",
886
+ " [--verbose] [--] [input]",
887
+ " playbook run (--continue | --session <id>) [reply]",
888
+ " playbook run --session <id> --retry-uncertain",
889
+ " playbook run --session <id> --discard-uncertain",
890
+ " playbook --help",
891
+ "",
844
892
  `Default config: ${userConfigPath}`,
845
- '',
846
- ' Only a fresh managed launch accepts --cwd. It creates a durable logical',
847
- ' Captain session and reports `playbook: session <id>` before attach.',
848
- ' Reopen that same session with `playbook --session <id>` or submit one',
849
- ' headless turn with `playbook run --session <id> [reply]`; selected',
850
- ' sessions always retain their stored working directory.',
851
- ' --with <path> overlays a top-level config fragment (same format as',
852
- ' the default config) for a fresh launch or compatible ordinary reopen —',
853
- ' maps merge recursively, other values replace, later files win, and the',
854
- ' default config file is never modified.',
855
- ' --no-provision keeps configured filesystem registries read-only;',
856
- ' any missing engine links remain a launch error.',
857
- ' `playbook run --verbose` prints Captain telemetry topics to stderr.',
858
- ' `playbook run --help` prints complete continuation and recovery usage.',
859
- ' Raw --config and --theme-diagnostics use cligent\'s stock tmux-play',
860
- ' process boundary and do not create or select a durable Captain session.',
861
- '',
862
- 'Adapter setup:',
863
- ' claude: npm install -g @anthropic-ai/claude-agent-sdk, then run',
864
- ' Claude Code once or set ANTHROPIC_API_KEY.',
865
- ' codex: npm install -g @openai/codex-sdk, then run Codex CLI once',
866
- ' or set OPENAI_API_KEY.',
867
- ' Each SDK is an optional peer dependency, so you install only the',
868
- ' vendors your config actually names.',
869
- '',
870
- 'Agent swap recipe:',
871
- ' - set the top-level captain and each stable players.<id> to an',
872
- ' adapter shorthand (claude, codex) or an inline agent block',
873
- ' - bind every playbooks.<id>.roles.<role> explicitly to a player id;',
874
- ' a scalar names the id, while { player, model?, effort? } may retune',
875
- ' one role; boolean false selects the provider default explicitly',
876
- ' - reusing one id deliberately shares that provider conversation;',
877
- ' distinct ids stay isolated even when their agent blocks are equal',
878
- ' - the launcher injects captain.from and retains referenced player ids',
879
- ' verbatim',
880
- '',
881
- 'Migration warning:',
882
- ' playbooks.<id>.players is removed and is not auto-migrated. Move each',
883
- ' agent to top-level players, choose ids for sharing or isolation, and',
884
- ' bind every local role under playbooks.<id>.roles.',
885
- '',
886
- ].join('\n');
893
+ "",
894
+ " Only a fresh managed launch accepts --cwd. It creates a durable logical",
895
+ " Captain session and reports `playbook: session <id>` before attach.",
896
+ " Reopen that same session with `playbook --session <id>` or submit one",
897
+ " headless turn with `playbook run --session <id> [reply]`; selected",
898
+ " sessions always retain their stored working directory.",
899
+ " --with <path> overlays a top-level config fragment (same format as",
900
+ " the default config) for a fresh launch or compatible ordinary reopen —",
901
+ " maps merge recursively, other values replace, later files win, and the",
902
+ " default config file is never modified.",
903
+ " --no-provision keeps configured filesystem registries read-only;",
904
+ " any missing engine links remain a launch error.",
905
+ " `playbook run --verbose` prints Captain telemetry topics to stderr.",
906
+ " `playbook run --help` prints complete continuation and recovery usage.",
907
+ " Raw --config and --theme-diagnostics use cligent's stock tmux-play",
908
+ " process boundary and do not create or select a durable Captain session.",
909
+ "",
910
+ "Adapter setup:",
911
+ " claude: npm install -g @anthropic-ai/claude-agent-sdk, then run",
912
+ " Claude Code once or set ANTHROPIC_API_KEY.",
913
+ " codex: npm install -g @openai/codex-sdk, then run Codex CLI once",
914
+ " or set OPENAI_API_KEY.",
915
+ " Each SDK is an optional peer dependency, so you install only the",
916
+ " vendors your config actually names.",
917
+ "",
918
+ "Agent swap recipe:",
919
+ " - set the top-level captain and each stable players.<id> to an",
920
+ " adapter shorthand (claude, codex) or an inline agent block",
921
+ " - bind every playbooks.<id>.roles.<role> explicitly to a player id;",
922
+ " a scalar names the id, while { player, model?, effort?, fastMode? }",
923
+ " may retune one role; false selects provider-default model/effort,",
924
+ " while fastMode false is a literal disabled request",
925
+ " - reusing one id deliberately shares that provider conversation;",
926
+ " distinct ids stay isolated even when their agent blocks are equal",
927
+ " - the launcher injects captain.from and retains referenced player ids",
928
+ " verbatim",
929
+ "",
930
+ "Migration warning:",
931
+ " playbooks.<id>.players is removed and is not auto-migrated. Move each",
932
+ " agent to top-level players, choose ids for sharing or isolation, and",
933
+ " bind every local role under playbooks.<id>.roles.",
934
+ "",
935
+ ].join("\n");
887
936
  }
888
937
 
889
938
  async function launchTmuxPlay(spawnFn, childArgs, stderr) {
890
939
  return await new Promise((resolveResult) => {
891
940
  let child;
892
941
  try {
893
- child = spawnFn(process.execPath, childArgs, { stdio: 'inherit' });
942
+ child = spawnFn(process.execPath, childArgs, { stdio: "inherit" });
894
943
  } catch (error) {
895
944
  stderr.write(
896
945
  `playbook: failed to launch tmux-play: ${errorMessage(error)}\n`,
@@ -904,13 +953,13 @@ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
904
953
  settled = true;
905
954
  resolveResult(result);
906
955
  };
907
- child.on('error', (err) => {
956
+ child.on("error", (err) => {
908
957
  stderr.write(
909
958
  `playbook: failed to launch tmux-play: ${errorMessage(err)}\n`,
910
959
  );
911
960
  settle({ code: 127 });
912
961
  });
913
- child.on('exit', (code, signal) => {
962
+ child.on("exit", (code, signal) => {
914
963
  if (signal) settle({ signal });
915
964
  else settle({ code: code ?? 0 });
916
965
  });
@@ -918,8 +967,8 @@ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
918
967
  }
919
968
 
920
969
  function resolveTmuxPlayBin() {
921
- const tmuxPlayIndexUrl = import.meta.resolve('@sublang/cligent/tmux-play');
922
- return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
970
+ const tmuxPlayIndexUrl = import.meta.resolve("@sublang/cligent/tmux-play");
971
+ return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), "cli.js");
923
972
  }
924
973
 
925
974
  function errorMessage(error) {