@sublang/playbook 6.0.0 → 7.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.
@@ -4,47 +4,53 @@
4
4
 
5
5
  import { spawn } from 'node:child_process';
6
6
  import {
7
- constants,
8
- copyFileSync,
9
- existsSync,
10
- mkdirSync,
11
7
  mkdtempSync,
12
- readFileSync,
13
8
  realpathSync,
14
9
  rmSync,
15
10
  writeFileSync,
16
11
  } from 'node:fs';
17
12
  import { homedir, tmpdir } from 'node:os';
18
- import { dirname, join, resolve } from 'node:path';
13
+ import { dirname, join } from 'node:path';
19
14
  import { fileURLToPath } from 'node:url';
20
- import {
21
- parse as parseYaml,
22
- parseDocument as parseYamlDocument,
23
- stringify as stringifyYaml,
24
- } from 'yaml';
15
+ import { stringify as stringifyYaml } from 'yaml';
25
16
  import {
26
17
  adapterSdkFailureLines,
27
18
  checkAdapterSdks,
28
19
  mappedSdksFor,
29
20
  probeAdapterSdk,
30
21
  } from './adapter-sdk.js';
22
+ import {
23
+ adaptersFromLaunchPlan,
24
+ extractWithFlags,
25
+ loadLaunchPlan,
26
+ projectTmuxConfig,
27
+ resolveUserConfigPath,
28
+ checkReadiness,
29
+ } from './launch-config.js';
30
+ import { prepareConfiguredRegistries } from './provision.js';
31
+
32
+ // Preserve the established import surface while the CLI itself delegates to
33
+ // the host-neutral launch-config module.
34
+ export {
35
+ PLAYBOOK_CAPTAIN_MODULE,
36
+ adaptersFromComposedConfig,
37
+ adaptersFromLaunchPlan,
38
+ canonicalizeRegistrySpecifier,
39
+ checkReadiness,
40
+ composeGenericConfig,
41
+ deriveLaunchReadiness,
42
+ extractWithFlags,
43
+ loadLaunchPlan,
44
+ loadOverlayFragment,
45
+ mergeConfigs,
46
+ migrateRetiredProfiles,
47
+ normalizeLaunchPlan,
48
+ projectTmuxConfig,
49
+ resolveAgent,
50
+ resolveConfigHome,
51
+ resolveUserConfigPath,
52
+ } from './launch-config.js';
31
53
 
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
54
  const READINESS_FAILURE_EXIT_CODE = 2;
49
55
  const COMPOSITION_FAILURE_EXIT_CODE = 1;
50
56
 
@@ -58,22 +64,52 @@ export async function runPlaybookCli(options = {}) {
58
64
  const userConfigPath =
59
65
  options.userConfigPath ?? resolveUserConfigPath(env, home);
60
66
 
61
- // PBCLI-18: `playbook run ...` is the non-interactive one-shot path; it
62
- // never seeds, composes, resolves tmux-play, or launches it.
67
+ // PBCLI-18: `playbook run ...` is the non-interactive presentation of the
68
+ // same generic-config Captain session. It never resolves or launches the
69
+ // tmux presenter, but it receives the launch inputs shared with this host.
63
70
  if (argv[0] === 'run') {
64
71
  const { runPlaybookRun } = await import('./run.js');
65
72
  return await runPlaybookRun({
66
73
  argv: argv.slice(1),
67
74
  stdout,
68
75
  stderr,
69
- // PBCLI-29: the run host reads the same user config as the launcher,
70
- // honoring any injected env, home, or explicit path.
76
+ env,
77
+ homeDir: home,
71
78
  userConfigPath,
79
+ ...(options.cwd ? { cwd: options.cwd } : {}),
72
80
  ...(options.loadModule ? { loadModule: options.loadModule } : {}),
73
- ...(options.createAgent ? { createAgent: options.createAgent } : {}),
74
81
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
75
- ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
76
82
  ...(options.hostRoots ? { hostRoots: options.hostRoots } : {}),
83
+ ...(options.prepareRegistryModule
84
+ ? { prepareRegistryModule: options.prepareRegistryModule }
85
+ : {}),
86
+ ...(options.adapterImports
87
+ ? { adapterImports: options.adapterImports }
88
+ : {}),
89
+ ...(options.createCaptainRuntime
90
+ ? { createCaptainRuntime: options.createCaptainRuntime }
91
+ : {}),
92
+ ...(options.createCaptainSessionId
93
+ ? { createCaptainSessionId: options.createCaptainSessionId }
94
+ : {}),
95
+ ...(options.createLogicalSessionId
96
+ ? { createLogicalSessionId: options.createLogicalSessionId }
97
+ : {}),
98
+ ...(options.createHostRuntime
99
+ ? { createHostRuntime: options.createHostRuntime }
100
+ : {}),
101
+ ...(options.sessionStore
102
+ ? { sessionStore: options.sessionStore }
103
+ : {}),
104
+ ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
105
+ ...(options.now ? { now: options.now } : {}),
106
+ ...(options.createSessionTempId
107
+ ? { createSessionTempId: options.createSessionTempId }
108
+ : {}),
109
+ ...(options.createAttemptId
110
+ ? { createAttemptId: options.createAttemptId }
111
+ : {}),
112
+ ...(options.signal ? { signal: options.signal } : {}),
77
113
  // PBCLI-39: the run path gates on SDK availability too.
78
114
  ...(options.probeAdapterSdk
79
115
  ? { probeAdapterSdk: options.probeAdapterSdk }
@@ -115,6 +151,15 @@ export async function runPlaybookCli(options = {}) {
115
151
  );
116
152
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
117
153
  }
154
+ const noProvision = forwardArgv.includes('--no-provision');
155
+ forwardArgv = forwardArgv.filter((arg) => arg !== '--no-provision');
156
+ if (noProvision && hasExplicitConfig(argv)) {
157
+ stderr.write(
158
+ 'playbook: --no-provision applies to configured registry preparation ' +
159
+ 'and cannot combine with a raw --config launch\n',
160
+ );
161
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
162
+ }
118
163
 
119
164
  // PBCLI-1: explicit `--config <path>` launches that raw tmux-play config
120
165
  // directly, bypassing seeding, composition, and the readiness gate.
@@ -122,29 +167,22 @@ export async function runPlaybookCli(options = {}) {
122
167
  return await launchTmuxPlay(spawnFn, [tmuxPlayBin, ...argv], stderr);
123
168
  }
124
169
 
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.
129
- try {
130
- migrateUserConfigIfRetired(userConfigPath, stderr);
131
- } catch (error) {
132
- stderr.write(`playbook: ${errorMessage(error)}\n`);
133
- return { code: COMPOSITION_FAILURE_EXIT_CODE };
134
- }
135
-
136
- let composed;
170
+ let plan;
137
171
  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`,
142
- );
143
- }
144
- for (const overlayPath of withPaths) {
145
- top = mergeConfigs(top, loadOverlayFragment(overlayPath));
146
- }
147
- composed = await composeGenericConfig(top, loadModule, userConfigPath);
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
+ });
148
186
  } catch (error) {
149
187
  stderr.write(`playbook: ${errorMessage(error)}\n`);
150
188
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -153,14 +191,15 @@ export async function runPlaybookCli(options = {}) {
153
191
  // PBCLI-5: `--list` prints each configured playbook's id, effective
154
192
  // command, and intent without launching tmux-play.
155
193
  if (argv.includes('--list')) {
156
- for (const pb of composed.playbooks) {
194
+ for (const pb of Object.values(plan.catalog)) {
157
195
  stdout.write(`/${pb.command} ${pb.id} — ${pb.intent}\n`);
158
196
  }
159
197
  return { code: 0 };
160
198
  }
161
199
 
162
- // PBCLI-12: readiness reads the adapters of the composed config.
163
- const declaredAdapters = adaptersFromComposedConfig(composed.config);
200
+ // PBCLI-12/46: readiness derives from the same normalized execution plan
201
+ // that both front ends consume, independent of its tmux projection.
202
+ const declaredAdapters = adaptersFromLaunchPlan(plan);
164
203
  const readiness = checkReadiness(declaredAdapters, env, home);
165
204
  // PBCLI-39/40: SDK availability is an independent check with its own
166
205
  // remedy — a credential and an SDK can be missing at once, and reporting
@@ -196,7 +235,7 @@ export async function runPlaybookCli(options = {}) {
196
235
  }
197
236
 
198
237
  const { dir: tempDir, path: composedPath } = writeComposedConfig(
199
- composed.config,
238
+ projectTmuxConfig(plan),
200
239
  );
201
240
  try {
202
241
  return await launchTmuxPlay(
@@ -209,489 +248,46 @@ export async function runPlaybookCli(options = {}) {
209
248
  }
210
249
  }
211
250
 
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);
232
- }
233
- }
234
- return { withPaths, rest };
235
- }
236
-
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) {
244
- throw new Error(
245
- `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
246
- );
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.
254
+ export async function runPlaybookCliEntry(options = {}) {
255
+ const processLike = options.processLike ?? process;
256
+ const entryArgv = options.argv ?? processLike.argv?.slice(2) ?? [];
257
+ if (entryArgv[0] !== 'run') {
258
+ return runPlaybookCli(options);
247
259
  }
248
- let fragment;
249
- try {
250
- fragment = parseYaml(text);
251
- } catch (error) {
252
- throw new Error(
253
- `cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
254
- );
255
- }
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;
305
- }
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
- );
315
- }
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
- );
325
- }
326
-
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;
333
- }
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
- }
260
+ const controller = new AbortController();
261
+ let receivedSignal;
262
+ const handlers = {};
263
+ const removeHandlers = () => {
264
+ for (const [signal, handler] of Object.entries(handlers)) {
265
+ processLike.off(signal, handler);
356
266
  }
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());
397
- }
398
- changed = true;
399
- }
400
- }
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;
421
- }
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
- }
428
-
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() !== '',
441
- );
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
- }
454
-
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;
462
- }
463
-
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) {
469
- 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)',
473
- );
474
- }
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
- }
492
- }
493
- }
494
-
495
- function isValidRegistryEntry(value) {
496
- if (!isObject(value)) return false;
497
- 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'
504
- );
505
- }
506
-
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
267
  };
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);
583
-
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
- }
593
-
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;
629
-
630
- const optionSlice = {};
631
- for (const key of Object.keys(block)) {
632
- if (!PLAYBOOK_LAUNCHER_KEYS.includes(key)) {
633
- optionSlice[key] = block[key];
268
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
269
+ handlers[signal] = () => {
270
+ if (receivedSignal !== undefined) {
271
+ removeHandlers();
272
+ processLike.kill(processLike.pid, signal);
273
+ return;
634
274
  }
635
- }
636
- optionsPlaybooks[id] = {
637
- from,
638
- ...(typeof block.command === 'string' && block.command.length > 0
639
- ? { command: block.command }
640
- : {}),
641
- options: optionSlice,
275
+ receivedSignal = signal;
276
+ controller.abort(new Error(`received ${signal}`));
642
277
  };
643
- listing.push({ id, command, intent: entry.intent });
644
278
  }
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 };
665
- }
666
-
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);
279
+ for (const [signal, handler] of Object.entries(handlers)) {
280
+ processLike.on(signal, handler);
672
281
  }
673
- return [...adapters];
674
- }
675
-
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;
691
- }
692
- unknownAdapters.push(adapter);
282
+ try {
283
+ const result = await runPlaybookCli({
284
+ ...options,
285
+ signal: controller.signal,
286
+ });
287
+ return receivedSignal === undefined ? result : { signal: receivedSignal };
288
+ } finally {
289
+ removeHandlers();
693
290
  }
694
- return { failingAdapters, unknownAdapters };
695
291
  }
696
292
 
697
293
  function writeComposedConfig(composed) {
@@ -701,13 +297,6 @@ function writeComposedConfig(composed) {
701
297
  return { dir, path };
702
298
  }
703
299
 
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`);
709
- }
710
-
711
300
  function hasExplicitConfig(argv) {
712
301
  return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
713
302
  }
@@ -727,17 +316,25 @@ function helpText({
727
316
  ...sdkFailureLines,
728
317
  ...failures,
729
318
  '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',
319
+ ' playbook [--list] [--with <path>]... [--no-provision]',
320
+ ' [--config <path>] [tmux-play options]',
321
+ ' playbook run [--with <path>]... [--no-provision] [--json]',
322
+ ' [--verbose] [--] [input]',
323
+ ' playbook run (--continue | --session <id>) [reply]',
324
+ ' playbook run --session <id> --retry-uncertain',
325
+ ' playbook run --session <id> --discard-uncertain',
733
326
  ' playbook --help',
734
327
  '',
735
328
  `Default config: ${userConfigPath}`,
736
329
  '',
737
330
  ' --with <path> overlays a top-level config fragment (same format as',
738
- ' the default config) over the default config for this launch only —',
331
+ ' the default config) over the default config for a fresh launch only —',
739
332
  ' maps merge recursively, other values replace, later files win. The',
740
333
  ' default config file is never modified.',
334
+ ' --no-provision keeps configured filesystem registries read-only;',
335
+ ' any missing engine links remain a launch error.',
336
+ ' `playbook run --verbose` prints Captain telemetry topics to stderr.',
337
+ ' `playbook run --help` prints complete continuation and recovery usage.',
741
338
  '',
742
339
  'Adapter setup:',
743
340
  ' claude: npm install -g @anthropic-ai/claude-agent-sdk, then run',
@@ -793,21 +390,6 @@ function resolveTmuxPlayBin() {
793
390
  return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
794
391
  }
795
392
 
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
393
  function errorMessage(error) {
812
394
  return error instanceof Error ? error.message : String(error);
813
395
  }
@@ -822,7 +404,8 @@ function isCliEntry(argv1 = process.argv[1], moduleUrl = import.meta.url) {
822
404
  }
823
405
 
824
406
  if (isCliEntry()) {
825
- const result = await runPlaybookCli();
407
+ const result = await runPlaybookCliEntry();
826
408
  if (result.signal) process.kill(process.pid, result.signal);
827
- else process.exit(result.code ?? 0);
409
+ // Let Node drain a long piped Captain reply or diagnostic naturally.
410
+ else process.exitCode = result.code ?? 0;
828
411
  }