@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.
- package/README.md +14 -10
- package/docs/cli.md +102 -57
- package/docs/configuration.md +57 -16
- package/package.json +3 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1171 -983
- package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +864 -68
- package/reference/sdlc/code.playbook/playbook-captain.ts +1296 -64
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +4 -14
- package/reference/sdlc/decide.playbook/decide.playbook.js +77 -13
- package/reference/sdlc/decide.playbook/decide.playbook.ts +93 -14
- package/slc/link.md +35 -12
- package/src/runtime.d.ts +14 -2
- package/src/runtime.ts +26 -6
- package/src/xstate-playbook-runtime.js +64 -14
- package/src/xstate-playbook-runtime.ts +79 -13
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +359 -57
- package/src/xstate-runtime.ts +491 -71
|
@@ -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/
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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,
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
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
|
|
105
|
-
return { code: EXIT.
|
|
63
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
64
|
+
return { code: EXIT.argument };
|
|
106
65
|
}
|
|
107
66
|
if (args.help) {
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
const
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
// runResume rebuilds the lineup stored with the session.
|
|
141
|
-
let runDefaults;
|
|
91
|
+
let store;
|
|
142
92
|
try {
|
|
143
|
-
|
|
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
|
|
146
|
-
return { code: EXIT.
|
|
105
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
106
|
+
return { code: EXIT.argument };
|
|
147
107
|
}
|
|
148
108
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
300
|
+
const adapters = adaptersFromExecutionConfig(config);
|
|
301
|
+
const readiness = checkReadiness(adapters, env, home);
|
|
302
|
+
let sdkReadiness;
|
|
190
303
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
351
|
+
return { code: EXIT.turn };
|
|
255
352
|
}
|
|
256
|
-
|
|
353
|
+
return { code: EXIT.argument };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (lease === undefined) {
|
|
257
357
|
try {
|
|
258
|
-
|
|
358
|
+
throwIfAborted(options.signal);
|
|
359
|
+
lease = await store.acquire(sessionId);
|
|
360
|
+
throwIfAborted(options.signal);
|
|
259
361
|
} catch (error) {
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
386
|
+
return { code: EXIT.turn };
|
|
264
387
|
}
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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.
|
|
491
|
+
return { code: EXIT.turn };
|
|
302
492
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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.
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
505
|
+
await presentHeadlessCaptainTurn(settled, {
|
|
506
|
+
stdout,
|
|
507
|
+
json: args.json,
|
|
337
508
|
});
|
|
338
509
|
} catch (error) {
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
514
|
+
return { code: EXIT.turn };
|
|
347
515
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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-
|
|
361
|
-
//
|
|
362
|
-
async function
|
|
363
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 (
|
|
434
|
-
throw new Error(
|
|
582
|
+
if (shell === undefined) {
|
|
583
|
+
throw new Error('Captain shell host initialized without a shell');
|
|
435
584
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
492
|
-
|
|
601
|
+
uncertainRecord = await beforeBossTurn?.(baselineSnapshot);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
throw new HeadlessHostSetupError(error);
|
|
493
604
|
}
|
|
494
|
-
|
|
495
|
-
if (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
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
|
-
|
|
509
|
-
return { code: EXIT.failed };
|
|
510
|
-
} finally {
|
|
511
|
-
if (!parked) {
|
|
645
|
+
if (host !== undefined) {
|
|
512
646
|
try {
|
|
513
|
-
await
|
|
647
|
+
await host.dispose();
|
|
514
648
|
} catch {
|
|
515
|
-
//
|
|
649
|
+
// Preserve the turn/capture failure as the primary diagnostic.
|
|
516
650
|
}
|
|
517
651
|
}
|
|
652
|
+
throw error;
|
|
518
653
|
}
|
|
519
654
|
}
|
|
520
655
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
})
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
768
|
+
}
|
|
769
|
+
if (seenCommands.has(command)) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Captain execution config has duplicate effective command ${JSON.stringify(command)}`,
|
|
603
772
|
);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
620
|
-
|
|
857
|
+
!isDeepStrictEqual(normalizedCaptain, config.captain) ||
|
|
858
|
+
!isDeepStrictEqual(normalizedHost.players, config.players)
|
|
621
859
|
) {
|
|
622
|
-
|
|
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
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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 (
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
902
|
+
`stored playbook ${JSON.stringify(id)} no longer matches its recorded manifest identity`,
|
|
785
903
|
);
|
|
786
904
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
913
|
+
return config;
|
|
795
914
|
}
|
|
796
915
|
|
|
797
|
-
function
|
|
798
|
-
|
|
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
|
-
|
|
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
|
|
811
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
typeof
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
971
|
+
return value;
|
|
855
972
|
}
|
|
856
973
|
|
|
857
|
-
function
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
|
982
|
-
const arg = argv[
|
|
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
|
-
|
|
992
|
-
positionals.push(...argv.slice(
|
|
1038
|
+
parsed.terminated = true;
|
|
1039
|
+
positionals.push(...argv.slice(index + 1));
|
|
993
1040
|
break;
|
|
994
1041
|
}
|
|
995
|
-
if (arg === '--help' || arg === '-h')
|
|
996
|
-
else if (arg === '--json')
|
|
997
|
-
else if (arg === '--verbose')
|
|
998
|
-
else if (arg === '--no-provision')
|
|
999
|
-
else if (arg === '--
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
|
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 (
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
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
|
|
1052
|
-
|
|
1053
|
-
if (
|
|
1054
|
-
|
|
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
|
|
1058
|
-
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
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
|
|
1064
|
-
if (
|
|
1065
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
1253
|
+
return attemptId;
|
|
1091
1254
|
}
|
|
1092
1255
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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)
|
|
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
|
|
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 <
|
|
1129
|
-
'
|
|
1130
|
-
' playbook run
|
|
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
|
-
'
|
|
1133
|
-
'
|
|
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
|
-
|
|
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
|
-
'
|
|
1151
|
-
'
|
|
1152
|
-
'
|
|
1153
|
-
'
|
|
1154
|
-
'
|
|
1155
|
-
'
|
|
1156
|
-
'
|
|
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
|
-
'
|
|
1159
|
-
'
|
|
1160
|
-
'
|
|
1161
|
-
'
|
|
1162
|
-
' session
|
|
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
|
}
|