@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.
@@ -1,1165 +1,1353 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
- // PBCLI-18/19/20: `playbook run <from> [task]` runs one playbook once,
5
- // non-interactively and without tmux-play, over a headless PlaybookPorts
6
- // host backed by cligent's Cligent. The playbook need not be enabled in
7
- // config; its registry entry is loaded straight from the `<from>` module.
8
- // PBCLI-22/23 (DR-014): a turn that parks awaiting a Boss reply persists
9
- // the session under ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions,
10
- // prints the pending question to stdout, and `playbook run resume
11
- // <session-id> [reply]` (or `--last`) finishes it in a later invocation.
4
+ // PBCLI-18/20 (DR-031): `playbook run [input]` is the non-interactive
5
+ // presentation of the same configured Captain session that `playbook` hosts
6
+ // in tmux. The core below uses cligent's ordinary tmux-play runtime without a
7
+ // presenter; it does not construct a registry runtime or PlaybookPorts itself.
12
8
 
13
9
  import { randomUUID } from 'node:crypto';
14
- import {
15
- mkdir,
16
- readdir,
17
- readFile,
18
- rename,
19
- rm,
20
- writeFile,
21
- } from 'node:fs/promises';
22
10
  import { homedir } from 'node:os';
23
- import { isAbsolute, join, resolve } from 'node:path';
24
- import { fileURLToPath, pathToFileURL } from 'node:url';
25
- import {
26
- Cligent,
27
- isEffortSupported,
28
- supportedEffortValues,
29
- } from '@sublang/cligent';
30
- import { parse as parseYaml } from 'yaml';
31
- import { hiddenControlEnvelope } from '../../../../src/xstate-runtime.js';
11
+ import { isAbsolute, resolve } from 'node:path';
12
+ import { isDeepStrictEqual } from 'node:util';
13
+ import { createTmuxPlayRuntime } from '@sublang/cligent/tmux-play';
14
+ import { snapshotJsonValue } from '../../../../src/xstate-runtime.js';
15
+ import { createPlaybookCaptainShell } from '../playbook-captain.js';
32
16
  import {
33
17
  adapterSdkFailureLines,
34
18
  checkAdapterSdks,
35
19
  mappedSdksFor,
36
20
  probeAdapterSdk,
37
21
  } from './adapter-sdk.js';
38
- import { provisionEngine } from './provision.js';
39
-
40
- // PBCLI-19: adapter shorthands the run host can construct.
41
- const ADAPTER_LOADERS = {
42
- claude: async () =>
43
- (await import('@sublang/cligent/adapters/claude-code')).ClaudeCodeAdapter,
44
- codex: async () => (await import('@sublang/cligent/adapters/codex')).CodexAdapter,
45
- gemini: async () =>
46
- (await import('@sublang/cligent/adapters/gemini')).GeminiAdapter,
47
- opencode: async () =>
48
- (await import('@sublang/cligent/adapters/opencode')).OpenCodeAdapter,
49
- };
22
+ import {
23
+ checkReadiness,
24
+ loadLaunchPlan,
25
+ normalizeHostConfig,
26
+ PLAYBOOK_CAPTAIN_MODULE,
27
+ resolveUserConfigPath,
28
+ } from './launch-config.js';
29
+ import { prepareConfiguredRegistries } from './provision.js';
30
+ import {
31
+ createCaptainSessionStore,
32
+ SESSION_ID_PATTERN,
33
+ validateCaptainSessionRecord,
34
+ } from './session-store.js';
50
35
 
51
- const DEFAULT_ADAPTER = 'claude';
52
- // PBCLI-18: exit-code map — terminal 0, arg/import 1, failed/aborted 2,
53
- // suspended/quiescent 3.
54
- const EXIT = { terminal: 0, arg: 1, failed: 2, suspended: 3 };
55
- // PBCLI-23: session-file schema version for the park/resume store.
56
- const SESSION_STORE_VERSION = 1;
57
- // PBCLI-23: a session id names a file inside the store; anything with a
58
- // path separator (or any character a fresh UUID cannot contain) would
59
- // escape the 0700-protected directory and must be rejected before join.
60
- const SESSION_REF_PATTERN = /^[A-Za-z0-9-]+$/;
36
+ const EXIT = { ok: 0, argument: 1, turn: 2 };
37
+ const UUID_PATTERN = SESSION_ID_PATTERN;
38
+ class HeadlessHostSetupError extends Error {
39
+ constructor(cause) {
40
+ super(message(cause));
41
+ this.name = 'HeadlessHostSetupError';
42
+ this.cause = cause;
43
+ }
44
+ }
45
+ const RETIRED_FLAGS = new Set([
46
+ '--player',
47
+ '--captain',
48
+ '--option',
49
+ '--cwd',
50
+ '--last',
51
+ '--config',
52
+ ]);
61
53
 
62
54
  export async function runPlaybookRun(options = {}) {
63
- const argv = options.argv ?? [];
55
+ const argv = [...(options.argv ?? [])];
64
56
  const stdout = options.stdout ?? process.stdout;
65
57
  const stderr = options.stderr ?? process.stderr;
66
- const cwdDefault = options.cwd ?? process.cwd();
67
- const loadModule =
68
- options.loadModule ??
69
- ((specifier) => import(registryImportSpecifier(specifier, cwdDefault)));
70
- const createAgent = options.createAgent ?? defaultCreateAgent;
71
- const readStdin = options.readStdin ?? readAllStdin;
72
- const sessionsDir = options.sessionsDir ?? defaultSessionsDir(process.env);
73
- // PBCLI-28/29: config defaults come from the same user config file the
74
- // interactive launcher resolves; tests inject a hermetic path.
75
- const userConfigPath =
76
- options.userConfigPath ?? (await defaultUserConfigPath());
77
- const ctx = {
78
- stdout,
79
- stderr,
80
- cwdDefault,
81
- loadModule,
82
- createAgent,
83
- readStdin,
84
- sessionsDir,
85
- userConfigPath,
86
- // PBCLI-39: the adapter SDK probe and the runtime classifier, injectable
87
- // like createAgent so tests can drive an unavailable or below-floor
88
- // runtime without uninstalling or downgrading one.
89
- probeAdapterSdk: options.probeAdapterSdk ?? probeAdapterSdk,
90
- classifyRuntime: options.classifyRuntime,
91
- // PBCLI-40: the original invocation, preserved on the ephemeral re-run;
92
- // this module receives argv with the leading `run` already consumed.
93
- rawArgv: ['run', ...argv],
94
- ephemeralNpx: options.ephemeralNpx,
95
- // PBCLI-37: injected host package roots let tests provision against
96
- // synthetic trees, like the injected session store.
97
- hostRoots: options.hostRoots,
98
- };
99
58
 
100
59
  let args;
101
60
  try {
102
61
  args = parseRunArgs(argv);
103
62
  } catch (error) {
104
- stderr.write(`playbook run: ${message(error)}\n`);
105
- return { code: EXIT.arg };
63
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
64
+ return { code: EXIT.argument };
106
65
  }
107
66
  if (args.help) {
108
- stdout.write(runHelpText());
109
- return { code: 0 };
67
+ const env = options.env ?? process.env;
68
+ const home = options.homeDir ?? env.HOME ?? homedir();
69
+ const userConfigPath =
70
+ options.userConfigPath ?? resolveUserConfigPath(env, home);
71
+ await writeStream(stdout, runHelpText(userConfigPath));
72
+ return { code: EXIT.ok };
110
73
  }
111
- if (args.resume) return runResume(args, ctx);
112
- return runFirst(args, ctx);
113
- }
114
-
115
- // PBCLI-18/20: the one-shot first run.
116
- async function runFirst(args, ctx) {
117
- const { stderr, cwdDefault, readStdin } = ctx;
118
- if (!args.from) {
119
- stderr.write('playbook run: missing <from> registry module\n');
120
- return { code: EXIT.arg };
121
- }
122
-
123
- // PBCLI-36/37 (DR-024): provision engine links for a filesystem module
124
- // before importing it; a resolvable engine is never touched.
125
- const provisioned = await maybeProvision(args.from, args, ctx);
126
- if (provisioned.code !== undefined) return provisioned;
127
74
 
128
- const loaded = await loadRegistryEntry(args.from, ctx);
129
- if (loaded.code !== undefined) return loaded;
130
- const { entry } = loaded;
75
+ const env = options.env ?? process.env;
76
+ const home = options.homeDir ?? env.HOME ?? homedir();
77
+ const recovering = args.retryUncertain || args.discardUncertain;
78
+ const continuing = args.continue || args.sessionId !== undefined;
79
+ let input = args.input;
131
80
 
132
- let task = args.task;
133
- if (task === undefined) task = (await readStdin()).trim();
134
- if (!task) {
135
- stderr.write('playbook run: empty task; pass it as an argument or on stdin\n');
136
- return { code: EXIT.arg };
81
+ // PBCLI-18/40: a fresh piped producer is drained before config,
82
+ // preparation, import, or readiness. Continuations first inspect the
83
+ // selected record so an uncertain turn never blocks waiting for input;
84
+ // explicit recovery never reads input at all.
85
+ if (!continuing && !recovering) {
86
+ const resolvedInput = await resolveBossInput(input, options, stderr);
87
+ if (!resolvedInput.ok) return { code: EXIT.argument };
88
+ input = resolvedInput.input;
137
89
  }
138
90
 
139
- // PBCLI-28 (DR-017): config-supplied defaults bind only a first run;
140
- // runResume rebuilds the lineup stored with the session.
141
- let runDefaults;
91
+ let store;
142
92
  try {
143
- runDefaults = await loadRunDefaults(ctx.userConfigPath);
93
+ store =
94
+ options.sessionStore ??
95
+ createCaptainSessionStore({
96
+ env,
97
+ homeDir: home,
98
+ ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
99
+ ...(options.now ? { now: options.now } : {}),
100
+ ...(options.createSessionTempId
101
+ ? { createTempId: options.createSessionTempId }
102
+ : {}),
103
+ });
144
104
  } catch (error) {
145
- stderr.write(`playbook run: ${message(error)}\n`);
146
- return { code: EXIT.arg };
105
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
106
+ return { code: EXIT.argument };
147
107
  }
148
108
 
149
- // PBCLI-19/28: bind every required role, then the captain — flag over
150
- // config default over built-in claude. An unrequired run.players role is
151
- // ignored (the config is global across playbooks); an unrequired
152
- // --player flag stays an error below.
153
- const roleSpecs = new Map(
154
- entry.requiredRoleIds.map((role) => [
155
- role,
156
- {
157
- ...(runDefaults.players.get(role) ??
158
- runDefaults.player ?? { adapter: DEFAULT_ADAPTER }),
159
- },
160
- ]),
109
+ let priorRecord;
110
+ let lease;
111
+ let sessionId;
112
+ let config;
113
+ let cwd;
114
+ let restoreSnapshot;
115
+ const loadModule = memoizedModuleLoader(
116
+ options.loadModule ?? ((specifier) => import(specifier)),
161
117
  );
162
- for (const [role, spec] of args.players) {
163
- if (!roleSpecs.has(role)) {
164
- stderr.write(`playbook run: --player ${role} is not a required role\n`);
165
- return { code: EXIT.arg };
118
+ const prepareRegistryModule = registryPreparer(args, options, stderr);
119
+
120
+ if (continuing) {
121
+ try {
122
+ if (args.sessionId === undefined) {
123
+ const selected = validateCaptainSessionRecord(
124
+ await awaitWithAbort(store.latest(), options.signal),
125
+ );
126
+ sessionId = selected.sessionId;
127
+ } else {
128
+ sessionId = args.sessionId;
129
+ }
130
+ throwIfAborted(options.signal);
131
+ lease = await store.acquire(sessionId);
132
+ throwIfAborted(options.signal);
133
+ const authoritative = await lease.read();
134
+ throwIfAborted(options.signal);
135
+ if (authoritative === undefined) {
136
+ throw new Error(
137
+ `Captain session ${JSON.stringify(sessionId)} does not exist`,
138
+ );
139
+ }
140
+ priorRecord = validateCaptainSessionRecord(authoritative);
141
+ assertLogicalSessionIdDistinct(priorRecord);
142
+ } catch (error) {
143
+ const releaseError = await releaseLease(lease);
144
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
145
+ if (releaseError !== undefined) {
146
+ await writeStream(
147
+ stderr,
148
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
149
+ );
150
+ return { code: EXIT.turn };
151
+ }
152
+ return { code: EXIT.argument };
153
+ }
154
+
155
+ if (priorRecord.state === 'uncertain') {
156
+ if (args.discardUncertain) {
157
+ try {
158
+ throwIfAborted(options.signal);
159
+ const record = await lease.discard({
160
+ attemptId: priorRecord.uncertain.attemptId,
161
+ });
162
+ throwIfAborted(options.signal);
163
+ const releaseError = await releaseLease(lease);
164
+ lease = undefined;
165
+ if (releaseError !== undefined) throw releaseError;
166
+ await writeStream(
167
+ stderr,
168
+ `playbook run: discarded uncertain turn for Captain session ${JSON.stringify(sessionId)}\n`,
169
+ );
170
+ return { code: EXIT.ok, sessionId, record };
171
+ } catch (error) {
172
+ const releaseError = await releaseLease(lease);
173
+ await writeStream(
174
+ stderr,
175
+ `playbook run: cannot discard uncertain Captain turn: ${message(error)}\n`,
176
+ );
177
+ if (releaseError !== undefined) {
178
+ await writeStream(
179
+ stderr,
180
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
181
+ );
182
+ }
183
+ return { code: EXIT.turn };
184
+ }
185
+ }
186
+ if (!args.retryUncertain) {
187
+ const releaseError = await releaseLease(lease);
188
+ lease = undefined;
189
+ await reportUncertainSession(stderr, sessionId);
190
+ if (releaseError !== undefined) {
191
+ await writeStream(
192
+ stderr,
193
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
194
+ );
195
+ return { code: EXIT.turn };
196
+ }
197
+ return { code: EXIT.argument };
198
+ }
199
+ input = priorRecord.uncertain.input;
200
+ } else if (recovering) {
201
+ const releaseError = await releaseLease(lease);
202
+ lease = undefined;
203
+ await writeStream(
204
+ stderr,
205
+ `playbook run: Captain session ${JSON.stringify(sessionId)} has no uncertain turn to recover\n`,
206
+ );
207
+ if (releaseError !== undefined) {
208
+ await writeStream(
209
+ stderr,
210
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
211
+ );
212
+ return { code: EXIT.turn };
213
+ }
214
+ return { code: EXIT.argument };
215
+ } else {
216
+ const resolvedInput = await resolveBossInput(input, options, stderr);
217
+ if (!resolvedInput.ok) {
218
+ const releaseError = await releaseLease(lease);
219
+ if (releaseError !== undefined) {
220
+ await writeStream(
221
+ stderr,
222
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
223
+ );
224
+ return { code: EXIT.turn };
225
+ }
226
+ return { code: EXIT.argument };
227
+ }
228
+ input = resolvedInput.input;
229
+ }
230
+
231
+ cwd = priorRecord.cwd;
232
+ restoreSnapshot = priorRecord.snapshot;
233
+ } else {
234
+ const userConfigPath =
235
+ options.userConfigPath ?? resolveUserConfigPath(env, home);
236
+ let plan;
237
+ const configNotices = [];
238
+ try {
239
+ throwIfAborted(options.signal);
240
+ plan = await loadLaunchPlan({
241
+ userConfigPath,
242
+ overlayPaths: args.withPaths,
243
+ loadModule,
244
+ prepareRegistryModule,
245
+ onNotice: (line) => configNotices.push(line),
246
+ });
247
+ throwIfAborted(options.signal);
248
+ } catch (error) {
249
+ for (const line of configNotices) await writeStream(stderr, line);
250
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
251
+ return { code: EXIT.argument };
252
+ }
253
+ for (const line of configNotices) await writeStream(stderr, line);
254
+
255
+ try {
256
+ sessionId = (options.createLogicalSessionId ?? randomUUID)();
257
+ if (typeof sessionId !== 'string' || !UUID_PATTERN.test(sessionId)) {
258
+ throw new Error(
259
+ `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
260
+ );
261
+ }
262
+ config = executionConfigFromPlan(plan);
263
+ cwd = resolve(options.cwd ?? process.cwd());
264
+ } catch (error) {
265
+ const releaseError = await releaseLease(lease);
266
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
267
+ if (releaseError !== undefined) {
268
+ await writeStream(
269
+ stderr,
270
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
271
+ );
272
+ return { code: EXIT.turn };
273
+ }
274
+ return { code: EXIT.argument };
166
275
  }
167
- roleSpecs.set(role, spec);
168
- }
169
- const captainSpec =
170
- args.captain ?? runDefaults.captain ?? { adapter: DEFAULT_ADAPTER };
171
- const specError = specsDiagnostic([...roleSpecs.values(), captainSpec]);
172
- if (specError !== undefined) {
173
- stderr.write(`playbook run: ${specError}\n`);
174
- return { code: EXIT.arg };
175
276
  }
176
277
 
177
- // PBCLI-39/40: an optional-peer SDK that is not installed fails here,
178
- // before the runtime exists and before any agent call — never mid-turn.
179
- const sdkError = await adapterSdksDiagnostic(
180
- [...roleSpecs.values(), captainSpec],
181
- ctx,
182
- stdinReplayArgs(args, task),
183
- );
184
- if (sdkError !== undefined) {
185
- stderr.write(sdkError);
186
- return { code: EXIT.arg };
278
+ if (continuing) {
279
+ try {
280
+ throwIfAborted(options.signal);
281
+ config = await validateFrozenExecutionConfig(priorRecord.config, {
282
+ loadModule,
283
+ prepareRegistryModule,
284
+ });
285
+ throwIfAborted(options.signal);
286
+ } catch (error) {
287
+ const releaseError = await releaseLease(lease);
288
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
289
+ if (releaseError !== undefined) {
290
+ await writeStream(
291
+ stderr,
292
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
293
+ );
294
+ return { code: EXIT.turn };
295
+ }
296
+ return { code: EXIT.argument };
297
+ }
187
298
  }
188
299
 
189
- let runtime;
300
+ const adapters = adaptersFromExecutionConfig(config);
301
+ const readiness = checkReadiness(adapters, env, home);
302
+ let sdkReadiness;
190
303
  try {
191
- runtime = entry.createRuntime({
192
- captainOptions: args.option,
193
- players: playersFromSpecs(roleSpecs),
194
- });
304
+ sdkReadiness = await awaitWithAbort(
305
+ checkAdapterSdks(
306
+ adapters,
307
+ options.probeAdapterSdk ?? probeAdapterSdk,
308
+ ...(options.classifyRuntime ? [options.classifyRuntime] : []),
309
+ ),
310
+ options.signal,
311
+ );
195
312
  } catch (error) {
196
- stderr.write(`playbook run: ${message(error)}\n`);
197
- return { code: EXIT.arg };
313
+ const releaseError = await releaseLease(lease);
314
+ await writeStream(
315
+ stderr,
316
+ `playbook run: adapter readiness failed: ${message(error)}\n`,
317
+ );
318
+ if (releaseError !== undefined) {
319
+ await writeStream(
320
+ stderr,
321
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
322
+ );
323
+ return { code: EXIT.turn };
324
+ }
325
+ return { code: EXIT.argument };
198
326
  }
199
-
200
- // PBCLI-23: the record stores everything a resume needs to rebuild the
201
- // identical host. `cwd` is resolved to an absolute path so a resume from
202
- // another directory rebinds the agents to the same place.
203
- const store = {
204
- schemaVersion: SESSION_STORE_VERSION,
205
- sessionId: randomUUID(),
206
- playbookId: entry.id,
207
- from: registryImportSpecifier(args.from, cwdDefault),
208
- cwd: resolve(cwdDefault, args.cwd ?? '.'),
209
- captain: captainSpec,
210
- players: Object.fromEntries(roleSpecs),
211
- option: args.option,
212
- };
213
- return driveTurn({
214
- ctx,
215
- runtime,
216
- store,
217
- text: task,
218
- json: args.json,
219
- verbose: args.verbose,
220
- restoreFrom: undefined,
221
- });
222
- }
223
-
224
- // PBCLI-22/23: continue a persisted parked session.
225
- async function runResume(args, ctx) {
226
- const { stderr, readStdin, sessionsDir } = ctx;
227
- if (
228
- args.players.size > 0 ||
229
- args.captain !== undefined ||
230
- Object.keys(args.option).length > 0 ||
231
- args.cwd !== undefined
232
- ) {
233
- stderr.write(
234
- 'playbook run: resume uses the bindings stored with the session; ' +
235
- 'drop --player/--captain/--option/--cwd\n',
327
+ for (const adapter of readiness.unknownAdapters) {
328
+ await writeStream(
329
+ stderr,
330
+ `playbook run: warning: no readiness check for adapter "${adapter}"\n`,
236
331
  );
237
- return { code: EXIT.arg };
238
332
  }
239
-
240
- let sessionFile;
241
- let record;
242
- if (args.last) {
243
- const latest = await latestSessionRecord(sessionsDir);
244
- if (!latest) {
245
- stderr.write(`playbook run: no persisted session under ${sessionsDir}\n`);
246
- return { code: EXIT.arg };
247
- }
248
- ({ file: sessionFile, record } = latest);
249
- } else if (args.sessionRef) {
250
- if (!SESSION_REF_PATTERN.test(args.sessionRef)) {
251
- stderr.write(
252
- `playbook run: "${args.sessionRef}" is not a session id\n`,
333
+ if (
334
+ readiness.failingAdapters.length > 0 ||
335
+ sdkReadiness.unusableAdapters.length > 0
336
+ ) {
337
+ await reportReadinessFailure({
338
+ stderr,
339
+ adapters,
340
+ failingAdapters: readiness.failingAdapters,
341
+ unusableAdapters: sdkReadiness.unusableAdapters,
342
+ invocation: replayInvocation(argv, args, input),
343
+ ephemeralNpx: options.ephemeralNpx,
344
+ });
345
+ const releaseError = await releaseLease(lease);
346
+ if (releaseError !== undefined) {
347
+ await writeStream(
348
+ stderr,
349
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
253
350
  );
254
- return { code: EXIT.arg };
351
+ return { code: EXIT.turn };
255
352
  }
256
- sessionFile = join(sessionsDir, `${args.sessionRef}.json`);
353
+ return { code: EXIT.argument };
354
+ }
355
+
356
+ if (lease === undefined) {
257
357
  try {
258
- record = JSON.parse(await readFile(sessionFile, 'utf8'));
358
+ throwIfAborted(options.signal);
359
+ lease = await store.acquire(sessionId);
360
+ throwIfAborted(options.signal);
259
361
  } catch (error) {
260
- stderr.write(
261
- `playbook run: cannot read session ${args.sessionRef}: ${message(error)}\n`,
362
+ const releaseError = await releaseLease(lease);
363
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
364
+ if (releaseError !== undefined) {
365
+ await writeStream(
366
+ stderr,
367
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
368
+ );
369
+ return { code: EXIT.turn };
370
+ }
371
+ return { code: EXIT.argument };
372
+ }
373
+ }
374
+
375
+ let attemptId;
376
+ try {
377
+ attemptId = createAttemptId(options);
378
+ } catch (error) {
379
+ const releaseError = await releaseLease(lease);
380
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
381
+ if (releaseError !== undefined) {
382
+ await writeStream(
383
+ stderr,
384
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
262
385
  );
263
- return { code: EXIT.arg };
386
+ return { code: EXIT.turn };
264
387
  }
265
- } else {
266
- stderr.write('playbook run: resume needs a <session-id> or --last\n');
267
- return { code: EXIT.arg };
388
+ return { code: EXIT.argument };
268
389
  }
269
- if (!isValidSessionRecord(record)) {
270
- stderr.write(
271
- `playbook run: ${sessionFile} is not a schema-version-${SESSION_STORE_VERSION} playbook run session\n`,
272
- );
273
- return { code: EXIT.arg };
390
+
391
+ let settled;
392
+ try {
393
+ settled = await driveHeadlessCaptainTurn({
394
+ config,
395
+ input,
396
+ sessionId,
397
+ cwd,
398
+ loadModule,
399
+ stderr,
400
+ verbose: args.verbose,
401
+ ...(options.adapterImports
402
+ ? { adapterImports: options.adapterImports }
403
+ : {}),
404
+ ...(options.createCaptainRuntime
405
+ ? { createCaptainRuntime: options.createCaptainRuntime }
406
+ : {}),
407
+ ...(options.createCaptainSessionId
408
+ ? { createCaptainSessionId: options.createCaptainSessionId }
409
+ : {}),
410
+ ...(options.createHostRuntime
411
+ ? { createHostRuntime: options.createHostRuntime }
412
+ : {}),
413
+ ...(restoreSnapshot !== undefined
414
+ ? { restoreSnapshot }
415
+ : {}),
416
+ ...(options.signal ? { signal: options.signal } : {}),
417
+ beforeBossTurn: async (baselineSnapshot) => {
418
+ throwIfAborted(options.signal);
419
+ return args.retryUncertain
420
+ ? lease.beginRetry({
421
+ expectedAttemptId: priorRecord.uncertain.attemptId,
422
+ nextAttemptId: attemptId,
423
+ })
424
+ : lease.beginTurn({
425
+ input,
426
+ attemptId,
427
+ ...(priorRecord === undefined
428
+ ? {
429
+ fresh: {
430
+ cwd,
431
+ config,
432
+ snapshot: baselineSnapshot,
433
+ },
434
+ }
435
+ : {}),
436
+ });
437
+ },
438
+ assertBeforeBossTurn: () => lease.assertOwner(),
439
+ });
440
+ } catch (error) {
441
+ const releaseError = await releaseLease(lease);
442
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
443
+ if (releaseError !== undefined) {
444
+ await writeStream(
445
+ stderr,
446
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
447
+ );
448
+ }
449
+ return {
450
+ code:
451
+ error instanceof HeadlessHostSetupError && releaseError === undefined
452
+ ? EXIT.argument
453
+ : EXIT.turn,
454
+ };
274
455
  }
275
456
 
276
- let reply = args.task;
277
- if (reply === undefined) reply = (await readStdin()).trim();
278
- if (!reply) {
279
- stderr.write(
280
- 'playbook run: empty reply; pass it as an argument or on stdin\n',
457
+ let durableRecord;
458
+ try {
459
+ throwIfAborted(options.signal);
460
+ durableRecord = await lease.settle({
461
+ attemptId: settled.uncertainRecord.uncertain.attemptId,
462
+ snapshot: settled.snapshot,
463
+ });
464
+ } catch (error) {
465
+ try {
466
+ await settled.dispose();
467
+ } catch {
468
+ // Preserve the failed durable hand-off as the primary diagnostic.
469
+ }
470
+ await writeStream(
471
+ stderr,
472
+ `playbook run: cannot persist Captain session: ${message(error)}\n`,
281
473
  );
282
- return { code: EXIT.arg };
474
+ const releaseError = await releaseLease(lease);
475
+ if (releaseError !== undefined) {
476
+ await writeStream(
477
+ stderr,
478
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
479
+ );
480
+ }
481
+ return { code: EXIT.turn };
283
482
  }
284
483
 
285
- // PBCLI-37: a stored filesystem `from` (a file: URL) is probed and
286
- // provisioned on resume exactly as on a first run.
287
- const provisioned = await maybeProvision(record.from, args, ctx);
288
- if (provisioned.code !== undefined) return provisioned;
289
-
290
- const loaded = await loadRegistryEntry(record.from, ctx);
291
- if (loaded.code !== undefined) return loaded;
292
- const { entry } = loaded;
293
- // PBCLI-23: the module may have changed since the session parked; a
294
- // different playbook id means a different machine, which the stored
295
- // snapshot cannot rehydrate.
296
- if (entry.id !== record.playbookId) {
297
- stderr.write(
298
- `playbook run: ${record.from} now exposes playbook "${entry.id}", ` +
299
- `but the stored session belongs to "${record.playbookId}"\n`,
484
+ const releaseError = await releaseLease(lease);
485
+ lease = undefined;
486
+ if (releaseError !== undefined) {
487
+ await writeStream(
488
+ stderr,
489
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
300
490
  );
301
- return { code: EXIT.arg };
491
+ return { code: EXIT.turn };
302
492
  }
303
- const roleSpecs = new Map(Object.entries(record.players));
304
- const missingRole = entry.requiredRoleIds.find(
305
- (role) => !roleSpecs.has(role),
306
- );
307
- if (missingRole !== undefined) {
308
- stderr.write(
309
- `playbook run: stored session lacks required role "${missingRole}"; ` +
310
- `the ${record.from} module changed since the session parked\n`,
493
+ if (options.signal?.aborted) {
494
+ await writeStream(
495
+ stderr,
496
+ 'playbook run: Captain turn was interrupted; reply withheld\n',
311
497
  );
312
- return { code: EXIT.arg };
313
- }
314
- const specError = specsDiagnostic([...roleSpecs.values(), record.captain]);
315
- if (specError !== undefined) {
316
- stderr.write(`playbook run: ${specError}\n`);
317
- return { code: EXIT.arg };
498
+ return { code: EXIT.turn, sessionId, record: durableRecord };
318
499
  }
319
500
 
320
- // PBCLI-39: a resume rebuilds the stored lineup, so it needs the same
321
- // SDKs an install that lost one must not resume into a mid-turn error.
322
- const sdkError = await adapterSdksDiagnostic(
323
- [...roleSpecs.values(), record.captain],
324
- ctx,
325
- stdinReplayArgs(args, reply),
326
- );
327
- if (sdkError !== undefined) {
328
- stderr.write(sdkError);
329
- return { code: EXIT.arg };
330
- }
331
-
332
- let runtime;
501
+ // Durable hand-off transfers semantic ownership to the logical session.
502
+ // Process exit owns ephemeral transport teardown; semantic disposal here
503
+ // would end the session that `--continue` must restore.
333
504
  try {
334
- runtime = entry.createRuntime({
335
- captainOptions: record.option,
336
- players: playersFromSpecs(roleSpecs),
505
+ await presentHeadlessCaptainTurn(settled, {
506
+ stdout,
507
+ json: args.json,
337
508
  });
338
509
  } catch (error) {
339
- stderr.write(`playbook run: ${message(error)}\n`);
340
- return { code: EXIT.arg };
341
- }
342
- if (typeof runtime.restore !== 'function') {
343
- stderr.write(
344
- `playbook run: the ${record.from} runtime does not support resume\n`,
510
+ await writeStream(
511
+ stderr,
512
+ `playbook run: cannot write Captain reply: ${message(error)}\n`,
345
513
  );
346
- return { code: EXIT.arg };
514
+ return { code: EXIT.turn };
347
515
  }
348
-
349
- return driveTurn({
350
- ctx,
351
- runtime,
352
- store: record,
353
- text: reply,
354
- json: args.json,
355
- verbose: args.verbose,
356
- restoreFrom: { snapshot: record.snapshot, sessionFile },
357
- });
516
+ return {
517
+ code: EXIT.ok,
518
+ sessionId,
519
+ reply: settled.reply,
520
+ snapshot: settled.snapshot,
521
+ config: settled.config,
522
+ cwd: settled.cwd,
523
+ record: durableRecord,
524
+ };
358
525
  }
359
526
 
360
- // PBCLI-18: shared module-load pipeline for `<from>` and a stored resume
361
- // specifier. Returns { entry } or an { code } failure already reported.
362
- async function loadRegistryEntry(specifier, { loadModule, stderr }) {
363
- let entry;
527
+ // PBCLI-20: run exactly one Boss boundary and capture its one accepted reply
528
+ // plus the complete shell snapshot. No stdout presentation occurs here.
529
+ export async function driveHeadlessCaptainTurn({
530
+ config,
531
+ input,
532
+ sessionId,
533
+ cwd,
534
+ loadModule,
535
+ stderr,
536
+ verbose = false,
537
+ adapterImports,
538
+ createCaptainRuntime,
539
+ createCaptainSessionId,
540
+ createHostRuntime = createTmuxPlayRuntime,
541
+ restoreSnapshot,
542
+ beforeBossTurn,
543
+ assertBeforeBossTurn,
544
+ signal,
545
+ }) {
546
+ const replies = [];
547
+ let shell;
548
+ let host;
549
+ let baselineSnapshot;
550
+ let uncertainRecord;
364
551
  try {
365
- entry = (await loadModule(specifier))?.default;
366
- } catch (cause) {
367
- stderr.write(
368
- `playbook run: ${specifier} failed to import: ${message(cause)}\n`,
369
- );
370
- return { code: EXIT.arg };
371
- }
372
- if (!isValidRegistryEntry(entry)) {
373
- stderr.write(
374
- `playbook run: ${specifier} exposes no valid registry entry\n`,
375
- );
376
- return { code: EXIT.arg };
377
- }
378
- return { entry };
379
- }
380
-
381
- // PBCLI-20/23: one Boss turn over the headless cligent-backed ports,
382
- // parking to the session store when the playbook awaits a Boss reply.
383
- async function driveTurn({ ctx, runtime, store, text, json, verbose, restoreFrom }) {
384
- const { stdout, stderr, createAgent } = ctx;
385
- const { sessionId, cwd } = store;
386
-
387
- const agentsByRole = new Map();
388
- for (const [role, spec] of Object.entries(store.players)) {
389
- agentsByRole.set(role, createAgent({ ...spec, role, cwd }));
390
- }
391
- const captainAgent = createAgent({ ...store.captain, role: 'captain', cwd });
392
-
393
- const controller = new AbortController();
394
- const ports = {
395
- async callPlayer(playerId, prompt, signal, callOptions) {
396
- const agent = agentsByRole.get(playerId);
397
- if (!agent) return { status: 'error', error: `unknown player ${playerId}` };
398
- const result = await agent.run(prompt, { resume: callOptions?.resume, signal });
399
- return toPlayerResult(result);
400
- },
401
- async callCaptain(prompt, signal, callOptions) {
402
- const result = await captainAgent.run(prompt, {
403
- resume: callOptions?.resume,
404
- ...(callOptions?.allowedTools === undefined
405
- ? {}
406
- : { allowedTools: callOptions.allowedTools }),
407
- signal,
552
+ try {
553
+ shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
554
+ loadModule,
555
+ ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
556
+ ...(createCaptainSessionId
557
+ ? { createSessionId: createCaptainSessionId }
558
+ : {}),
408
559
  });
409
- return {
410
- status: result.status,
411
- ...(result.finalText === undefined
412
- ? {}
413
- : { finalText: result.finalText }),
414
- ...(result.error ? { error: result.error } : {}),
415
- };
416
- },
417
- async callJudge(prompt, signal) {
418
- // CAPTAIN-9 / DR-013 A1: wrap every judge prompt in the shared
419
- // hidden-control envelope. Runtime judge prompts embed raw Boss text
420
- // and quoted player output, so the envelope is what makes them
421
- // delimited evidence rather than instructions — and it is the
422
- // prompt-level isolation that stands in for provider enforcement
423
- // when the tool allowlist below has to be omitted.
424
- const result = await captainAgent.run(hiddenControlEnvelope(prompt), {
425
- resume: false,
426
- // An empty allowlist means "no tools" and is distinct from omission,
427
- // which grants the adapter's full tool surface. Send it only where
428
- // the adapter can enforce it; codex rejects any tool list outright,
429
- // so requesting one would fail every judge call.
430
- ...controlCallToolOptions(store.captain.adapter),
431
- signal,
560
+ const captain = captainHostBoundary(shell, restoreSnapshot);
561
+ host = await createHostRuntime({
562
+ captain,
563
+ captainConfig: cloneJson(config.captain),
564
+ players: cloneJson(config.players),
565
+ cwd,
566
+ ...(signal ? { signal } : {}),
567
+ ...(adapterImports ? { adapterImports } : {}),
568
+ observers: [
569
+ {
570
+ async onRecord(record) {
571
+ if (record.type === 'captain_reply') {
572
+ replies.push(record.text);
573
+ } else if (record.type === 'captain_status') {
574
+ await writeStream(stderr, `${record.message}\n`);
575
+ } else if (verbose && record.type === 'captain_telemetry') {
576
+ await writeStream(stderr, `\u00b7 ${record.topic}\n`);
577
+ }
578
+ },
579
+ },
580
+ ],
432
581
  });
433
- if (result.status !== 'ok' || result.finalText === undefined) {
434
- throw new Error(result.error ?? 'judge call failed');
582
+ if (shell === undefined) {
583
+ throw new Error('Captain shell host initialized without a shell');
435
584
  }
436
- return result.finalText;
437
- },
438
- async callPlaybook() {
439
- // The one-shot host cannot drive the child, but returning a suspended
440
- // start lets the linked runtime expose that boundary as outcome
441
- // `suspended`, which finishRun maps to the documented exit code 3.
442
- return { state: 'suspended', childSessionId: randomUUID() };
443
- },
444
- async emitStatus(statusText) {
445
- stderr.write(`◇ ${statusText}\n`);
446
- },
447
- async emitTelemetry(event) {
448
- if (verbose) stderr.write(`· ${event.topic}\n`);
449
- },
450
- };
451
-
452
- const session = {
453
- sessionId,
454
- playbookId: store.playbookId,
455
- rootSessionId: sessionId,
456
- depth: 0,
457
- ports,
458
- };
459
- // DR-014 §2: only a successfully persisted parked hand-off skips
460
- // disposal; the session is then suspended, not ended.
461
- let parked = false;
462
- try {
463
- if (restoreFrom) {
464
- try {
465
- await runtime.restore(session, restoreFrom.snapshot);
466
- } catch (error) {
467
- stderr.write(
468
- `playbook run: session ${sessionId} cannot be resumed: ${message(error)}\n`,
585
+ baselineSnapshot = shell.exportSnapshot();
586
+ if (baselineSnapshot === undefined) {
587
+ throw new Error(
588
+ 'Captain shell initialized without an exportable session snapshot',
469
589
  );
470
- return { code: EXIT.arg };
471
590
  }
472
- } else {
473
- await runtime.init(session);
474
- }
475
- const result = await runtime.handleBossInput({
476
- text,
477
- signal: controller.signal,
478
- });
479
- const parkedSnapshot =
480
- (result.outcome === 'quiescent' || result.outcome === 'no-action') &&
481
- typeof runtime.exportSnapshot === 'function'
482
- ? runtime.exportSnapshot()
483
- : undefined;
484
- if (parkedSnapshot && parkedSnapshot.pendingBossQuestions.length > 0) {
485
- const outcome = await finishParked({
486
- ctx,
487
- store,
488
- snapshot: parkedSnapshot,
489
- json,
591
+ if (
592
+ restoreSnapshot !== undefined &&
593
+ !isDeepStrictEqual(baselineSnapshot, restoreSnapshot)
594
+ ) {
595
+ throw new Error('restored Captain snapshot changed before the Boss turn');
596
+ }
597
+ assertLogicalSessionIdDistinct({
598
+ sessionId,
599
+ snapshot: baselineSnapshot,
490
600
  });
491
- parked = outcome.code === EXIT.suspended;
492
- return outcome;
601
+ uncertainRecord = await beforeBossTurn?.(baselineSnapshot);
602
+ } catch (error) {
603
+ throw new HeadlessHostSetupError(error);
493
604
  }
494
- const outcome = finishRun(result, { stdout, stderr, json, sessionId });
495
- if (restoreFrom && result.outcome === 'terminal') {
496
- // The turn succeeded; a session-file removal failure must not mask
497
- // the terminal output or flip the exit code.
498
- try {
499
- await rm(restoreFrom.sessionFile, { force: true });
500
- } catch (error) {
501
- stderr.write(
502
- `playbook run: warning: could not remove ${restoreFrom.sessionFile}: ${message(error)}\n`,
503
- );
504
- }
605
+ await assertBeforeBossTurn?.();
606
+ if (signal?.aborted) {
607
+ throw signal.reason ?? new Error('Captain turn aborted');
608
+ }
609
+ await host.runBossTurn(input);
610
+ throwIfAborted(signal);
611
+ if (
612
+ replies.length !== 1 ||
613
+ typeof replies[0] !== 'string' ||
614
+ replies[0].trim().length === 0
615
+ ) {
616
+ throw new Error(
617
+ `Captain turn produced ${replies.length} usable Boss-visible replies; expected exactly one`,
618
+ );
619
+ }
620
+ if (shell === undefined) {
621
+ throw new Error('Captain shell host initialized without a shell');
622
+ }
623
+ const snapshot = shell.exportSnapshot();
624
+ if (snapshot === undefined) {
625
+ throw new Error('Captain turn settled without an exportable session snapshot');
626
+ }
627
+ if (
628
+ snapshot.captain?.sessionId === sessionId ||
629
+ snapshot.issuedSessionIds?.includes(sessionId)
630
+ ) {
631
+ throw new Error(
632
+ 'logical session id collided with an internal Captain session id',
633
+ );
505
634
  }
506
- return outcome;
635
+ return {
636
+ sessionId,
637
+ reply: replies[0],
638
+ snapshot,
639
+ config: cloneJson(config),
640
+ cwd,
641
+ uncertainRecord,
642
+ dispose: () => host.dispose(),
643
+ };
507
644
  } catch (error) {
508
- stderr.write(`playbook run: ${message(error)}\n`);
509
- return { code: EXIT.failed };
510
- } finally {
511
- if (!parked) {
645
+ if (host !== undefined) {
512
646
  try {
513
- await runtime.dispose();
647
+ await host.dispose();
514
648
  } catch {
515
- // A dispose failure must not mask the run's own outcome.
649
+ // Preserve the turn/capture failure as the primary diagnostic.
516
650
  }
517
651
  }
652
+ throw error;
518
653
  }
519
654
  }
520
655
 
521
- // PBCLI-23: persist the parked session and surface the pending question
522
- // stdout carries the question text (the run's product), stderr one hint
523
- // naming the session id and the exact resume command.
524
- async function finishParked({ ctx, store, snapshot, json }) {
525
- const { stdout, stderr } = ctx;
526
- const now = new Date().toISOString();
527
- const record = {
528
- ...store,
529
- createdAt: store.createdAt ?? now,
530
- updatedAt: now,
531
- snapshot,
656
+ // Task 7's restore path must enter through the host's one init boundary: a
657
+ // restored shell is fresh and receives restore instead of init, never both.
658
+ function captainHostBoundary(shell, restoreSnapshot) {
659
+ return {
660
+ init: (session) =>
661
+ restoreSnapshot === undefined
662
+ ? shell.init(session)
663
+ : shell.restore(session, restoreSnapshot),
664
+ handleBossTurn: (turn, context) => shell.handleBossTurn(turn, context),
665
+ prepareDispose: () => shell.prepareDispose?.(),
666
+ dispose: () => shell.dispose?.(),
532
667
  };
533
- const file = join(ctx.sessionsDir, `${store.sessionId}.json`);
534
- try {
535
- await mkdir(ctx.sessionsDir, { recursive: true, mode: 0o700 });
536
- // Write-then-rename so a crash mid-write can never truncate the only
537
- // durable copy of the session.
538
- const tmpFile = `${file}.${process.pid}.tmp`;
539
- await writeFile(tmpFile, `${JSON.stringify(record, null, 2)}\n`, {
540
- mode: 0o600,
541
- });
542
- await rename(tmpFile, file);
543
- } catch (error) {
544
- stderr.write(`playbook run: cannot persist session: ${message(error)}\n`);
545
- return { code: EXIT.failed };
546
- }
547
- const questions = snapshot.pendingBossQuestions;
548
- if (json) {
549
- stdout.write(
550
- `${JSON.stringify(
551
- {
552
- outcome: 'awaiting-reply',
553
- sessionId: store.sessionId,
554
- questions: questions.map(({ questionId, player, question }) => ({
555
- questionId,
556
- player,
557
- question,
558
- })),
559
- },
560
- null,
561
- 2,
562
- )}\n`,
668
+ }
669
+
670
+ // Host-neutral execution-only projection. It is detached from the frozen
671
+ // launch plan and intentionally excludes layout, theme, and notifications.
672
+ export function executionConfigFromPlan(plan) {
673
+ return cloneJson({
674
+ schemaVersion: 1,
675
+ captain: plan.captain,
676
+ players: plan.players.map(({ id, agent }) => ({ id, ...agent })),
677
+ // Keep the complete normalized catalog. Task 7 can freeze and validate
678
+ // the same identities instead of silently accepting changed module
679
+ // defaults while restoring a chat-only session.
680
+ catalog: plan.catalog,
681
+ });
682
+ }
683
+
684
+ // PBCLI-22/23: a continuation consumes only the detached execution projection
685
+ // captured at session creation. The current registry code must still expose
686
+ // the recorded manifest identity, while the stored effective command remains
687
+ // authoritative even when launcher configuration originally overrode it.
688
+ export async function validateFrozenExecutionConfig(
689
+ value,
690
+ { loadModule, prepareRegistryModule },
691
+ ) {
692
+ const config = requireRecord(
693
+ snapshotJsonValue(value, 'Captain execution config'),
694
+ 'Captain execution config',
695
+ );
696
+ requireExactKeys(
697
+ config,
698
+ ['schemaVersion', 'captain', 'players', 'catalog'],
699
+ 'Captain execution config',
700
+ );
701
+ if (config.schemaVersion !== 1) {
702
+ throw new Error(
703
+ `Captain execution config schema ${JSON.stringify(config.schemaVersion)} is not supported`,
563
704
  );
564
- } else {
565
- stdout.write(`${questions.map(({ question }) => question).join('\n')}\n`);
566
705
  }
567
- stderr.write(
568
- `playbook run: session ${store.sessionId} is awaiting a Boss reply; ` +
569
- `continue with: playbook run resume ${store.sessionId} "<answer>"\n`,
706
+ requireRecord(config.captain, 'Captain execution config.captain');
707
+ if (!Array.isArray(config.players)) {
708
+ throw new Error('Captain execution config.players must be an array');
709
+ }
710
+ const catalog = requireRecord(
711
+ config.catalog,
712
+ 'Captain execution config.catalog',
570
713
  );
571
- return { code: EXIT.suspended };
572
- }
714
+ const catalogItems = Object.entries(catalog);
715
+ if (catalogItems.length === 0) {
716
+ throw new Error('Captain execution config.catalog must not be empty');
717
+ }
573
718
 
574
- // PBCLI-18: map the single turn's outcome to stdout output and an exit code.
575
- function finishRun(result, { stdout, stderr, json, sessionId }) {
576
- switch (result.outcome) {
577
- case 'terminal':
578
- stdout.write(
579
- (json
580
- ? JSON.stringify(
581
- {
582
- outcome: 'terminal',
583
- sessionId,
584
- output: result.output ?? null,
585
- },
586
- null,
587
- 2,
588
- )
589
- : renderOutput(result.output)) + '\n',
719
+ const expectedPlayerIds = [];
720
+ const seenCommands = new Set();
721
+ for (const [key, itemValue] of catalogItems) {
722
+ const item = requireRecord(
723
+ itemValue,
724
+ `Captain execution config.catalog.${key}`,
725
+ );
726
+ const allowed = [
727
+ 'id',
728
+ 'from',
729
+ 'manifestCommand',
730
+ 'command',
731
+ 'intent',
732
+ 'requiredRoleIds',
733
+ 'playerIds',
734
+ 'options',
735
+ ...(Object.hasOwn(item, 'commandOverride') ? ['commandOverride'] : []),
736
+ ];
737
+ requireExactKeys(
738
+ item,
739
+ allowed,
740
+ `Captain execution config.catalog.${key}`,
741
+ );
742
+ if (requireNonblank(item.id, `catalog.${key}.id`) !== key) {
743
+ throw new Error(
744
+ `Captain execution config catalog key must equal id ${JSON.stringify(item.id)}`,
745
+ );
746
+ }
747
+ if (item.id === 'captain') {
748
+ throw new Error('Captain execution config uses the reserved playbook id "captain"');
749
+ }
750
+ const from = requireNonblank(item.from, `catalog.${key}.from`);
751
+ if (
752
+ isAbsolute(from) ||
753
+ /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[A-Za-z]:[\\/])/.test(from)
754
+ ) {
755
+ throw new Error(
756
+ `Captain execution config catalog.${key}.from is not a canonical module specifier`,
590
757
  );
591
- return { code: EXIT.terminal };
592
- case 'failed':
593
- case 'aborted':
594
- stderr.write(
595
- `playbook run: ${result.outcome}${
596
- result.error ? `: ${result.error.message}` : ''
597
- }\n`,
758
+ }
759
+ requireNonblank(item.manifestCommand, `catalog.${key}.manifestCommand`);
760
+ const command = requireNonblank(item.command, `catalog.${key}.command`);
761
+ if (typeof item.intent !== 'string') {
762
+ throw new Error(`catalog.${key}.intent must be a string`);
763
+ }
764
+ if (command === 'captain') {
765
+ throw new Error(
766
+ `Captain execution config catalog.${key} uses the reserved command "captain"`,
598
767
  );
599
- return { code: EXIT.failed };
600
- case 'suspended':
601
- stderr.write(
602
- 'playbook run: playbook made a nested call a one-shot run cannot answer\n',
768
+ }
769
+ if (seenCommands.has(command)) {
770
+ throw new Error(
771
+ `Captain execution config has duplicate effective command ${JSON.stringify(command)}`,
603
772
  );
604
- return { code: EXIT.suspended };
605
- default:
606
- // quiescent / no-action without a persistable pending question:
607
- // the pre-DR-014 diagnostic path.
608
- stderr.write(
609
- 'playbook run: playbook is awaiting Boss input; a one-shot run cannot continue\n',
773
+ }
774
+ seenCommands.add(command);
775
+ if (Object.hasOwn(item, 'commandOverride')) {
776
+ if (
777
+ requireNonblank(item.commandOverride, `catalog.${key}.commandOverride`) !==
778
+ command
779
+ ) {
780
+ throw new Error(`Captain execution config catalog.${key} command override is not frozen`);
781
+ }
782
+ } else if (command !== item.manifestCommand) {
783
+ throw new Error(
784
+ `Captain execution config catalog.${key} changed its manifest command without an override`,
610
785
  );
611
- return { code: EXIT.suspended };
786
+ }
787
+ if (
788
+ !Array.isArray(item.requiredRoleIds) ||
789
+ item.requiredRoleIds.some(
790
+ (role) => typeof role !== 'string' || role.trim().length === 0,
791
+ ) ||
792
+ new Set(item.requiredRoleIds).size !== item.requiredRoleIds.length
793
+ ) {
794
+ throw new Error(`Captain execution config catalog.${key}.requiredRoleIds is invalid`);
795
+ }
796
+ const playerIds = requireRecord(
797
+ item.playerIds,
798
+ `Captain execution config catalog.${key}.playerIds`,
799
+ );
800
+ if (Object.keys(playerIds).length === 0) {
801
+ throw new Error(`Captain execution config catalog.${key}.playerIds must not be empty`);
802
+ }
803
+ for (const required of item.requiredRoleIds) {
804
+ if (!Object.hasOwn(playerIds, required)) {
805
+ throw new Error(
806
+ `Captain execution config catalog.${key} has no mapped player for required role ${JSON.stringify(required)}`,
807
+ );
808
+ }
809
+ }
810
+ for (const [role, playerId] of Object.entries(playerIds)) {
811
+ requireNonblank(role, `catalog.${key}.playerIds role`);
812
+ if (role === 'captain') {
813
+ throw new Error(`Captain execution config catalog.${key} uses the reserved role "captain"`);
814
+ }
815
+ requireNonblank(playerId, `catalog.${key}.playerIds.${role}`);
816
+ if (playerId !== `${key}-${role}`) {
817
+ throw new Error(
818
+ `Captain execution config catalog.${key}.playerIds.${role} is not canonical`,
819
+ );
820
+ }
821
+ expectedPlayerIds.push(playerId);
822
+ }
823
+ requireRecord(item.options, `Captain execution config catalog.${key}.options`);
824
+ }
825
+ if (new Set(expectedPlayerIds).size !== expectedPlayerIds.length) {
826
+ throw new Error('Captain execution config maps a host player more than once');
827
+ }
828
+ const actualPlayerIds = config.players.map((player, index) =>
829
+ requireNonblank(
830
+ requireRecord(player, `Captain execution config.players[${index}]`).id,
831
+ `Captain execution config.players[${index}].id`,
832
+ ),
833
+ );
834
+ if (!isDeepStrictEqual(actualPlayerIds, expectedPlayerIds)) {
835
+ throw new Error('Captain execution config players do not match the frozen catalog mapping');
612
836
  }
613
- }
614
837
 
615
- function renderOutput(output) {
616
- if (output === null || output === undefined) return '';
617
- if (typeof output === 'string') return output;
838
+ // Round-trip the stored agents through the installed cligent validator. No
839
+ // user config participates, and equality prevents defaults or coercions
840
+ // from silently changing the frozen lineup.
841
+ const firstVisible = Object.values(catalogItems[0][1].playerIds);
842
+ const normalizedHost = await normalizeHostConfig({
843
+ captain: {
844
+ ...config.captain,
845
+ from: PLAYBOOK_CAPTAIN_MODULE,
846
+ options: {},
847
+ },
848
+ players: config.players,
849
+ layout: { initialVisible: firstVisible },
850
+ });
851
+ const {
852
+ from: _captainFrom,
853
+ options: _captainOptions,
854
+ ...normalizedCaptain
855
+ } = normalizedHost.captain;
618
856
  if (
619
- typeof output === 'object' &&
620
- typeof (output.response ?? output.finalText) === 'string'
857
+ !isDeepStrictEqual(normalizedCaptain, config.captain) ||
858
+ !isDeepStrictEqual(normalizedHost.players, config.players)
621
859
  ) {
622
- return output.response ?? output.finalText;
860
+ throw new Error('Captain execution config agents are not canonical for this cligent host');
623
861
  }
624
- return JSON.stringify(output);
625
- }
626
-
627
- function toPlayerResult(result) {
628
- return {
629
- status: result.status,
630
- ...(result.finalText === undefined ? {} : { finalText: result.finalText }),
631
- ...(result.resumeToken ? { resumeToken: result.resumeToken } : {}),
632
- ...(result.error ? { error: result.error } : {}),
633
- };
634
- }
635
-
636
- function playersFromSpecs(roleSpecs) {
637
- return [...roleSpecs].map(([role, spec]) => ({
638
- id: role,
639
- adapter: spec.adapter,
640
- ...(spec.model ? { model: spec.model } : {}),
641
- }));
642
- }
643
-
644
- // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
645
- // Cligent's codex adapter rejects any allowedTools value — including the
646
- // empty list that expresses tool-free — so a control call that requests one
647
- // fails before the model is reached. Omission is the only way such an
648
- // adapter can run a control call; isolation then rests on the prompt.
649
- const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT = new Set(['codex']);
650
-
651
- // Keep requesting enforcement whenever the adapter is unknown, so the
652
- // DR-013 guarantee holds by default.
653
- function controlCallToolOptions(captainAdapter) {
654
- if (ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)) return {};
655
- return { allowedTools: [] };
656
- }
657
862
 
658
- // PBCLI-19/26: returns a diagnostic for the first invalid spec an
659
- // unknown adapter or an effort the adapter does not support — or
660
- // undefined when every spec resolves. The caller must compare against
661
- // undefined.
662
- function specsDiagnostic(specs) {
663
- for (const spec of specs) {
664
- if (
665
- !isAgentSpec(spec) ||
666
- !Object.prototype.hasOwnProperty.call(ADAPTER_LOADERS, spec.adapter)
667
- ) {
668
- const adapter = isAgentSpec(spec)
669
- ? spec.adapter
670
- : String(spec?.adapter);
671
- return `unknown adapter "${adapter}"`;
863
+ // Preserve the complete-catalog preparation transaction: prepare every
864
+ // stored canonical module before importing any. A hook may provision the
865
+ // module's dependencies but cannot rewrite the frozen module identity.
866
+ for (const [id, item] of catalogItems) {
867
+ if (prepareRegistryModule === undefined) continue;
868
+ let prepared;
869
+ try {
870
+ prepared = await prepareRegistryModule({
871
+ id,
872
+ from: item.from,
873
+ authoredFrom: item.from,
874
+ });
875
+ } catch (cause) {
876
+ throw new Error(`stored playbook ${JSON.stringify(id)} failed to prepare: ${message(cause)}`);
672
877
  }
673
- if (spec.effort !== undefined && !isEffortSupported(spec.adapter, spec.effort)) {
674
- const supported = supportedEffortValues(spec.adapter).join(', ');
675
- return `adapter "${spec.adapter}" does not support effort "${spec.effort}" (supported: ${supported})`;
878
+ if (prepared !== undefined && prepared !== item.from) {
879
+ throw new Error(
880
+ `stored playbook ${JSON.stringify(id)} preparation changed its frozen module identity`,
881
+ );
676
882
  }
677
883
  }
678
- return undefined;
679
- }
680
-
681
- // PBCLI-40: the replay tail for input the command consumed from stdin —
682
- // the pipe that carried it will not exist when the printed command runs.
683
- // The value rides behind a `--` end-of-options terminator, because quoting
684
- // alone cannot keep a flag-shaped value (`--json`, `--last`, a `-`-leading
685
- // bullet) from being read as an option; where the original invocation
686
- // already activated a terminator of its own, that one is reused — a second
687
- // `--` after the first would itself be positional data on the replay,
688
- // turning a `--json` task into `-- --json`.
689
- function stdinReplayArgs(args, resolved) {
690
- if (args.task !== undefined) return [];
691
- return args.terminated ? [resolved] : ['--', resolved];
692
- }
693
884
 
694
- // PBCLI-39/40: returns the ready-to-write stderr block naming every bound
695
- // adapter whose optional-peer SDK is not installed, or undefined when every
696
- // one of them loads. Runs only after specsDiagnostic has accepted the
697
- // adapter names, so every spec here carries a known adapter.
698
- async function adapterSdksDiagnostic(specs, ctx, stdinArgs = []) {
699
- const adapters = specs.map((spec) => spec.adapter);
700
- const { unusableAdapters } = await checkAdapterSdks(
701
- adapters,
702
- ctx.probeAdapterSdk,
703
- ...(ctx.classifyRuntime ? [ctx.classifyRuntime] : []),
704
- );
705
- if (unusableAdapters.length === 0) return undefined;
706
- const [header, ...commands] = adapterSdkFailureLines(unusableAdapters, {
707
- // PBCLI-40: the ephemeral re-run carries the lineup's full mapped SDK
708
- // set and the original arguments, so it completes in one hop and runs
709
- // exactly as printed. stdinArgs arrive terminator-ready from
710
- // stdinReplayArgs — appended verbatim here, because whether a `--` is
711
- // needed depends on the original invocation's own parse state.
712
- requiredSdks: mappedSdksFor(adapters),
713
- invocation: [...ctx.rawArgv, ...stdinArgs],
714
- ...(ctx.ephemeralNpx !== undefined
715
- ? { ephemeralNpx: ctx.ephemeralNpx }
716
- : {}),
717
- }).filter((line) => line !== '');
718
- // Only the header takes the command prefix; the install lines stay
719
- // copy-pasteable.
720
- return [`playbook run: ${header}`, ...commands]
721
- .map((line) => `${line}\n`)
722
- .join('');
723
- }
724
-
725
- function isAgentSpec(spec) {
726
- return (
727
- typeof spec === 'object' &&
728
- spec !== null &&
729
- typeof spec.adapter === 'string' &&
730
- spec.adapter.length > 0 &&
731
- (spec.model === undefined || typeof spec.model === 'string') &&
732
- (spec.effort === undefined ||
733
- (typeof spec.effort === 'string' && spec.effort.length > 0))
734
- );
735
- }
736
-
737
- // PBCLI-29: the run host reads the same user config file the interactive
738
- // launcher resolves. The resolver is imported lazily: a static import of
739
- // ./playbook.js would deadlock the CLI entry — playbook.js is still
740
- // mid-evaluation of its own top-level await when it dynamically imports
741
- // this module, and a circular static edge back to it can never settle.
742
- // The launcher always injects userConfigPath, so this default runs only
743
- // for direct runPlaybookRun callers, where playbook.js is not evaluating.
744
- async function defaultUserConfigPath() {
745
- const { resolveUserConfigPath } = await import('./playbook.js');
746
- return resolveUserConfigPath(process.env, process.env.HOME ?? homedir());
747
- }
748
-
749
- // PBCLI-28/29 (DR-017): default agent specs for a first run, read from the
750
- // user config's top-level `run` map. An absent file or absent map is an
751
- // empty default set; a malformed file or block fails closed — the run must
752
- // never silently bind different agents than the user configured. Adapter
753
- // and effort support of the specs actually bound flow through the shared
754
- // specsDiagnostic path.
755
- async function loadRunDefaults(userConfigPath) {
756
- const defaults = { players: new Map() };
757
- let text;
758
- try {
759
- text = await readFile(userConfigPath, 'utf8');
760
- } catch (error) {
761
- if (error?.code === 'ENOENT') return defaults;
762
- throw new Error(`cannot read config ${userConfigPath}: ${message(error)}`);
763
- }
764
- let config;
765
- try {
766
- config = parseYaml(text);
767
- } catch (error) {
768
- throw new Error(`cannot parse config ${userConfigPath}: ${message(error)}`);
769
- }
770
- const run = isPlainMap(config) ? config.run : undefined;
771
- if (run === undefined || run === null) return defaults;
772
- if (!isPlainMap(run)) {
773
- throw new Error(`${userConfigPath}: run must be a map of agent defaults`);
774
- }
775
- if (run.captain !== undefined) {
776
- defaults.captain = parseAgentDefault(run.captain, 'run.captain', userConfigPath);
777
- }
778
- if (run.player !== undefined) {
779
- defaults.player = parseAgentDefault(run.player, 'run.player', userConfigPath);
780
- }
781
- if (run.players !== undefined && run.players !== null) {
782
- if (!isPlainMap(run.players)) {
885
+ for (const [id, item] of catalogItems) {
886
+ let entry;
887
+ try {
888
+ entry = (await loadModule(item.from))?.default;
889
+ } catch (cause) {
890
+ throw new Error(`stored playbook ${JSON.stringify(id)} failed to import: ${message(cause)}`);
891
+ }
892
+ if (!isValidRegistryEntry(entry)) {
893
+ throw new Error(`stored playbook ${JSON.stringify(id)} exposes no valid registry entry`);
894
+ }
895
+ if (
896
+ entry.id !== id ||
897
+ entry.command !== item.manifestCommand ||
898
+ entry.intent !== item.intent ||
899
+ !isDeepStrictEqual(entry.requiredRoleIds, item.requiredRoleIds)
900
+ ) {
783
901
  throw new Error(
784
- `${userConfigPath}: run.players must be a map of <role>: <agent>`,
902
+ `stored playbook ${JSON.stringify(id)} no longer matches its recorded manifest identity`,
785
903
  );
786
904
  }
787
- for (const [role, value] of Object.entries(run.players)) {
788
- defaults.players.set(
789
- role,
790
- parseAgentDefault(value, `run.players.${role}`, userConfigPath),
905
+ try {
906
+ entry.validateOptions(cloneJson(item.options));
907
+ } catch (cause) {
908
+ throw new Error(
909
+ `stored playbook ${JSON.stringify(id)} options are no longer compatible: ${message(cause)}`,
791
910
  );
792
911
  }
793
912
  }
794
- return defaults;
913
+ return config;
795
914
  }
796
915
 
797
- function parseAgentDefault(value, key, userConfigPath) {
798
- if (typeof value !== 'string' || value.length === 0) {
916
+ function adaptersFromExecutionConfig(config) {
917
+ return [
918
+ ...new Set([
919
+ config.captain.adapter,
920
+ ...config.players.map((player) => player.adapter),
921
+ ]),
922
+ ];
923
+ }
924
+
925
+ function assertLogicalSessionIdDistinct(record) {
926
+ if (
927
+ record.snapshot.captain?.sessionId === record.sessionId ||
928
+ (Array.isArray(record.snapshot.issuedSessionIds) &&
929
+ record.snapshot.issuedSessionIds.includes(record.sessionId))
930
+ ) {
799
931
  throw new Error(
800
- `${userConfigPath}: ${key} must be an <adapter>[:<model>][@<effort>] string`,
932
+ 'logical session id collides with an internal Captain session id',
801
933
  );
802
934
  }
803
- try {
804
- return parseAgent(value);
805
- } catch (error) {
806
- throw new Error(`${userConfigPath}: ${key}: ${message(error)}`);
807
- }
808
935
  }
809
936
 
810
- function isPlainMap(value) {
811
- return typeof value === 'object' && value !== null && !Array.isArray(value);
937
+ function memoizedModuleLoader(loadModule) {
938
+ const modules = new Map();
939
+ return (specifier) => {
940
+ if (!modules.has(specifier)) {
941
+ modules.set(specifier, Promise.resolve().then(() => loadModule(specifier)));
942
+ }
943
+ return modules.get(specifier);
944
+ };
812
945
  }
813
946
 
814
- // PBCLI-23: the session store honors XDG_STATE_HOME at invocation time.
815
- export function defaultSessionsDir(env = process.env) {
816
- const stateHome =
817
- typeof env.XDG_STATE_HOME === 'string' && env.XDG_STATE_HOME.trim() !== ''
818
- ? env.XDG_STATE_HOME
819
- : join(homedir(), '.local', 'state');
820
- return join(stateHome, 'playbook', 'sessions');
947
+ function isValidRegistryEntry(value) {
948
+ return (
949
+ value !== null &&
950
+ typeof value === 'object' &&
951
+ !Array.isArray(value) &&
952
+ typeof value.id === 'string' &&
953
+ value.id.trim().length > 0 &&
954
+ typeof value.command === 'string' &&
955
+ value.command.trim().length > 0 &&
956
+ typeof value.intent === 'string' &&
957
+ Array.isArray(value.requiredRoleIds) &&
958
+ value.requiredRoleIds.every(
959
+ (role) => typeof role === 'string' && role.trim().length > 0,
960
+ ) &&
961
+ new Set(value.requiredRoleIds).size === value.requiredRoleIds.length &&
962
+ typeof value.validateOptions === 'function' &&
963
+ typeof value.createRuntime === 'function'
964
+ );
821
965
  }
822
966
 
823
- // PBCLI-23: `--last` selects by the record's own update timestamp, not
824
- // filesystem mtime. Unreadable or foreign .json files are skipped.
825
- async function latestSessionRecord(sessionsDir) {
826
- let names;
827
- try {
828
- names = await readdir(sessionsDir);
829
- } catch {
830
- return undefined;
831
- }
832
- const candidates = await Promise.all(
833
- names
834
- .filter((name) => name.endsWith('.json'))
835
- .map(async (name) => {
836
- const file = join(sessionsDir, name);
837
- try {
838
- const record = JSON.parse(await readFile(file, 'utf8'));
839
- if (!isValidSessionRecord(record)) return undefined;
840
- if (typeof record.updatedAt !== 'string') return undefined;
841
- return { file, record };
842
- } catch {
843
- return undefined;
844
- }
845
- }),
846
- );
847
- let latest;
848
- for (const candidate of candidates) {
849
- if (!candidate) continue;
850
- if (!latest || candidate.record.updatedAt > latest.record.updatedAt) {
851
- latest = candidate;
852
- }
967
+ function requireRecord(value, path) {
968
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
969
+ throw new Error(`${path} must be an object`);
853
970
  }
854
- return latest;
971
+ return value;
855
972
  }
856
973
 
857
- function isValidSessionRecord(record) {
858
- return (
859
- typeof record === 'object' &&
860
- record !== null &&
861
- record.schemaVersion === SESSION_STORE_VERSION &&
862
- typeof record.sessionId === 'string' &&
863
- SESSION_REF_PATTERN.test(record.sessionId) &&
864
- typeof record.playbookId === 'string' &&
865
- typeof record.from === 'string' &&
866
- typeof record.cwd === 'string' &&
867
- isAgentSpec(record.captain) &&
868
- typeof record.players === 'object' &&
869
- record.players !== null &&
870
- Object.values(record.players).every(isAgentSpec) &&
871
- typeof record.option === 'object' &&
872
- record.option !== null &&
873
- typeof record.snapshot === 'object' &&
874
- record.snapshot !== null
875
- );
974
+ function requireExactKeys(value, expected, path) {
975
+ const keys = Object.keys(value);
976
+ const allowed = new Set(expected);
977
+ const unknown = keys.find((key) => !allowed.has(key));
978
+ if (unknown !== undefined) {
979
+ throw new Error(`${path} has unknown field ${JSON.stringify(unknown)}`);
980
+ }
981
+ const missing = expected.find((key) => !Object.hasOwn(value, key));
982
+ if (missing !== undefined) {
983
+ throw new Error(`${path} is missing field ${JSON.stringify(missing)}`);
984
+ }
876
985
  }
877
986
 
878
- // PBCLI-20: default agent — one lazily-built Cligent per role, run through
879
- // the same event drain as tmux-play's host.
880
- function defaultCreateAgent({ adapter, model, effort, cwd, role }) {
881
- let cligent;
882
- return {
883
- async run(prompt, callOptions) {
884
- if (!cligent) {
885
- const AdapterClass = await ADAPTER_LOADERS[adapter]();
886
- // Protected auto mode (as the seeded lineup uses, PBCLI-11) so a
887
- // one-shot run does not block on routine approval prompts.
888
- cligent = new Cligent(new AdapterClass(), {
889
- cwd,
890
- role,
891
- permissions: { mode: 'auto' },
892
- ...(model ? { model } : {}),
893
- ...(effort ? { effort } : {}),
894
- });
895
- }
896
- return runCligentCall(cligent, prompt, callOptions);
897
- },
898
- };
987
+ function requireNonblank(value, path) {
988
+ if (typeof value !== 'string' || value.trim().length === 0) {
989
+ throw new Error(`${path} must be a nonblank string`);
990
+ }
991
+ return value;
899
992
  }
900
993
 
901
- export async function runCligentCall(cligent, prompt, callOptions = {}) {
902
- const { resume, allowedTools, signal } = callOptions;
903
- const gen = cligent.run(prompt, {
904
- ...(signal ? { abortSignal: signal } : {}),
905
- ...(resume !== undefined ? { resume } : {}),
906
- ...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}),
907
- });
908
- const textParts = [];
909
- let done;
910
- let lastError;
911
- let completed = false;
912
- try {
913
- for (;;) {
914
- let next;
915
- try {
916
- next = await gen.next();
917
- } catch (error) {
918
- return { status: signal?.aborted ? 'aborted' : 'error', error: message(error) };
919
- }
920
- if (next.done) {
921
- completed = true;
922
- break;
923
- }
924
- const event = next.value;
925
- if (event.type === 'text' && typeof event.payload?.content === 'string') {
926
- textParts.push(event.payload.content);
927
- } else if (
928
- event.type === 'text_delta' &&
929
- typeof event.payload?.delta === 'string'
930
- ) {
931
- textParts.push(event.payload.delta);
932
- }
933
- if (event.type === 'error') lastError = event.payload?.message;
934
- if (event.type === 'done') done = event.payload;
935
- }
936
- } finally {
937
- if (!completed) {
938
- try {
939
- await gen.return(undefined);
940
- } catch {
941
- // The original outcome is already captured.
942
- }
943
- }
944
- }
945
- const status = done ? mapStatus(done.status) : 'error';
946
- const finalText = done?.result ?? (textParts.length > 0 ? textParts.join('') : undefined);
994
+ function captainOptionsFromConfig(config) {
947
995
  return {
948
- status,
949
- finalText,
950
- ...(done?.resumeToken ? { resumeToken: done.resumeToken } : {}),
951
- ...(status === 'error'
952
- ? { error: done?.result ?? lastError ?? 'agent run failed' }
953
- : {}),
996
+ playbooks: Object.fromEntries(
997
+ Object.entries(config.catalog).map(([id, item]) => [
998
+ id,
999
+ {
1000
+ from: item.from,
1001
+ command: item.command,
1002
+ options: cloneJson(item.options),
1003
+ },
1004
+ ]),
1005
+ ),
1006
+ captainAdapter: config.captain.adapter,
954
1007
  };
955
1008
  }
956
1009
 
957
- function mapStatus(doneStatus) {
958
- if (doneStatus === 'success') return 'ok';
959
- if (doneStatus === 'interrupted') return 'aborted';
960
- return 'error';
1010
+ export async function presentHeadlessCaptainTurn(
1011
+ { sessionId, reply },
1012
+ { stdout, json = false },
1013
+ ) {
1014
+ await writeStream(
1015
+ stdout,
1016
+ `${json ? JSON.stringify({ sessionId, reply }) : reply}\n`,
1017
+ );
961
1018
  }
962
1019
 
963
1020
  export function parseRunArgs(argv) {
964
- const args = {
965
- from: undefined,
966
- task: undefined,
967
- resume: false,
968
- sessionRef: undefined,
969
- last: false,
970
- players: new Map(),
971
- captain: undefined,
972
- option: {},
973
- cwd: undefined,
1021
+ const parsed = {
1022
+ input: undefined,
1023
+ withPaths: [],
1024
+ noProvision: false,
974
1025
  json: false,
975
1026
  verbose: false,
976
- noProvision: false,
1027
+ continue: false,
1028
+ sessionId: undefined,
1029
+ retryUncertain: false,
1030
+ discardUncertain: false,
977
1031
  help: false,
978
1032
  terminated: false,
979
1033
  };
980
1034
  const positionals = [];
981
- for (let i = 0; i < argv.length; i += 1) {
982
- const arg = argv[i];
983
- // PBCLI-40: end-of-options — everything after `--` is positional, so a
984
- // flag-shaped task or reply (a stdin-derived `--json`, a `- bullet`
985
- // line) survives the ephemeral re-run round trip instead of being
986
- // reinterpreted as an option. `terminated` records that this branch
987
- // fired — only a `--` the walk itself treats as the terminator counts,
988
- // never one consumed as an option's value (`--cwd --`) — so the re-run
989
- // builder can reuse an active terminator instead of doubling it.
1035
+ for (let index = 0; index < argv.length; index += 1) {
1036
+ const arg = argv[index];
990
1037
  if (arg === '--') {
991
- args.terminated = true;
992
- positionals.push(...argv.slice(i + 1));
1038
+ parsed.terminated = true;
1039
+ positionals.push(...argv.slice(index + 1));
993
1040
  break;
994
1041
  }
995
- if (arg === '--help' || arg === '-h') args.help = true;
996
- else if (arg === '--json') args.json = true;
997
- else if (arg === '--verbose') args.verbose = true;
998
- else if (arg === '--no-provision') args.noProvision = true;
999
- else if (arg === '--last') args.last = true;
1000
- else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
1001
- else if (arg === '--captain')
1002
- args.captain = parseAgent(takeValue(argv, (i += 1), '--captain'));
1003
- else if (arg === '--player') {
1004
- const [role, agent] = takePair(takeValue(argv, (i += 1), '--player'), '--player');
1005
- args.players.set(role, parseAgent(agent));
1006
- } else if (arg === '--option') {
1007
- const [key, value] = takePair(takeValue(argv, (i += 1), '--option'), '--option');
1008
- args.option[key] = value;
1042
+ if (arg === '--help' || arg === '-h') parsed.help = true;
1043
+ else if (arg === '--json') parsed.json = true;
1044
+ else if (arg === '--verbose') parsed.verbose = true;
1045
+ else if (arg === '--no-provision') parsed.noProvision = true;
1046
+ else if (arg === '--retry-uncertain') {
1047
+ if (parsed.retryUncertain) {
1048
+ throw new Error('--retry-uncertain may be specified only once');
1049
+ }
1050
+ parsed.retryUncertain = true;
1051
+ } else if (arg === '--discard-uncertain') {
1052
+ if (parsed.discardUncertain) {
1053
+ throw new Error('--discard-uncertain may be specified only once');
1054
+ }
1055
+ parsed.discardUncertain = true;
1056
+ }
1057
+ else if (arg === '--continue') {
1058
+ if (parsed.continue) throw new Error('--continue may be specified only once');
1059
+ parsed.continue = true;
1060
+ } else if (arg === '--session') {
1061
+ const value = argv[index + 1];
1062
+ if (value === undefined || value === '') {
1063
+ throw new Error('--session needs a UUID value');
1064
+ }
1065
+ if (parsed.sessionId !== undefined) {
1066
+ throw new Error('--session may be specified only once');
1067
+ }
1068
+ parsed.sessionId = value;
1069
+ index += 1;
1070
+ } else if (arg.startsWith('--session=')) {
1071
+ const value = arg.slice('--session='.length);
1072
+ if (value === '') throw new Error('--session needs a UUID value');
1073
+ if (parsed.sessionId !== undefined) {
1074
+ throw new Error('--session may be specified only once');
1075
+ }
1076
+ parsed.sessionId = value;
1077
+ } else if (arg === '--with') {
1078
+ const value = argv[index + 1];
1079
+ if (value === undefined || value === '') {
1080
+ throw new Error('--with needs a value');
1081
+ }
1082
+ parsed.withPaths.push(value);
1083
+ index += 1;
1084
+ } else if (arg.startsWith('--with=')) {
1085
+ const value = arg.slice('--with='.length);
1086
+ if (value === '') throw new Error('--with needs a value');
1087
+ parsed.withPaths.push(value);
1088
+ } else if (
1089
+ RETIRED_FLAGS.has(arg) ||
1090
+ [...RETIRED_FLAGS].some((flag) => arg.startsWith(`${flag}=`))
1091
+ ) {
1092
+ throw new Error(
1093
+ `${arg.split('=')[0]} was removed; configure the shared Captain session in playbook.config.yaml or a --with overlay`,
1094
+ );
1009
1095
  } else if (arg.startsWith('-')) {
1010
1096
  throw new Error(`unknown option ${arg}`);
1011
- } else positionals.push(arg);
1012
- }
1013
- // PBCLI-22: `playbook run resume <session-id>|--last [reply]`.
1014
- if (positionals[0] === 'resume') {
1015
- args.resume = true;
1016
- let rest = positionals.slice(1);
1017
- if (!args.last) {
1018
- args.sessionRef = rest[0];
1019
- rest = rest.slice(1);
1097
+ } else {
1098
+ positionals.push(arg);
1020
1099
  }
1021
- if (rest.length > 0) args.task = rest.join(' ');
1022
- return args;
1023
1100
  }
1024
- if (args.last) throw new Error('--last applies to `playbook run resume`');
1025
- args.from = positionals[0];
1026
- if (positionals.length > 1) args.task = positionals.slice(1).join(' ');
1027
- return args;
1101
+ if (positionals.length > 1) {
1102
+ throw new Error(
1103
+ 'expected at most one [input] argument; quote multi-word input as one shell argument',
1104
+ );
1105
+ }
1106
+ if (parsed.continue && parsed.sessionId !== undefined) {
1107
+ throw new Error('--continue and --session are mutually exclusive');
1108
+ }
1109
+ if (parsed.retryUncertain && parsed.discardUncertain) {
1110
+ throw new Error(
1111
+ '--retry-uncertain and --discard-uncertain are mutually exclusive',
1112
+ );
1113
+ }
1114
+ if (
1115
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1116
+ parsed.sessionId === undefined
1117
+ ) {
1118
+ throw new Error(
1119
+ '--retry-uncertain and --discard-uncertain require --session <id>',
1120
+ );
1121
+ }
1122
+ if (
1123
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1124
+ (parsed.continue || positionals.length > 0)
1125
+ ) {
1126
+ throw new Error(
1127
+ 'uncertain-turn recovery accepts only an explicit --session and no input',
1128
+ );
1129
+ }
1130
+ if (
1131
+ parsed.discardUncertain &&
1132
+ (parsed.json || parsed.verbose || parsed.noProvision)
1133
+ ) {
1134
+ throw new Error(
1135
+ '--discard-uncertain does not accept --json, --verbose, or --no-provision',
1136
+ );
1137
+ }
1138
+ if (
1139
+ (parsed.continue || parsed.sessionId !== undefined) &&
1140
+ parsed.withPaths.length > 0
1141
+ ) {
1142
+ throw new Error('--with cannot change a frozen continued Captain session');
1143
+ }
1144
+ if (
1145
+ parsed.sessionId !== undefined &&
1146
+ !SESSION_ID_PATTERN.test(parsed.sessionId)
1147
+ ) {
1148
+ throw new Error('--session needs a canonical UUID value');
1149
+ }
1150
+ parsed.input = positionals[0];
1151
+ return parsed;
1028
1152
  }
1029
1153
 
1030
- // PBCLI-19: `<agent>` is `<adapter>[:<model>][@<effort>]`. The effort
1031
- // rides after the last `@` so a model name may itself contain colons
1032
- // (`opencode:ollama/llama3:8b@max`); `claude@high` keeps the default
1033
- // model while setting effort.
1034
- function parseAgent(value) {
1035
- const at = value.lastIndexOf('@');
1036
- const spec = at === -1 ? value : value.slice(0, at);
1037
- const effort = at === -1 ? undefined : value.slice(at + 1);
1038
- if (at !== -1 && !effort) {
1039
- throw new Error(`agent "${value}" has an empty effort after '@'`);
1154
+ async function reportReadinessFailure({
1155
+ stderr,
1156
+ adapters,
1157
+ failingAdapters,
1158
+ unusableAdapters,
1159
+ invocation,
1160
+ ephemeralNpx,
1161
+ }) {
1162
+ if (unusableAdapters.length > 0) {
1163
+ const lines = adapterSdkFailureLines(unusableAdapters, {
1164
+ requiredSdks: mappedSdksFor(adapters),
1165
+ invocation,
1166
+ ...(ephemeralNpx !== undefined ? { ephemeralNpx } : {}),
1167
+ });
1168
+ const [first, ...rest] = lines;
1169
+ await writeStream(
1170
+ stderr,
1171
+ [
1172
+ ...(first ? [`playbook run: ${first}`] : []),
1173
+ ...rest,
1174
+ ]
1175
+ .map((line) => `${line}\n`)
1176
+ .join(''),
1177
+ );
1178
+ }
1179
+ if (failingAdapters.length > 0) {
1180
+ await writeStream(
1181
+ stderr,
1182
+ `playbook run: adapters not ready: ${failingAdapters.join(', ')}\n`,
1183
+ );
1040
1184
  }
1041
- const colon = spec.indexOf(':');
1042
- const adapter = colon === -1 ? spec : spec.slice(0, colon);
1043
- const model = colon === -1 ? undefined : spec.slice(colon + 1);
1044
- return {
1045
- adapter,
1046
- ...(model ? { model } : {}),
1047
- ...(effort ? { effort } : {}),
1048
- };
1049
1185
  }
1050
1186
 
1051
- function takeValue(argv, index, flag) {
1052
- const value = argv[index];
1053
- if (value === undefined) throw new Error(`${flag} needs a value`);
1054
- return value;
1187
+ async function resolveBossInput(input, options, stderr) {
1188
+ let resolved = input;
1189
+ if (resolved === undefined) {
1190
+ try {
1191
+ resolved = await awaitWithAbort(
1192
+ (options.readStdin ?? readAllStdin)(),
1193
+ options.signal,
1194
+ );
1195
+ } catch (error) {
1196
+ await writeStream(
1197
+ stderr,
1198
+ `playbook run: cannot read stdin: ${message(error)}\n`,
1199
+ );
1200
+ return { ok: false };
1201
+ }
1202
+ }
1203
+ if (resolved.trim().length === 0) {
1204
+ await writeStream(
1205
+ stderr,
1206
+ 'playbook run: empty input; pass one argument or pipe a Boss message on stdin\n',
1207
+ );
1208
+ return { ok: false };
1209
+ }
1210
+ return { ok: true, input: resolved };
1055
1211
  }
1056
1212
 
1057
- function takePair(value, flag) {
1058
- const eq = value.indexOf('=');
1059
- if (eq <= 0) throw new Error(`${flag} needs <key>=<value>`);
1060
- return [value.slice(0, eq), value.slice(eq + 1)];
1213
+ async function awaitWithAbort(value, signal) {
1214
+ if (signal === undefined) return value;
1215
+ if (signal.aborted) throw signal.reason ?? new Error('operation aborted');
1216
+ let onAbort;
1217
+ const aborted = new Promise((_, reject) => {
1218
+ onAbort = () => reject(signal.reason ?? new Error('operation aborted'));
1219
+ signal.addEventListener('abort', onAbort, { once: true });
1220
+ });
1221
+ try {
1222
+ return await Promise.race([value, aborted]);
1223
+ } finally {
1224
+ signal.removeEventListener('abort', onAbort);
1225
+ }
1061
1226
  }
1062
1227
 
1063
- function registryImportSpecifier(specifier, cwd) {
1064
- if (
1065
- isAbsolute(specifier) ||
1066
- specifier.startsWith('./') ||
1067
- specifier.startsWith('../') ||
1068
- specifier.startsWith('.\\') ||
1069
- specifier.startsWith('..\\')
1070
- ) {
1071
- return pathToFileURL(resolve(cwd, specifier)).href;
1228
+ function throwIfAborted(signal) {
1229
+ if (signal?.aborted) {
1230
+ throw signal.reason ?? new Error('operation aborted');
1072
1231
  }
1073
- return specifier;
1074
1232
  }
1075
1233
 
1076
- // PBCLI-37: the absolute file path of a filesystem `<from>` (path or
1077
- // file: URL), or undefined for a bare package specifier — those resolve
1078
- // from the host's own module tree and are neither probed nor provisioned.
1079
- function moduleFilePath(specifier, cwd) {
1080
- if (specifier.startsWith('file:')) return fileURLToPath(specifier);
1081
- if (
1082
- isAbsolute(specifier) ||
1083
- specifier.startsWith('./') ||
1084
- specifier.startsWith('../') ||
1085
- specifier.startsWith('.\\') ||
1086
- specifier.startsWith('..\\')
1087
- ) {
1088
- return resolve(cwd, specifier);
1234
+ function registryPreparer(args, options, stderr) {
1235
+ return (
1236
+ options.prepareRegistryModule ??
1237
+ prepareConfiguredRegistries({
1238
+ enabled: !args.noProvision,
1239
+ stderr,
1240
+ hostRoots: options.hostRoots,
1241
+ commandName: 'playbook run',
1242
+ })
1243
+ );
1244
+ }
1245
+
1246
+ function createAttemptId(options) {
1247
+ const attemptId = (options.createAttemptId ?? randomUUID)();
1248
+ if (typeof attemptId !== 'string' || !UUID_PATTERN.test(attemptId)) {
1249
+ throw new Error(
1250
+ `uncertain turn attempt id generator returned a non-UUID value: ${JSON.stringify(attemptId)}`,
1251
+ );
1089
1252
  }
1090
- return undefined;
1253
+ return attemptId;
1091
1254
  }
1092
1255
 
1093
- // PBCLI-36/37: probe-and-provision for a filesystem registry module.
1094
- // Returns {} to proceed or { code } after a reported provisioning fault.
1095
- async function maybeProvision(specifier, args, ctx) {
1096
- const modulePath = moduleFilePath(specifier, ctx.cwdDefault);
1097
- if (modulePath === undefined) return {};
1098
- return provisionEngine({
1099
- modulePath,
1100
- stderr: ctx.stderr,
1101
- enabled: !args.noProvision,
1102
- hostRoots: ctx.hostRoots,
1103
- });
1256
+ async function releaseLease(lease) {
1257
+ if (lease === undefined) return undefined;
1258
+ try {
1259
+ await lease.release();
1260
+ return undefined;
1261
+ } catch (error) {
1262
+ return error;
1263
+ }
1104
1264
  }
1105
1265
 
1106
- function isValidRegistryEntry(value) {
1107
- return (
1108
- typeof value === 'object' &&
1109
- value !== null &&
1110
- typeof value.id === 'string' &&
1111
- typeof value.command === 'string' &&
1112
- typeof value.intent === 'string' &&
1113
- Array.isArray(value.requiredRoleIds) &&
1114
- typeof value.validateOptions === 'function' &&
1115
- typeof value.createRuntime === 'function'
1266
+ async function reportUncertainSession(stderr, sessionId) {
1267
+ await writeStream(
1268
+ stderr,
1269
+ [
1270
+ `playbook run: Captain session ${JSON.stringify(sessionId)} has an uncertain turn and will not be replayed automatically`,
1271
+ 'Retry may duplicate external effects from the interrupted attempt; discard abandons that attempted turn.',
1272
+ `playbook run --session ${sessionId} --retry-uncertain`,
1273
+ `playbook run --session ${sessionId} --discard-uncertain`,
1274
+ '',
1275
+ ].join('\n'),
1116
1276
  );
1117
1277
  }
1118
1278
 
1279
+ function replayInvocation(argv, args, input) {
1280
+ if (args.retryUncertain || args.discardUncertain) {
1281
+ return ['run', ...argv];
1282
+ }
1283
+ if (args.input !== undefined) return ['run', ...argv];
1284
+ return args.terminated
1285
+ ? ['run', ...argv, input]
1286
+ : ['run', ...argv, '--', input];
1287
+ }
1288
+
1289
+ function cloneJson(value) {
1290
+ return JSON.parse(JSON.stringify(value));
1291
+ }
1292
+
1119
1293
  async function readAllStdin() {
1120
1294
  const chunks = [];
1121
- for await (const chunk of process.stdin) chunks.push(chunk);
1295
+ for await (const chunk of process.stdin) {
1296
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1297
+ }
1122
1298
  return Buffer.concat(chunks).toString('utf8');
1123
1299
  }
1124
1300
 
1125
- function runHelpText() {
1301
+ async function writeStream(stream, text) {
1302
+ const ready = stream.write(text);
1303
+ if (ready !== false || typeof stream.once !== 'function') return;
1304
+ await new Promise((resolvePromise, rejectPromise) => {
1305
+ const onDrain = () => {
1306
+ stream.off?.('error', onError);
1307
+ resolvePromise();
1308
+ };
1309
+ const onError = (error) => {
1310
+ stream.off?.('drain', onDrain);
1311
+ rejectPromise(error);
1312
+ };
1313
+ stream.once('drain', onDrain);
1314
+ stream.once('error', onError);
1315
+ });
1316
+ }
1317
+
1318
+ function runHelpText(userConfigPath) {
1126
1319
  return [
1127
1320
  'Usage:',
1128
- ' playbook run <from> [task] [options]',
1129
- ' playbook run resume <session-id> [reply] [options]',
1130
- ' playbook run resume --last [reply] [options]',
1321
+ ' playbook run [--with <path>]... [--no-provision] [--json]',
1322
+ ' [--verbose] [--] [input]',
1323
+ ' playbook run (--continue | --session <id>) [--no-provision]',
1324
+ ' [--json] [--verbose] [--] [reply]',
1325
+ ' playbook run --session <id> --retry-uncertain [--no-provision]',
1326
+ ' playbook run --session <id> --discard-uncertain',
1131
1327
  '',
1132
- ' <from> registry module specifier (package subpath, path, or file: URL)',
1133
- ' [task] Boss intent; read from stdin when omitted',
1134
- ' [reply] Boss reply to a parked session; read from stdin when omitted',
1135
- ' -- end of options; use before a task or reply that starts with -',
1328
+ ' [input] one exact Boss message; read verbatim from stdin when omitted',
1329
+ ' -- end options so a flag-shaped input remains Boss text',
1136
1330
  '',
1137
- 'Options:',
1138
- ' --player <role>=<agent> bind a required role (repeatable)',
1139
- ' --captain <agent> set the captain/judge agent',
1140
- ' --option <key>=<value> playbook option slice (repeatable)',
1141
- ' --cwd <dir> agents working directory',
1142
- ' --json print one JSON envelope (outcome, sessionId,',
1143
- ' output or questions) instead of plain text',
1144
- ' --last resume the most recently parked session',
1145
- ' --verbose forward telemetry topics to stderr',
1146
- ' --no-provision never create engine links beside a',
1147
- ' filesystem <from> module',
1148
- ' -h, --help print this help',
1331
+ `Default config: ${userConfigPath}`,
1149
1332
  '',
1150
- ' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
1151
- ' claude, codex, gemini, opencode e.g. codex:gpt-5.5@xhigh, or',
1152
- ' claude@high for the default model at high effort. Every role and',
1153
- ' the captain default to claude, unless a top-level run: block in',
1154
- ' ${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml',
1155
- ' supplies defaults run.captain, run.players.<role>, or the',
1156
- ' run.player catch-all for other roles; flags override per role.',
1333
+ 'A new run uses the same configured Captain, enabled playbooks, players,',
1334
+ 'options, overlays, provisioning, and readiness gate as interactive',
1335
+ '`playbook`. Enable an external registry in that config, then invoke its',
1336
+ 'effective /command through Captain. The former positional registry,',
1337
+ 'resume, and run-only binding surfaces have been removed.',
1338
+ 'A continued run restores the stored execution config and working',
1339
+ 'directory; it never re-reads current config or --with overlays.',
1157
1340
  '',
1158
- ' When a playbook needs a Boss reply, the run prints the question,',
1159
- ' parks the session under',
1160
- ' ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions, and exits 3;',
1161
- ' answer with `playbook run resume`. Bindings are stored with the',
1162
- ' session, so resume takes no --player/--captain/--option/--cwd.',
1341
+ 'Options:',
1342
+ ' --with <path> overlay a generic config fragment (repeatable)',
1343
+ ' --no-provision do not provision thin filesystem registry engines',
1344
+ ' --continue reply to the latest durable Captain session',
1345
+ ' --session <id> reply to one durable Captain session UUID',
1346
+ ' --retry-uncertain retry that session\'s exact recorded uncertain input',
1347
+ ' --discard-uncertain discard that session\'s uncertain attempt',
1348
+ ' --json print exactly {"sessionId", "reply"}',
1349
+ ' --verbose print Captain telemetry topics to stderr',
1350
+ ' -h, --help print this help without reading input or config',
1163
1351
  '',
1164
1352
  ].join('\n');
1165
1353
  }