@sublang/playbook 6.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -11
- package/docs/cli.md +158 -68
- package/docs/configuration.md +246 -108
- package/docs/embedding.md +71 -25
- package/package.json +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1164 -991
- package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.js +7 -11
- package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
- package/reference/sdlc/code.playbook/code.gears.md +1 -1
- package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
- package/reference/sdlc/code.playbook/code.playbook.js +12 -13
- package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
- package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
- package/reference/sdlc/code.playbook/code.registry.js +3 -10
- package/reference/sdlc/code.playbook/code.registry.ts +7 -32
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
- package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
- package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
- package/reference/sdlc/decide.md +4 -4
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
- package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
- package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
- package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
- package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
- package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
- package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
- package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
- package/reference/sdlc/review.md +4 -5
- package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
- package/reference/sdlc/review.playbook/review.fsm.js +30 -24
- package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
- package/reference/sdlc/review.playbook/review.gears.md +6 -5
- package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
- package/reference/sdlc/review.playbook/review.playbook.js +16 -21
- package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
- package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
- package/reference/sdlc/review.playbook/review.registry.js +3 -16
- package/reference/sdlc/review.playbook/review.registry.ts +7 -38
- package/slc/gears2fsm.md +27 -23
- package/slc/link.md +140 -97
- package/slc/text2gears.md +19 -18
- package/src/runtime.d.ts +24 -8
- package/src/runtime.ts +29 -13
- package/src/xstate-playbook-runtime.d.ts +21 -17
- package/src/xstate-playbook-runtime.js +301 -159
- package/src/xstate-playbook-runtime.ts +405 -186
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +403 -62
- package/src/xstate-runtime.ts +566 -78
|
@@ -1,1165 +1,1338 @@
|
|
|
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 {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
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 { resolve } from 'node:path';
|
|
12
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
13
|
+
import { createTmuxPlayRuntime } from '@sublang/cligent/tmux-play';
|
|
14
|
+
import { createPlaybookCaptainShell } from '../playbook-captain.js';
|
|
32
15
|
import {
|
|
33
16
|
adapterSdkFailureLines,
|
|
34
17
|
checkAdapterSdks,
|
|
35
18
|
mappedSdksFor,
|
|
36
19
|
probeAdapterSdk,
|
|
37
20
|
} from './adapter-sdk.js';
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
21
|
+
import {
|
|
22
|
+
checkReadiness,
|
|
23
|
+
loadLaunchPlan,
|
|
24
|
+
projectHostAgent,
|
|
25
|
+
resolveUserConfigPath,
|
|
26
|
+
} from './launch-config.js';
|
|
27
|
+
import { prepareConfiguredRegistries } from './provision.js';
|
|
28
|
+
import {
|
|
29
|
+
assertCaptainSessionExecutionCompatible,
|
|
30
|
+
captainSessionSelectedMembers,
|
|
31
|
+
createCaptainSessionStore,
|
|
32
|
+
projectCaptainSessionStructure,
|
|
33
|
+
SESSION_ID_PATTERN,
|
|
34
|
+
validateCaptainSessionExecutionProjection,
|
|
35
|
+
validateCaptainSessionRecord,
|
|
36
|
+
} from './session-store.js';
|
|
50
37
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
38
|
+
const EXIT = { ok: 0, argument: 1, turn: 2 };
|
|
39
|
+
const UUID_PATTERN = SESSION_ID_PATTERN;
|
|
40
|
+
class HeadlessHostSetupError extends Error {
|
|
41
|
+
constructor(cause) {
|
|
42
|
+
super(message(cause));
|
|
43
|
+
this.name = 'HeadlessHostSetupError';
|
|
44
|
+
this.cause = cause;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const RETIRED_FLAGS = new Set([
|
|
48
|
+
'--player',
|
|
49
|
+
'--captain',
|
|
50
|
+
'--option',
|
|
51
|
+
'--cwd',
|
|
52
|
+
'--last',
|
|
53
|
+
'--config',
|
|
54
|
+
]);
|
|
61
55
|
|
|
62
56
|
export async function runPlaybookRun(options = {}) {
|
|
63
|
-
const argv = options.argv ?? [];
|
|
57
|
+
const argv = [...(options.argv ?? [])];
|
|
64
58
|
const stdout = options.stdout ?? process.stdout;
|
|
65
59
|
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
60
|
|
|
100
61
|
let args;
|
|
101
62
|
try {
|
|
102
63
|
args = parseRunArgs(argv);
|
|
103
64
|
} catch (error) {
|
|
104
|
-
stderr
|
|
105
|
-
return { code: EXIT.
|
|
65
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
66
|
+
return { code: EXIT.argument };
|
|
106
67
|
}
|
|
107
68
|
if (args.help) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 };
|
|
69
|
+
const env = options.env ?? process.env;
|
|
70
|
+
const home = options.homeDir ?? env.HOME ?? homedir();
|
|
71
|
+
const userConfigPath =
|
|
72
|
+
options.userConfigPath ?? resolveUserConfigPath(env, home);
|
|
73
|
+
await writeStream(stdout, runHelpText(userConfigPath));
|
|
74
|
+
return { code: EXIT.ok };
|
|
121
75
|
}
|
|
122
76
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const loaded = await loadRegistryEntry(args.from, ctx);
|
|
129
|
-
if (loaded.code !== undefined) return loaded;
|
|
130
|
-
const { entry } = loaded;
|
|
77
|
+
const env = options.env ?? process.env;
|
|
78
|
+
const home = options.homeDir ?? env.HOME ?? homedir();
|
|
79
|
+
const recovering = args.retryUncertain || args.discardUncertain;
|
|
80
|
+
const continuing = args.continue || args.sessionId !== undefined;
|
|
81
|
+
let input = args.input;
|
|
131
82
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
83
|
+
// PBCLI-18/40: a fresh piped producer is drained before config,
|
|
84
|
+
// preparation, import, or readiness. Continuations first inspect the
|
|
85
|
+
// selected record so an uncertain turn never blocks waiting for input;
|
|
86
|
+
// explicit recovery never reads input at all.
|
|
87
|
+
if (!continuing && !recovering) {
|
|
88
|
+
const resolvedInput = await resolveBossInput(input, options, stderr);
|
|
89
|
+
if (!resolvedInput.ok) return { code: EXIT.argument };
|
|
90
|
+
input = resolvedInput.input;
|
|
137
91
|
}
|
|
138
92
|
|
|
139
|
-
|
|
140
|
-
// runResume rebuilds the lineup stored with the session.
|
|
141
|
-
let runDefaults;
|
|
93
|
+
let store;
|
|
142
94
|
try {
|
|
143
|
-
|
|
95
|
+
store =
|
|
96
|
+
options.sessionStore ??
|
|
97
|
+
createCaptainSessionStore({
|
|
98
|
+
env,
|
|
99
|
+
homeDir: home,
|
|
100
|
+
...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
|
|
101
|
+
...(options.now ? { now: options.now } : {}),
|
|
102
|
+
...(options.createSessionTempId
|
|
103
|
+
? { createTempId: options.createSessionTempId }
|
|
104
|
+
: {}),
|
|
105
|
+
});
|
|
144
106
|
} catch (error) {
|
|
145
|
-
stderr
|
|
146
|
-
return { code: EXIT.
|
|
147
|
-
}
|
|
148
|
-
|
|
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
|
-
]),
|
|
161
|
-
);
|
|
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 };
|
|
166
|
-
}
|
|
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 };
|
|
107
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
108
|
+
return { code: EXIT.argument };
|
|
175
109
|
}
|
|
176
110
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
111
|
+
let priorRecord;
|
|
112
|
+
let lease;
|
|
113
|
+
let sessionId;
|
|
114
|
+
let config;
|
|
115
|
+
let cwd;
|
|
116
|
+
let restoreSnapshot;
|
|
117
|
+
const loadModule = memoizedModuleLoader(
|
|
118
|
+
options.loadModule ?? ((specifier) => import(specifier)),
|
|
183
119
|
);
|
|
184
|
-
|
|
185
|
-
stderr.write(sdkError);
|
|
186
|
-
return { code: EXIT.arg };
|
|
187
|
-
}
|
|
120
|
+
const prepareRegistryModule = registryPreparer(args, options, stderr);
|
|
188
121
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
122
|
+
if (continuing) {
|
|
123
|
+
try {
|
|
124
|
+
if (args.sessionId === undefined) {
|
|
125
|
+
const selected = validateCaptainSessionRecord(
|
|
126
|
+
await awaitWithAbort(
|
|
127
|
+
store.latest({
|
|
128
|
+
onLegacyRecord: ({ sessionId: legacyId, path }) =>
|
|
129
|
+
writeStream(
|
|
130
|
+
stderr,
|
|
131
|
+
`playbook run: skipping legacy Captain session ${JSON.stringify(legacyId)} at ${JSON.stringify(path)} because schema 2 has incompatible player identity; move it outside the sessions directory or remove it to silence this warning\n`,
|
|
132
|
+
),
|
|
133
|
+
}),
|
|
134
|
+
options.signal,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
sessionId = selected.sessionId;
|
|
138
|
+
} else {
|
|
139
|
+
sessionId = args.sessionId;
|
|
140
|
+
}
|
|
141
|
+
throwIfAborted(options.signal);
|
|
142
|
+
lease = await store.acquire(sessionId);
|
|
143
|
+
throwIfAborted(options.signal);
|
|
144
|
+
const authoritative = await lease.read();
|
|
145
|
+
throwIfAborted(options.signal);
|
|
146
|
+
if (authoritative === undefined) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Captain session ${JSON.stringify(sessionId)} does not exist`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
priorRecord = validateCaptainSessionRecord(authoritative);
|
|
152
|
+
assertLogicalSessionIdDistinct(priorRecord);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const releaseError = await releaseLease(lease);
|
|
155
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
156
|
+
if (releaseError !== undefined) {
|
|
157
|
+
await writeStream(
|
|
158
|
+
stderr,
|
|
159
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
160
|
+
);
|
|
161
|
+
return { code: EXIT.turn };
|
|
162
|
+
}
|
|
163
|
+
return { code: EXIT.argument };
|
|
164
|
+
}
|
|
199
165
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
166
|
+
if (priorRecord.state === 'uncertain') {
|
|
167
|
+
if (args.discardUncertain) {
|
|
168
|
+
try {
|
|
169
|
+
throwIfAborted(options.signal);
|
|
170
|
+
const record = await lease.discard({
|
|
171
|
+
attemptId: priorRecord.uncertain.attemptId,
|
|
172
|
+
});
|
|
173
|
+
throwIfAborted(options.signal);
|
|
174
|
+
const releaseError = await releaseLease(lease);
|
|
175
|
+
lease = undefined;
|
|
176
|
+
if (releaseError !== undefined) throw releaseError;
|
|
177
|
+
await writeStream(
|
|
178
|
+
stderr,
|
|
179
|
+
`playbook run: discarded uncertain turn for Captain session ${JSON.stringify(sessionId)}\n`,
|
|
180
|
+
);
|
|
181
|
+
return { code: EXIT.ok, sessionId, record };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const releaseError = await releaseLease(lease);
|
|
184
|
+
await writeStream(
|
|
185
|
+
stderr,
|
|
186
|
+
`playbook run: cannot discard uncertain Captain turn: ${message(error)}\n`,
|
|
187
|
+
);
|
|
188
|
+
if (releaseError !== undefined) {
|
|
189
|
+
await writeStream(
|
|
190
|
+
stderr,
|
|
191
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return { code: EXIT.turn };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!args.retryUncertain) {
|
|
198
|
+
const releaseError = await releaseLease(lease);
|
|
199
|
+
lease = undefined;
|
|
200
|
+
await reportUncertainSession(stderr, sessionId);
|
|
201
|
+
if (releaseError !== undefined) {
|
|
202
|
+
await writeStream(
|
|
203
|
+
stderr,
|
|
204
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
205
|
+
);
|
|
206
|
+
return { code: EXIT.turn };
|
|
207
|
+
}
|
|
208
|
+
return { code: EXIT.argument };
|
|
209
|
+
}
|
|
210
|
+
input = priorRecord.uncertain.input;
|
|
211
|
+
} else if (recovering) {
|
|
212
|
+
const releaseError = await releaseLease(lease);
|
|
213
|
+
lease = undefined;
|
|
214
|
+
await writeStream(
|
|
215
|
+
stderr,
|
|
216
|
+
`playbook run: Captain session ${JSON.stringify(sessionId)} has no uncertain turn to recover\n`,
|
|
217
|
+
);
|
|
218
|
+
if (releaseError !== undefined) {
|
|
219
|
+
await writeStream(
|
|
220
|
+
stderr,
|
|
221
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
222
|
+
);
|
|
223
|
+
return { code: EXIT.turn };
|
|
224
|
+
}
|
|
225
|
+
return { code: EXIT.argument };
|
|
226
|
+
} else {
|
|
227
|
+
const resolvedInput = await resolveBossInput(input, options, stderr);
|
|
228
|
+
if (!resolvedInput.ok) {
|
|
229
|
+
const releaseError = await releaseLease(lease);
|
|
230
|
+
if (releaseError !== undefined) {
|
|
231
|
+
await writeStream(
|
|
232
|
+
stderr,
|
|
233
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
234
|
+
);
|
|
235
|
+
return { code: EXIT.turn };
|
|
236
|
+
}
|
|
237
|
+
return { code: EXIT.argument };
|
|
238
|
+
}
|
|
239
|
+
input = resolvedInput.input;
|
|
240
|
+
}
|
|
223
241
|
|
|
224
|
-
|
|
225
|
-
|
|
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',
|
|
236
|
-
);
|
|
237
|
-
return { code: EXIT.arg };
|
|
242
|
+
cwd = priorRecord.cwd;
|
|
243
|
+
restoreSnapshot = priorRecord.snapshot;
|
|
238
244
|
}
|
|
239
245
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
246
|
+
if (!continuing || !args.retryUncertain) {
|
|
247
|
+
const userConfigPath =
|
|
248
|
+
options.userConfigPath ?? resolveUserConfigPath(env, home);
|
|
249
|
+
let plan;
|
|
250
|
+
const configNotices = [];
|
|
251
|
+
try {
|
|
252
|
+
throwIfAborted(options.signal);
|
|
253
|
+
plan = await loadLaunchPlan({
|
|
254
|
+
userConfigPath,
|
|
255
|
+
overlayPaths: args.withPaths,
|
|
256
|
+
loadModule,
|
|
257
|
+
prepareRegistryModule,
|
|
258
|
+
onNotice: (line) => configNotices.push(line),
|
|
259
|
+
...(continuing
|
|
260
|
+
? {
|
|
261
|
+
selectedMembers: captainSessionSelectedMembers(
|
|
262
|
+
priorRecord.structuralProjection,
|
|
263
|
+
),
|
|
264
|
+
}
|
|
265
|
+
: {}),
|
|
266
|
+
});
|
|
267
|
+
throwIfAborted(options.signal);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
for (const line of configNotices) await writeStream(stderr, line);
|
|
270
|
+
const releaseError = await releaseLease(lease);
|
|
271
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
272
|
+
if (releaseError !== undefined) {
|
|
273
|
+
await writeStream(
|
|
274
|
+
stderr,
|
|
275
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
276
|
+
);
|
|
277
|
+
return { code: EXIT.turn };
|
|
278
|
+
}
|
|
279
|
+
return { code: EXIT.argument };
|
|
255
280
|
}
|
|
256
|
-
|
|
281
|
+
for (const line of configNotices) await writeStream(stderr, line);
|
|
282
|
+
|
|
257
283
|
try {
|
|
258
|
-
|
|
284
|
+
const current = executionConfigFromPlan(plan);
|
|
285
|
+
if (continuing) {
|
|
286
|
+
config = assertCaptainSessionExecutionCompatible(
|
|
287
|
+
priorRecord.structuralProjection,
|
|
288
|
+
current,
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
sessionId = (options.createLogicalSessionId ?? randomUUID)();
|
|
292
|
+
if (typeof sessionId !== 'string' || !UUID_PATTERN.test(sessionId)) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
config = current;
|
|
298
|
+
cwd = resolve(options.cwd ?? process.cwd());
|
|
299
|
+
}
|
|
259
300
|
} catch (error) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
)
|
|
263
|
-
|
|
301
|
+
const releaseError = await releaseLease(lease);
|
|
302
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
303
|
+
if (releaseError !== undefined) {
|
|
304
|
+
await writeStream(
|
|
305
|
+
stderr,
|
|
306
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
307
|
+
);
|
|
308
|
+
return { code: EXIT.turn };
|
|
309
|
+
}
|
|
310
|
+
return { code: EXIT.argument };
|
|
264
311
|
}
|
|
265
312
|
} else {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
313
|
+
try {
|
|
314
|
+
throwIfAborted(options.signal);
|
|
315
|
+
config = await validateFrozenExecutionConfig(
|
|
316
|
+
priorRecord.structuralProjection,
|
|
317
|
+
priorRecord.uncertain.attemptedExecutionProjection,
|
|
318
|
+
{ loadModule, prepareRegistryModule },
|
|
319
|
+
);
|
|
320
|
+
throwIfAborted(options.signal);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
const releaseError = await releaseLease(lease);
|
|
323
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
324
|
+
if (releaseError !== undefined) {
|
|
325
|
+
await writeStream(
|
|
326
|
+
stderr,
|
|
327
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
328
|
+
);
|
|
329
|
+
return { code: EXIT.turn };
|
|
330
|
+
}
|
|
331
|
+
return { code: EXIT.argument };
|
|
332
|
+
}
|
|
274
333
|
}
|
|
275
334
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
335
|
+
const adapters = adaptersFromExecutionConfig(config);
|
|
336
|
+
const readiness = checkReadiness(adapters, env, home);
|
|
337
|
+
let sdkReadiness;
|
|
338
|
+
try {
|
|
339
|
+
sdkReadiness = await awaitWithAbort(
|
|
340
|
+
checkAdapterSdks(
|
|
341
|
+
adapters,
|
|
342
|
+
options.probeAdapterSdk ?? probeAdapterSdk,
|
|
343
|
+
...(options.classifyRuntime ? [options.classifyRuntime] : []),
|
|
344
|
+
),
|
|
345
|
+
options.signal,
|
|
281
346
|
);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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`,
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const releaseError = await releaseLease(lease);
|
|
349
|
+
await writeStream(
|
|
350
|
+
stderr,
|
|
351
|
+
`playbook run: adapter readiness failed: ${message(error)}\n`,
|
|
300
352
|
);
|
|
301
|
-
|
|
353
|
+
if (releaseError !== undefined) {
|
|
354
|
+
await writeStream(
|
|
355
|
+
stderr,
|
|
356
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
357
|
+
);
|
|
358
|
+
return { code: EXIT.turn };
|
|
359
|
+
}
|
|
360
|
+
return { code: EXIT.argument };
|
|
302
361
|
}
|
|
303
|
-
const
|
|
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`,
|
|
362
|
+
for (const adapter of readiness.unknownAdapters) {
|
|
363
|
+
await writeStream(
|
|
364
|
+
stderr,
|
|
365
|
+
`playbook run: warning: no readiness check for adapter "${adapter}"\n`,
|
|
311
366
|
);
|
|
312
|
-
return { code: EXIT.arg };
|
|
313
367
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
368
|
+
if (
|
|
369
|
+
readiness.failingAdapters.length > 0 ||
|
|
370
|
+
sdkReadiness.unusableAdapters.length > 0
|
|
371
|
+
) {
|
|
372
|
+
await reportReadinessFailure({
|
|
373
|
+
stderr,
|
|
374
|
+
adapters,
|
|
375
|
+
failingAdapters: readiness.failingAdapters,
|
|
376
|
+
unusableAdapters: sdkReadiness.unusableAdapters,
|
|
377
|
+
invocation: replayInvocation(argv, args, input),
|
|
378
|
+
ephemeralNpx: options.ephemeralNpx,
|
|
379
|
+
});
|
|
380
|
+
const releaseError = await releaseLease(lease);
|
|
381
|
+
if (releaseError !== undefined) {
|
|
382
|
+
await writeStream(
|
|
383
|
+
stderr,
|
|
384
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
385
|
+
);
|
|
386
|
+
return { code: EXIT.turn };
|
|
387
|
+
}
|
|
388
|
+
return { code: EXIT.argument };
|
|
318
389
|
}
|
|
319
390
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
391
|
+
if (lease === undefined) {
|
|
392
|
+
try {
|
|
393
|
+
throwIfAborted(options.signal);
|
|
394
|
+
lease = await store.acquire(sessionId);
|
|
395
|
+
throwIfAborted(options.signal);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
const releaseError = await releaseLease(lease);
|
|
398
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
399
|
+
if (releaseError !== undefined) {
|
|
400
|
+
await writeStream(
|
|
401
|
+
stderr,
|
|
402
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
403
|
+
);
|
|
404
|
+
return { code: EXIT.turn };
|
|
405
|
+
}
|
|
406
|
+
return { code: EXIT.argument };
|
|
407
|
+
}
|
|
330
408
|
}
|
|
331
409
|
|
|
332
|
-
let
|
|
410
|
+
let attemptId;
|
|
333
411
|
try {
|
|
334
|
-
|
|
335
|
-
captainOptions: record.option,
|
|
336
|
-
players: playersFromSpecs(roleSpecs),
|
|
337
|
-
});
|
|
412
|
+
attemptId = createAttemptId(options);
|
|
338
413
|
} catch (error) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
});
|
|
358
|
-
}
|
|
359
|
-
|
|
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;
|
|
364
|
-
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 }));
|
|
414
|
+
const releaseError = await releaseLease(lease);
|
|
415
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
416
|
+
if (releaseError !== undefined) {
|
|
417
|
+
await writeStream(
|
|
418
|
+
stderr,
|
|
419
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
420
|
+
);
|
|
421
|
+
return { code: EXIT.turn };
|
|
422
|
+
}
|
|
423
|
+
return { code: EXIT.argument };
|
|
390
424
|
}
|
|
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,
|
|
408
|
-
});
|
|
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,
|
|
432
|
-
});
|
|
433
|
-
if (result.status !== 'ok' || result.finalText === undefined) {
|
|
434
|
-
throw new Error(result.error ?? 'judge call failed');
|
|
435
|
-
}
|
|
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
425
|
|
|
452
|
-
|
|
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;
|
|
426
|
+
let settled;
|
|
462
427
|
try {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
428
|
+
settled = await driveHeadlessCaptainTurn({
|
|
429
|
+
config,
|
|
430
|
+
input,
|
|
431
|
+
sessionId,
|
|
432
|
+
cwd,
|
|
433
|
+
loadModule,
|
|
434
|
+
stderr,
|
|
435
|
+
verbose: args.verbose,
|
|
436
|
+
...(options.adapterImports
|
|
437
|
+
? { adapterImports: options.adapterImports }
|
|
438
|
+
: {}),
|
|
439
|
+
...(options.createCaptainRuntime
|
|
440
|
+
? { createCaptainRuntime: options.createCaptainRuntime }
|
|
441
|
+
: {}),
|
|
442
|
+
...(options.createCaptainSessionId
|
|
443
|
+
? { createCaptainSessionId: options.createCaptainSessionId }
|
|
444
|
+
: {}),
|
|
445
|
+
...(options.createHostRuntime
|
|
446
|
+
? { createHostRuntime: options.createHostRuntime }
|
|
447
|
+
: {}),
|
|
448
|
+
...(restoreSnapshot !== undefined
|
|
449
|
+
? { restoreSnapshot }
|
|
450
|
+
: {}),
|
|
451
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
452
|
+
beforeBossTurn: async (baselineSnapshot) => {
|
|
453
|
+
throwIfAborted(options.signal);
|
|
454
|
+
return args.retryUncertain
|
|
455
|
+
? lease.beginRetry({
|
|
456
|
+
expectedAttemptId: priorRecord.uncertain.attemptId,
|
|
457
|
+
nextAttemptId: attemptId,
|
|
458
|
+
})
|
|
459
|
+
: lease.beginTurn({
|
|
460
|
+
input,
|
|
461
|
+
attemptId,
|
|
462
|
+
attemptedExecutionProjection: config,
|
|
463
|
+
...(priorRecord === undefined
|
|
464
|
+
? {
|
|
465
|
+
fresh: {
|
|
466
|
+
cwd,
|
|
467
|
+
structuralProjection:
|
|
468
|
+
projectCaptainSessionStructure(config),
|
|
469
|
+
snapshot: baselineSnapshot,
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
: {}),
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
assertBeforeBossTurn: () => lease.assertOwner(),
|
|
478
476
|
});
|
|
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,
|
|
490
|
-
});
|
|
491
|
-
parked = outcome.code === EXIT.suspended;
|
|
492
|
-
return outcome;
|
|
493
|
-
}
|
|
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
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return outcome;
|
|
507
477
|
} catch (error) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
478
|
+
const cleanupIncomplete = isCaptainSessionHostCleanupIncomplete(error);
|
|
479
|
+
const releaseError = cleanupIncomplete
|
|
480
|
+
? undefined
|
|
481
|
+
: await releaseLease(lease);
|
|
482
|
+
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
483
|
+
if (cleanupIncomplete) {
|
|
484
|
+
await writeStream(
|
|
485
|
+
stderr,
|
|
486
|
+
'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
|
|
487
|
+
);
|
|
517
488
|
}
|
|
489
|
+
if (releaseError !== undefined) {
|
|
490
|
+
await writeStream(
|
|
491
|
+
stderr,
|
|
492
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
code:
|
|
497
|
+
error instanceof HeadlessHostSetupError && releaseError === undefined
|
|
498
|
+
? EXIT.argument
|
|
499
|
+
: EXIT.turn,
|
|
500
|
+
};
|
|
518
501
|
}
|
|
519
|
-
}
|
|
520
502
|
|
|
521
|
-
|
|
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,
|
|
532
|
-
};
|
|
533
|
-
const file = join(ctx.sessionsDir, `${store.sessionId}.json`);
|
|
503
|
+
let durableRecord;
|
|
534
504
|
try {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
await writeFile(tmpFile, `${JSON.stringify(record, null, 2)}\n`, {
|
|
540
|
-
mode: 0o600,
|
|
505
|
+
throwIfAborted(options.signal);
|
|
506
|
+
durableRecord = await lease.settle({
|
|
507
|
+
attemptId: settled.uncertainRecord.uncertain.attemptId,
|
|
508
|
+
snapshot: settled.snapshot,
|
|
541
509
|
});
|
|
542
|
-
await rename(tmpFile, file);
|
|
543
510
|
} catch (error) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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`,
|
|
511
|
+
let cleanupError;
|
|
512
|
+
try {
|
|
513
|
+
await settled.dispose();
|
|
514
|
+
} catch (cause) {
|
|
515
|
+
cleanupError = cause;
|
|
516
|
+
}
|
|
517
|
+
await writeStream(
|
|
518
|
+
stderr,
|
|
519
|
+
`playbook run: cannot persist Captain session: ${message(error)}\n`,
|
|
563
520
|
);
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
`playbook run: session ${store.sessionId} is awaiting a Boss reply; ` +
|
|
569
|
-
`continue with: playbook run resume ${store.sessionId} "<answer>"\n`,
|
|
570
|
-
);
|
|
571
|
-
return { code: EXIT.suspended };
|
|
572
|
-
}
|
|
573
|
-
|
|
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',
|
|
521
|
+
if (cleanupError !== undefined) {
|
|
522
|
+
const cleanupFailure = new CaptainSessionHostCleanupError(
|
|
523
|
+
[error, cleanupError],
|
|
524
|
+
`Captain session settlement failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
|
|
590
525
|
);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
stderr.write(
|
|
595
|
-
`playbook run: ${result.outcome}${
|
|
596
|
-
result.error ? `: ${result.error.message}` : ''
|
|
597
|
-
}\n`,
|
|
526
|
+
await writeStream(
|
|
527
|
+
stderr,
|
|
528
|
+
`playbook run: ${message(cleanupFailure)}\n`,
|
|
598
529
|
);
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
'playbook run: playbook made a nested call a one-shot run cannot answer\n',
|
|
530
|
+
await writeStream(
|
|
531
|
+
stderr,
|
|
532
|
+
'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
|
|
603
533
|
);
|
|
604
|
-
return { code: EXIT.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
534
|
+
return { code: EXIT.turn };
|
|
535
|
+
}
|
|
536
|
+
const releaseError = await releaseLease(lease);
|
|
537
|
+
if (releaseError !== undefined) {
|
|
538
|
+
await writeStream(
|
|
539
|
+
stderr,
|
|
540
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
610
541
|
);
|
|
611
|
-
|
|
542
|
+
}
|
|
543
|
+
return { code: EXIT.turn };
|
|
612
544
|
}
|
|
613
|
-
}
|
|
614
545
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
return
|
|
546
|
+
const releaseError = await releaseLease(lease);
|
|
547
|
+
lease = undefined;
|
|
548
|
+
if (releaseError !== undefined) {
|
|
549
|
+
await writeStream(
|
|
550
|
+
stderr,
|
|
551
|
+
`playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
|
|
552
|
+
);
|
|
553
|
+
return { code: EXIT.turn };
|
|
554
|
+
}
|
|
555
|
+
if (options.signal?.aborted) {
|
|
556
|
+
await writeStream(
|
|
557
|
+
stderr,
|
|
558
|
+
'playbook run: Captain turn was interrupted; reply withheld\n',
|
|
559
|
+
);
|
|
560
|
+
return { code: EXIT.turn, sessionId, record: durableRecord };
|
|
623
561
|
}
|
|
624
|
-
return JSON.stringify(output);
|
|
625
|
-
}
|
|
626
562
|
|
|
627
|
-
|
|
563
|
+
// Durable hand-off transfers semantic ownership to the logical session.
|
|
564
|
+
// Process exit owns ephemeral transport teardown; semantic disposal here
|
|
565
|
+
// would end the session that `--continue` must restore.
|
|
566
|
+
try {
|
|
567
|
+
await presentHeadlessCaptainTurn(settled, {
|
|
568
|
+
stdout,
|
|
569
|
+
json: args.json,
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
await writeStream(
|
|
573
|
+
stderr,
|
|
574
|
+
`playbook run: cannot write Captain reply: ${message(error)}\n`,
|
|
575
|
+
);
|
|
576
|
+
return { code: EXIT.turn };
|
|
577
|
+
}
|
|
628
578
|
return {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
579
|
+
code: EXIT.ok,
|
|
580
|
+
sessionId,
|
|
581
|
+
reply: settled.reply,
|
|
582
|
+
snapshot: settled.snapshot,
|
|
583
|
+
config: settled.config,
|
|
584
|
+
cwd: settled.cwd,
|
|
585
|
+
record: durableRecord,
|
|
633
586
|
};
|
|
634
587
|
}
|
|
635
588
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
589
|
+
// PBCLI-20: run exactly one Boss boundary and capture its one accepted reply
|
|
590
|
+
// plus the complete shell snapshot. No stdout presentation occurs here.
|
|
591
|
+
export async function driveHeadlessCaptainTurn({
|
|
592
|
+
config,
|
|
593
|
+
input,
|
|
594
|
+
sessionId,
|
|
595
|
+
cwd,
|
|
596
|
+
loadModule,
|
|
597
|
+
stderr,
|
|
598
|
+
verbose = false,
|
|
599
|
+
adapterImports,
|
|
600
|
+
createCaptainRuntime,
|
|
601
|
+
createCaptainSessionId,
|
|
602
|
+
createHostRuntime = createTmuxPlayRuntime,
|
|
603
|
+
restoreSnapshot,
|
|
604
|
+
beforeBossTurn,
|
|
605
|
+
assertBeforeBossTurn,
|
|
606
|
+
signal,
|
|
607
|
+
}) {
|
|
608
|
+
const replies = [];
|
|
609
|
+
let shell;
|
|
610
|
+
let host;
|
|
611
|
+
let baselineSnapshot;
|
|
612
|
+
let uncertainRecord;
|
|
613
|
+
try {
|
|
614
|
+
try {
|
|
615
|
+
const created = await createCaptainSessionHost({
|
|
616
|
+
config,
|
|
617
|
+
sessionId,
|
|
618
|
+
cwd,
|
|
619
|
+
loadModule,
|
|
620
|
+
observers: [
|
|
621
|
+
{
|
|
622
|
+
async onRecord(record) {
|
|
623
|
+
if (record.type === 'captain_reply') {
|
|
624
|
+
replies.push(record.text);
|
|
625
|
+
} else if (record.type === 'captain_status') {
|
|
626
|
+
await writeStream(stderr, `${record.message}\n`);
|
|
627
|
+
} else if (verbose && record.type === 'captain_telemetry') {
|
|
628
|
+
await writeStream(stderr, `\u00b7 ${record.topic}\n`);
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
...(signal ? { signal } : {}),
|
|
634
|
+
...(adapterImports ? { adapterImports } : {}),
|
|
635
|
+
...(createCaptainRuntime ? { createCaptainRuntime } : {}),
|
|
636
|
+
...(createCaptainSessionId ? { createCaptainSessionId } : {}),
|
|
637
|
+
createHostRuntime,
|
|
638
|
+
...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
|
|
639
|
+
});
|
|
640
|
+
({ shell, host, snapshot: baselineSnapshot } = created);
|
|
641
|
+
uncertainRecord = await beforeBossTurn?.(baselineSnapshot);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
throw new HeadlessHostSetupError(error);
|
|
644
|
+
}
|
|
645
|
+
await assertBeforeBossTurn?.();
|
|
646
|
+
if (signal?.aborted) {
|
|
647
|
+
throw signal.reason ?? new Error('Captain turn aborted');
|
|
648
|
+
}
|
|
649
|
+
await host.runBossTurn(input);
|
|
650
|
+
throwIfAborted(signal);
|
|
651
|
+
if (
|
|
652
|
+
replies.length !== 1 ||
|
|
653
|
+
typeof replies[0] !== 'string' ||
|
|
654
|
+
replies[0].trim().length === 0
|
|
655
|
+
) {
|
|
656
|
+
throw new Error(
|
|
657
|
+
`Captain turn produced ${replies.length} usable Boss-visible replies; expected exactly one`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (shell === undefined) {
|
|
661
|
+
throw new Error('Captain shell host initialized without a shell');
|
|
662
|
+
}
|
|
663
|
+
const snapshot = shell.exportSnapshot();
|
|
664
|
+
if (snapshot === undefined) {
|
|
665
|
+
throw new Error('Captain turn settled without an exportable session snapshot');
|
|
666
|
+
}
|
|
664
667
|
if (
|
|
665
|
-
|
|
666
|
-
|
|
668
|
+
snapshot.captain?.sessionId === sessionId ||
|
|
669
|
+
snapshot.issuedSessionIds?.includes(sessionId)
|
|
667
670
|
) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
return `unknown adapter "${adapter}"`;
|
|
671
|
+
throw new Error(
|
|
672
|
+
'logical session id collided with an internal Captain session id',
|
|
673
|
+
);
|
|
672
674
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
675
|
+
return {
|
|
676
|
+
sessionId,
|
|
677
|
+
reply: replies[0],
|
|
678
|
+
snapshot,
|
|
679
|
+
config: cloneJson(config),
|
|
680
|
+
cwd,
|
|
681
|
+
uncertainRecord,
|
|
682
|
+
dispose: () => host.dispose(),
|
|
683
|
+
};
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (host !== undefined) {
|
|
686
|
+
try {
|
|
687
|
+
await host.dispose();
|
|
688
|
+
} catch (cleanupError) {
|
|
689
|
+
const cleanupFailure = new CaptainSessionHostCleanupError(
|
|
690
|
+
[error, cleanupError],
|
|
691
|
+
`Captain session turn failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
|
|
692
|
+
);
|
|
693
|
+
throw error instanceof HeadlessHostSetupError
|
|
694
|
+
? new HeadlessHostSetupError(cleanupFailure)
|
|
695
|
+
: cleanupFailure;
|
|
696
|
+
}
|
|
676
697
|
}
|
|
698
|
+
throw error;
|
|
677
699
|
}
|
|
678
|
-
return undefined;
|
|
679
700
|
}
|
|
680
701
|
|
|
681
|
-
// PBCLI-
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return args.terminated ? [resolved] : ['--', resolved];
|
|
692
|
-
}
|
|
693
|
-
|
|
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('');
|
|
702
|
+
// PBCLI-20/49: both presentations construct the same shell and cligent core.
|
|
703
|
+
// A caller supplies only observers and lifecycle ownership; the configured
|
|
704
|
+
// Captain, referenced-player roster, working directory, and restore boundary
|
|
705
|
+
// remain one implementation.
|
|
706
|
+
export class CaptainSessionHostCleanupError extends AggregateError {
|
|
707
|
+
constructor(errors, messageText) {
|
|
708
|
+
super(errors, messageText);
|
|
709
|
+
this.name = 'CaptainSessionHostCleanupError';
|
|
710
|
+
this.code = 'PLAYBOOK_CAPTAIN_HOST_CLEANUP_INCOMPLETE';
|
|
711
|
+
}
|
|
723
712
|
}
|
|
724
713
|
|
|
725
|
-
function
|
|
714
|
+
function isCaptainSessionHostCleanupIncomplete(error) {
|
|
715
|
+
if (error instanceof CaptainSessionHostCleanupError) return true;
|
|
726
716
|
return (
|
|
727
|
-
|
|
728
|
-
|
|
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))
|
|
717
|
+
error instanceof HeadlessHostSetupError &&
|
|
718
|
+
isCaptainSessionHostCleanupIncomplete(error.cause)
|
|
734
719
|
);
|
|
735
720
|
}
|
|
736
721
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
let
|
|
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;
|
|
722
|
+
export async function createCaptainSessionHost({
|
|
723
|
+
config,
|
|
724
|
+
sessionId,
|
|
725
|
+
cwd,
|
|
726
|
+
loadModule,
|
|
727
|
+
observers,
|
|
728
|
+
adapterImports,
|
|
729
|
+
createCaptainRuntime,
|
|
730
|
+
createCaptainSessionId,
|
|
731
|
+
createHostRuntime = createTmuxPlayRuntime,
|
|
732
|
+
restoreSnapshot,
|
|
733
|
+
signal,
|
|
734
|
+
}) {
|
|
735
|
+
const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
|
|
736
|
+
loadModule,
|
|
737
|
+
...(createCaptainRuntime ? { createCaptainRuntime } : {}),
|
|
738
|
+
...(createCaptainSessionId
|
|
739
|
+
? { createSessionId: createCaptainSessionId }
|
|
740
|
+
: {}),
|
|
741
|
+
});
|
|
742
|
+
let host;
|
|
765
743
|
try {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
744
|
+
const captain = captainHostBoundary(shell, restoreSnapshot);
|
|
745
|
+
host = await createHostRuntime({
|
|
746
|
+
captain,
|
|
747
|
+
captainConfig: projectHostAgent(
|
|
748
|
+
config.captain,
|
|
749
|
+
'Captain execution config.captain',
|
|
750
|
+
),
|
|
751
|
+
players: config.players.map(({ id, ...agent }) => ({
|
|
752
|
+
id,
|
|
753
|
+
...projectHostAgent(agent, `Captain execution config.players.${id}`),
|
|
754
|
+
})),
|
|
755
|
+
cwd,
|
|
756
|
+
observers,
|
|
757
|
+
...(signal ? { signal } : {}),
|
|
758
|
+
...(adapterImports ? { adapterImports } : {}),
|
|
759
|
+
});
|
|
760
|
+
const snapshot = shell.exportSnapshot();
|
|
761
|
+
if (snapshot === undefined) {
|
|
783
762
|
throw new Error(
|
|
784
|
-
|
|
763
|
+
'Captain shell initialized without an exportable session snapshot',
|
|
785
764
|
);
|
|
786
765
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
766
|
+
if (
|
|
767
|
+
restoreSnapshot !== undefined &&
|
|
768
|
+
!isDeepStrictEqual(snapshot, restoreSnapshot)
|
|
769
|
+
) {
|
|
770
|
+
throw new Error('restored Captain snapshot changed before the Boss turn');
|
|
771
|
+
}
|
|
772
|
+
if (sessionId !== undefined) {
|
|
773
|
+
assertLogicalSessionIdDistinct({ sessionId, snapshot });
|
|
774
|
+
}
|
|
775
|
+
return { shell, host, snapshot };
|
|
776
|
+
} catch (error) {
|
|
777
|
+
let cleanupError;
|
|
778
|
+
try {
|
|
779
|
+
if (host !== undefined) await host.dispose();
|
|
780
|
+
else await shell.dispose?.();
|
|
781
|
+
} catch (cause) {
|
|
782
|
+
cleanupError = cause;
|
|
783
|
+
}
|
|
784
|
+
if (cleanupError !== undefined) {
|
|
785
|
+
throw new CaptainSessionHostCleanupError(
|
|
786
|
+
[error, cleanupError],
|
|
787
|
+
`Captain session host construction failed (${message(error)}) and cleanup also failed: ${message(cleanupError)}`,
|
|
791
788
|
);
|
|
792
789
|
}
|
|
790
|
+
throw error;
|
|
793
791
|
}
|
|
794
|
-
return defaults;
|
|
795
792
|
}
|
|
796
793
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
794
|
+
// PBCLI-20: restoration enters through the host's one init boundary. The
|
|
795
|
+
// restored shell is fresh and receives restore instead of init, never both.
|
|
796
|
+
function captainHostBoundary(shell, restoreSnapshot) {
|
|
797
|
+
return {
|
|
798
|
+
init: (session) =>
|
|
799
|
+
restoreSnapshot === undefined
|
|
800
|
+
? shell.init(session)
|
|
801
|
+
: shell.restore(session, restoreSnapshot),
|
|
802
|
+
handleBossTurn: (turn, context) => shell.handleBossTurn(turn, context),
|
|
803
|
+
prepareDispose: () => shell.prepareDispose?.(),
|
|
804
|
+
dispose: () => shell.dispose?.(),
|
|
805
|
+
};
|
|
808
806
|
}
|
|
809
807
|
|
|
810
|
-
|
|
811
|
-
|
|
808
|
+
// Host-neutral execution-only projection. It is detached from the frozen
|
|
809
|
+
// launch plan and intentionally excludes layout, theme, and notifications.
|
|
810
|
+
export function executionConfigFromPlan(plan) {
|
|
811
|
+
return validateCaptainSessionExecutionProjection({
|
|
812
|
+
schemaVersion: 2,
|
|
813
|
+
captain: plan.captain,
|
|
814
|
+
players: plan.players.map(({ id, agent }) => ({ id, ...agent })),
|
|
815
|
+
catalog: Object.fromEntries(
|
|
816
|
+
Object.entries(plan.catalog).map(([id, item]) => [
|
|
817
|
+
id,
|
|
818
|
+
{
|
|
819
|
+
id: item.id,
|
|
820
|
+
from: item.from,
|
|
821
|
+
manifestCommand: item.manifestCommand,
|
|
822
|
+
command: item.command,
|
|
823
|
+
intent: item.intent,
|
|
824
|
+
artifactSchema: item.artifactSchema,
|
|
825
|
+
requiredRoleIds: item.requiredRoleIds,
|
|
826
|
+
concurrentRoleSets: item.concurrentRoleSets,
|
|
827
|
+
roles: item.roles,
|
|
828
|
+
options: item.options,
|
|
829
|
+
},
|
|
830
|
+
]),
|
|
831
|
+
),
|
|
832
|
+
});
|
|
812
833
|
}
|
|
813
834
|
|
|
814
|
-
// PBCLI-23:
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
835
|
+
// PBCLI-22/23: uncertain retry consumes only its exact attempted execution
|
|
836
|
+
// projection. Pure record validation and structural compatibility precede the
|
|
837
|
+
// complete stored-catalog prepare-before-import transaction.
|
|
838
|
+
export async function validateFrozenExecutionConfig(
|
|
839
|
+
structuralProjection,
|
|
840
|
+
executionProjection,
|
|
841
|
+
{ loadModule, prepareRegistryModule },
|
|
842
|
+
) {
|
|
843
|
+
const config = assertCaptainSessionExecutionCompatible(
|
|
844
|
+
structuralProjection,
|
|
845
|
+
executionProjection,
|
|
846
|
+
);
|
|
847
|
+
const catalogItems = Object.entries(config.catalog);
|
|
822
848
|
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
849
|
+
// Preserve the complete-catalog preparation transaction: prepare every
|
|
850
|
+
// stored canonical module before importing any. A hook may provision the
|
|
851
|
+
// module's dependencies but cannot rewrite the frozen module identity.
|
|
852
|
+
for (const [id, item] of catalogItems) {
|
|
853
|
+
if (prepareRegistryModule === undefined) continue;
|
|
854
|
+
let prepared;
|
|
855
|
+
try {
|
|
856
|
+
prepared = await prepareRegistryModule({
|
|
857
|
+
id,
|
|
858
|
+
from: item.from,
|
|
859
|
+
authoredFrom: item.from,
|
|
860
|
+
});
|
|
861
|
+
} catch (cause) {
|
|
862
|
+
throw new Error(`stored playbook ${JSON.stringify(id)} failed to prepare: ${message(cause)}`);
|
|
863
|
+
}
|
|
864
|
+
if (prepared !== undefined && prepared !== item.from) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
`stored playbook ${JSON.stringify(id)} preparation changed its frozen module identity`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
831
869
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
870
|
+
|
|
871
|
+
for (const [id, item] of catalogItems) {
|
|
872
|
+
let entry;
|
|
873
|
+
try {
|
|
874
|
+
entry = (await loadModule(item.from))?.default;
|
|
875
|
+
} catch (cause) {
|
|
876
|
+
throw new Error(`stored playbook ${JSON.stringify(id)} failed to import: ${message(cause)}`);
|
|
877
|
+
}
|
|
878
|
+
if (!isValidRegistryEntry(entry)) {
|
|
879
|
+
throw new Error(`stored playbook ${JSON.stringify(id)} exposes no valid registry entry`);
|
|
880
|
+
}
|
|
881
|
+
if (
|
|
882
|
+
entry.id !== id ||
|
|
883
|
+
entry.command !== item.manifestCommand ||
|
|
884
|
+
entry.intent !== item.intent ||
|
|
885
|
+
entry.artifactSchema !== item.artifactSchema ||
|
|
886
|
+
!isDeepStrictEqual(entry.requiredRoleIds, item.requiredRoleIds) ||
|
|
887
|
+
!isDeepStrictEqual(
|
|
888
|
+
entry.concurrentRoleSets,
|
|
889
|
+
item.concurrentRoleSets,
|
|
890
|
+
)
|
|
891
|
+
) {
|
|
892
|
+
throw new Error(
|
|
893
|
+
`stored playbook ${JSON.stringify(id)} no longer matches its recorded manifest identity`,
|
|
894
|
+
);
|
|
852
895
|
}
|
|
853
896
|
}
|
|
854
|
-
return
|
|
897
|
+
return config;
|
|
855
898
|
}
|
|
856
899
|
|
|
857
|
-
function
|
|
858
|
-
return
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
);
|
|
900
|
+
function adaptersFromExecutionConfig(config) {
|
|
901
|
+
return [
|
|
902
|
+
...new Set([
|
|
903
|
+
config.captain.adapter,
|
|
904
|
+
...config.players.map((player) => player.adapter),
|
|
905
|
+
]),
|
|
906
|
+
];
|
|
876
907
|
}
|
|
877
908
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
};
|
|
909
|
+
function assertLogicalSessionIdDistinct(record) {
|
|
910
|
+
if (
|
|
911
|
+
record.snapshot.captain?.sessionId === record.sessionId ||
|
|
912
|
+
(Array.isArray(record.snapshot.issuedSessionIds) &&
|
|
913
|
+
record.snapshot.issuedSessionIds.includes(record.sessionId))
|
|
914
|
+
) {
|
|
915
|
+
throw new Error(
|
|
916
|
+
'logical session id collides with an internal Captain session id',
|
|
917
|
+
);
|
|
918
|
+
}
|
|
899
919
|
}
|
|
900
920
|
|
|
901
|
-
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
}
|
|
921
|
+
function memoizedModuleLoader(loadModule) {
|
|
922
|
+
const modules = new Map();
|
|
923
|
+
return (specifier) => {
|
|
924
|
+
if (!modules.has(specifier)) {
|
|
925
|
+
modules.set(specifier, Promise.resolve().then(() => loadModule(specifier)));
|
|
943
926
|
}
|
|
927
|
+
return modules.get(specifier);
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function isValidRegistryEntry(value) {
|
|
932
|
+
if (
|
|
933
|
+
value === null ||
|
|
934
|
+
typeof value !== 'object' ||
|
|
935
|
+
Array.isArray(value) ||
|
|
936
|
+
typeof value.id !== 'string' ||
|
|
937
|
+
value.id.trim().length === 0 ||
|
|
938
|
+
typeof value.command !== 'string' ||
|
|
939
|
+
value.command.trim().length === 0 ||
|
|
940
|
+
typeof value.intent !== 'string' ||
|
|
941
|
+
value.artifactSchema !== 2 ||
|
|
942
|
+
!Array.isArray(value.requiredRoleIds) ||
|
|
943
|
+
value.requiredRoleIds.some(
|
|
944
|
+
(role) => typeof role !== 'string' || role.trim().length === 0,
|
|
945
|
+
) ||
|
|
946
|
+
new Set(value.requiredRoleIds).size !== value.requiredRoleIds.length ||
|
|
947
|
+
!Array.isArray(value.concurrentRoleSets) ||
|
|
948
|
+
typeof value.validateOptions !== 'function' ||
|
|
949
|
+
typeof value.createRuntime !== 'function'
|
|
950
|
+
) {
|
|
951
|
+
return false;
|
|
944
952
|
}
|
|
945
|
-
const
|
|
946
|
-
|
|
953
|
+
const roles = new Set(value.requiredRoleIds);
|
|
954
|
+
return value.concurrentRoleSets.every(
|
|
955
|
+
(set) =>
|
|
956
|
+
Array.isArray(set) &&
|
|
957
|
+
set.length >= 2 &&
|
|
958
|
+
set.every((role) => typeof role === 'string' && roles.has(role)) &&
|
|
959
|
+
new Set(set).size === set.length,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export function captainOptionsFromConfig(config) {
|
|
947
964
|
return {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
965
|
+
playbooks: Object.fromEntries(
|
|
966
|
+
Object.entries(config.catalog).map(([id, item]) => [
|
|
967
|
+
id,
|
|
968
|
+
{
|
|
969
|
+
from: item.from,
|
|
970
|
+
command: item.command,
|
|
971
|
+
roles: cloneJson(item.roles),
|
|
972
|
+
options: cloneJson(item.options),
|
|
973
|
+
},
|
|
974
|
+
]),
|
|
975
|
+
),
|
|
976
|
+
sessionAgents: {
|
|
977
|
+
captain: cloneJson(config.captain),
|
|
978
|
+
players: Object.fromEntries(
|
|
979
|
+
config.players.map(({ id, ...agent }) => [id, cloneJson(agent)]),
|
|
980
|
+
),
|
|
981
|
+
},
|
|
982
|
+
...(typeof config.captain.adapter === 'string' &&
|
|
983
|
+
config.captain.adapter.length > 0
|
|
984
|
+
? { captainAdapter: config.captain.adapter }
|
|
953
985
|
: {}),
|
|
954
986
|
};
|
|
955
987
|
}
|
|
956
988
|
|
|
957
|
-
function
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
989
|
+
export async function presentHeadlessCaptainTurn(
|
|
990
|
+
{ sessionId, reply },
|
|
991
|
+
{ stdout, json = false },
|
|
992
|
+
) {
|
|
993
|
+
await writeStream(
|
|
994
|
+
stdout,
|
|
995
|
+
`${json ? JSON.stringify({ sessionId, reply }) : reply}\n`,
|
|
996
|
+
);
|
|
961
997
|
}
|
|
962
998
|
|
|
963
999
|
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,
|
|
1000
|
+
const parsed = {
|
|
1001
|
+
input: undefined,
|
|
1002
|
+
withPaths: [],
|
|
1003
|
+
noProvision: false,
|
|
974
1004
|
json: false,
|
|
975
1005
|
verbose: false,
|
|
976
|
-
|
|
1006
|
+
continue: false,
|
|
1007
|
+
sessionId: undefined,
|
|
1008
|
+
retryUncertain: false,
|
|
1009
|
+
discardUncertain: false,
|
|
977
1010
|
help: false,
|
|
978
1011
|
terminated: false,
|
|
979
1012
|
};
|
|
980
1013
|
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.
|
|
1014
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1015
|
+
const arg = argv[index];
|
|
990
1016
|
if (arg === '--') {
|
|
991
|
-
|
|
992
|
-
positionals.push(...argv.slice(
|
|
1017
|
+
parsed.terminated = true;
|
|
1018
|
+
positionals.push(...argv.slice(index + 1));
|
|
993
1019
|
break;
|
|
994
1020
|
}
|
|
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
|
-
|
|
1021
|
+
if (arg === '--help' || arg === '-h') parsed.help = true;
|
|
1022
|
+
else if (arg === '--json') parsed.json = true;
|
|
1023
|
+
else if (arg === '--verbose') parsed.verbose = true;
|
|
1024
|
+
else if (arg === '--no-provision') parsed.noProvision = true;
|
|
1025
|
+
else if (arg === '--retry-uncertain') {
|
|
1026
|
+
if (parsed.retryUncertain) {
|
|
1027
|
+
throw new Error('--retry-uncertain may be specified only once');
|
|
1028
|
+
}
|
|
1029
|
+
parsed.retryUncertain = true;
|
|
1030
|
+
} else if (arg === '--discard-uncertain') {
|
|
1031
|
+
if (parsed.discardUncertain) {
|
|
1032
|
+
throw new Error('--discard-uncertain may be specified only once');
|
|
1033
|
+
}
|
|
1034
|
+
parsed.discardUncertain = true;
|
|
1035
|
+
}
|
|
1036
|
+
else if (arg === '--continue') {
|
|
1037
|
+
if (parsed.continue) throw new Error('--continue may be specified only once');
|
|
1038
|
+
parsed.continue = true;
|
|
1039
|
+
} else if (arg === '--session') {
|
|
1040
|
+
const value = argv[index + 1];
|
|
1041
|
+
if (value === undefined || value === '') {
|
|
1042
|
+
throw new Error('--session needs a UUID value');
|
|
1043
|
+
}
|
|
1044
|
+
if (parsed.sessionId !== undefined) {
|
|
1045
|
+
throw new Error('--session may be specified only once');
|
|
1046
|
+
}
|
|
1047
|
+
parsed.sessionId = value;
|
|
1048
|
+
index += 1;
|
|
1049
|
+
} else if (arg.startsWith('--session=')) {
|
|
1050
|
+
const value = arg.slice('--session='.length);
|
|
1051
|
+
if (value === '') throw new Error('--session needs a UUID value');
|
|
1052
|
+
if (parsed.sessionId !== undefined) {
|
|
1053
|
+
throw new Error('--session may be specified only once');
|
|
1054
|
+
}
|
|
1055
|
+
parsed.sessionId = value;
|
|
1056
|
+
} else if (arg === '--with') {
|
|
1057
|
+
const value = argv[index + 1];
|
|
1058
|
+
if (value === undefined || value === '') {
|
|
1059
|
+
throw new Error('--with needs a value');
|
|
1060
|
+
}
|
|
1061
|
+
parsed.withPaths.push(value);
|
|
1062
|
+
index += 1;
|
|
1063
|
+
} else if (arg.startsWith('--with=')) {
|
|
1064
|
+
const value = arg.slice('--with='.length);
|
|
1065
|
+
if (value === '') throw new Error('--with needs a value');
|
|
1066
|
+
parsed.withPaths.push(value);
|
|
1067
|
+
} else if (
|
|
1068
|
+
RETIRED_FLAGS.has(arg) ||
|
|
1069
|
+
[...RETIRED_FLAGS].some((flag) => arg.startsWith(`${flag}=`))
|
|
1070
|
+
) {
|
|
1071
|
+
throw new Error(
|
|
1072
|
+
`${arg.split('=')[0]} was removed; configure the shared Captain session in playbook.config.yaml or a --with overlay`,
|
|
1073
|
+
);
|
|
1009
1074
|
} else if (arg.startsWith('-')) {
|
|
1010
1075
|
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);
|
|
1076
|
+
} else {
|
|
1077
|
+
positionals.push(arg);
|
|
1020
1078
|
}
|
|
1021
|
-
if (rest.length > 0) args.task = rest.join(' ');
|
|
1022
|
-
return args;
|
|
1023
1079
|
}
|
|
1024
|
-
if (
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1080
|
+
if (positionals.length > 1) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
'expected at most one [input] argument; quote multi-word input as one shell argument',
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
if (parsed.continue && parsed.sessionId !== undefined) {
|
|
1086
|
+
throw new Error('--continue and --session are mutually exclusive');
|
|
1087
|
+
}
|
|
1088
|
+
if (parsed.retryUncertain && parsed.discardUncertain) {
|
|
1089
|
+
throw new Error(
|
|
1090
|
+
'--retry-uncertain and --discard-uncertain are mutually exclusive',
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
if (
|
|
1094
|
+
(parsed.retryUncertain || parsed.discardUncertain) &&
|
|
1095
|
+
parsed.sessionId === undefined
|
|
1096
|
+
) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
'--retry-uncertain and --discard-uncertain require --session <id>',
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (
|
|
1102
|
+
(parsed.retryUncertain || parsed.discardUncertain) &&
|
|
1103
|
+
(parsed.continue || positionals.length > 0)
|
|
1104
|
+
) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
'uncertain-turn recovery accepts only an explicit --session and no input',
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
if (
|
|
1110
|
+
parsed.discardUncertain &&
|
|
1111
|
+
(parsed.json || parsed.verbose || parsed.noProvision)
|
|
1112
|
+
) {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
'--discard-uncertain does not accept --json, --verbose, or --no-provision',
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
if (
|
|
1118
|
+
(parsed.retryUncertain || parsed.discardUncertain) &&
|
|
1119
|
+
parsed.withPaths.length > 0
|
|
1120
|
+
) {
|
|
1121
|
+
throw new Error('--with is unavailable during uncertain-turn recovery');
|
|
1122
|
+
}
|
|
1123
|
+
if (
|
|
1124
|
+
parsed.sessionId !== undefined &&
|
|
1125
|
+
!SESSION_ID_PATTERN.test(parsed.sessionId)
|
|
1126
|
+
) {
|
|
1127
|
+
throw new Error('--session needs a canonical UUID value');
|
|
1128
|
+
}
|
|
1129
|
+
parsed.input = positionals[0];
|
|
1130
|
+
return parsed;
|
|
1028
1131
|
}
|
|
1029
1132
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1133
|
+
async function reportReadinessFailure({
|
|
1134
|
+
stderr,
|
|
1135
|
+
adapters,
|
|
1136
|
+
failingAdapters,
|
|
1137
|
+
unusableAdapters,
|
|
1138
|
+
invocation,
|
|
1139
|
+
ephemeralNpx,
|
|
1140
|
+
}) {
|
|
1141
|
+
if (unusableAdapters.length > 0) {
|
|
1142
|
+
const lines = adapterSdkFailureLines(unusableAdapters, {
|
|
1143
|
+
requiredSdks: mappedSdksFor(adapters),
|
|
1144
|
+
invocation,
|
|
1145
|
+
...(ephemeralNpx !== undefined ? { ephemeralNpx } : {}),
|
|
1146
|
+
});
|
|
1147
|
+
const [first, ...rest] = lines;
|
|
1148
|
+
await writeStream(
|
|
1149
|
+
stderr,
|
|
1150
|
+
[
|
|
1151
|
+
...(first ? [`playbook run: ${first}`] : []),
|
|
1152
|
+
...rest,
|
|
1153
|
+
]
|
|
1154
|
+
.map((line) => `${line}\n`)
|
|
1155
|
+
.join(''),
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
if (failingAdapters.length > 0) {
|
|
1159
|
+
await writeStream(
|
|
1160
|
+
stderr,
|
|
1161
|
+
`playbook run: adapters not ready: ${failingAdapters.join(', ')}\n`,
|
|
1162
|
+
);
|
|
1040
1163
|
}
|
|
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
1164
|
}
|
|
1050
1165
|
|
|
1051
|
-
function
|
|
1052
|
-
|
|
1053
|
-
if (
|
|
1054
|
-
|
|
1166
|
+
async function resolveBossInput(input, options, stderr) {
|
|
1167
|
+
let resolved = input;
|
|
1168
|
+
if (resolved === undefined) {
|
|
1169
|
+
try {
|
|
1170
|
+
resolved = await awaitWithAbort(
|
|
1171
|
+
(options.readStdin ?? readAllStdin)(),
|
|
1172
|
+
options.signal,
|
|
1173
|
+
);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
await writeStream(
|
|
1176
|
+
stderr,
|
|
1177
|
+
`playbook run: cannot read stdin: ${message(error)}\n`,
|
|
1178
|
+
);
|
|
1179
|
+
return { ok: false };
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (resolved.trim().length === 0) {
|
|
1183
|
+
await writeStream(
|
|
1184
|
+
stderr,
|
|
1185
|
+
'playbook run: empty input; pass one argument or pipe a Boss message on stdin\n',
|
|
1186
|
+
);
|
|
1187
|
+
return { ok: false };
|
|
1188
|
+
}
|
|
1189
|
+
return { ok: true, input: resolved };
|
|
1055
1190
|
}
|
|
1056
1191
|
|
|
1057
|
-
function
|
|
1058
|
-
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
1192
|
+
async function awaitWithAbort(value, signal) {
|
|
1193
|
+
if (signal === undefined) return value;
|
|
1194
|
+
if (signal.aborted) throw signal.reason ?? new Error('operation aborted');
|
|
1195
|
+
let onAbort;
|
|
1196
|
+
const aborted = new Promise((_, reject) => {
|
|
1197
|
+
onAbort = () => reject(signal.reason ?? new Error('operation aborted'));
|
|
1198
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1199
|
+
});
|
|
1200
|
+
try {
|
|
1201
|
+
return await Promise.race([value, aborted]);
|
|
1202
|
+
} finally {
|
|
1203
|
+
signal.removeEventListener('abort', onAbort);
|
|
1204
|
+
}
|
|
1061
1205
|
}
|
|
1062
1206
|
|
|
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;
|
|
1207
|
+
function throwIfAborted(signal) {
|
|
1208
|
+
if (signal?.aborted) {
|
|
1209
|
+
throw signal.reason ?? new Error('operation aborted');
|
|
1072
1210
|
}
|
|
1073
|
-
return specifier;
|
|
1074
1211
|
}
|
|
1075
1212
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1213
|
+
function registryPreparer(args, options, stderr) {
|
|
1214
|
+
return (
|
|
1215
|
+
options.prepareRegistryModule ??
|
|
1216
|
+
prepareConfiguredRegistries({
|
|
1217
|
+
enabled: !args.noProvision,
|
|
1218
|
+
stderr,
|
|
1219
|
+
hostRoots: options.hostRoots,
|
|
1220
|
+
commandName: 'playbook run',
|
|
1221
|
+
})
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function createAttemptId(options) {
|
|
1226
|
+
const attemptId = (options.createAttemptId ?? randomUUID)();
|
|
1227
|
+
if (typeof attemptId !== 'string' || !UUID_PATTERN.test(attemptId)) {
|
|
1228
|
+
throw new Error(
|
|
1229
|
+
`uncertain turn attempt id generator returned a non-UUID value: ${JSON.stringify(attemptId)}`,
|
|
1230
|
+
);
|
|
1089
1231
|
}
|
|
1090
|
-
return
|
|
1232
|
+
return attemptId;
|
|
1091
1233
|
}
|
|
1092
1234
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
enabled: !args.noProvision,
|
|
1102
|
-
hostRoots: ctx.hostRoots,
|
|
1103
|
-
});
|
|
1235
|
+
async function releaseLease(lease) {
|
|
1236
|
+
if (lease === undefined) return undefined;
|
|
1237
|
+
try {
|
|
1238
|
+
await lease.release();
|
|
1239
|
+
return undefined;
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
return error;
|
|
1242
|
+
}
|
|
1104
1243
|
}
|
|
1105
1244
|
|
|
1106
|
-
function
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1245
|
+
async function reportUncertainSession(stderr, sessionId) {
|
|
1246
|
+
await writeStream(
|
|
1247
|
+
stderr,
|
|
1248
|
+
[
|
|
1249
|
+
`playbook run: Captain session ${JSON.stringify(sessionId)} has an uncertain turn and will not be replayed automatically`,
|
|
1250
|
+
'Retry may duplicate external effects from the interrupted attempt; discard abandons that attempted turn.',
|
|
1251
|
+
`playbook run --session ${sessionId} --retry-uncertain`,
|
|
1252
|
+
`playbook run --session ${sessionId} --discard-uncertain`,
|
|
1253
|
+
'',
|
|
1254
|
+
].join('\n'),
|
|
1116
1255
|
);
|
|
1117
1256
|
}
|
|
1118
1257
|
|
|
1258
|
+
function replayInvocation(argv, args, input) {
|
|
1259
|
+
if (args.retryUncertain || args.discardUncertain) {
|
|
1260
|
+
return ['run', ...argv];
|
|
1261
|
+
}
|
|
1262
|
+
if (args.input !== undefined) return ['run', ...argv];
|
|
1263
|
+
return args.terminated
|
|
1264
|
+
? ['run', ...argv, input]
|
|
1265
|
+
: ['run', ...argv, '--', input];
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function cloneJson(value) {
|
|
1269
|
+
return JSON.parse(JSON.stringify(value));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1119
1272
|
async function readAllStdin() {
|
|
1120
1273
|
const chunks = [];
|
|
1121
|
-
for await (const chunk of process.stdin)
|
|
1274
|
+
for await (const chunk of process.stdin) {
|
|
1275
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
1276
|
+
}
|
|
1122
1277
|
return Buffer.concat(chunks).toString('utf8');
|
|
1123
1278
|
}
|
|
1124
1279
|
|
|
1125
|
-
function
|
|
1280
|
+
async function writeStream(stream, text) {
|
|
1281
|
+
const ready = stream.write(text);
|
|
1282
|
+
if (ready !== false || typeof stream.once !== 'function') return;
|
|
1283
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
1284
|
+
const onDrain = () => {
|
|
1285
|
+
stream.off?.('error', onError);
|
|
1286
|
+
resolvePromise();
|
|
1287
|
+
};
|
|
1288
|
+
const onError = (error) => {
|
|
1289
|
+
stream.off?.('drain', onDrain);
|
|
1290
|
+
rejectPromise(error);
|
|
1291
|
+
};
|
|
1292
|
+
stream.once('drain', onDrain);
|
|
1293
|
+
stream.once('error', onError);
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function runHelpText(userConfigPath) {
|
|
1126
1298
|
return [
|
|
1127
1299
|
'Usage:',
|
|
1128
|
-
' playbook run <
|
|
1129
|
-
'
|
|
1130
|
-
' playbook run
|
|
1300
|
+
' playbook run [--with <path>]... [--no-provision] [--json]',
|
|
1301
|
+
' [--verbose] [--] [input]',
|
|
1302
|
+
' playbook run (--continue | --session <id>) [--with <path>]...',
|
|
1303
|
+
' [--no-provision] [--json] [--verbose] [--] [reply]',
|
|
1304
|
+
' playbook run --session <id> --retry-uncertain [--no-provision]',
|
|
1305
|
+
' playbook run --session <id> --discard-uncertain',
|
|
1131
1306
|
'',
|
|
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 -',
|
|
1307
|
+
' [input] one exact Boss message; read verbatim from stdin when omitted',
|
|
1308
|
+
' -- end options so a flag-shaped input remains Boss text',
|
|
1136
1309
|
'',
|
|
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',
|
|
1310
|
+
`Default config: ${userConfigPath}`,
|
|
1149
1311
|
'',
|
|
1150
|
-
'
|
|
1151
|
-
'
|
|
1152
|
-
'
|
|
1153
|
-
'
|
|
1154
|
-
'
|
|
1155
|
-
'
|
|
1156
|
-
'
|
|
1312
|
+
'A new run uses the same configured Captain, enabled playbooks, players,',
|
|
1313
|
+
'options, overlays, provisioning, and readiness gate as interactive',
|
|
1314
|
+
'`playbook`. Enable an external registry in that config, then invoke its',
|
|
1315
|
+
'effective /command through Captain. The former positional registry,',
|
|
1316
|
+
'resume, and run-only binding surfaces have been removed.',
|
|
1317
|
+
'Stable agents live under top-level players; every playbook-local role',
|
|
1318
|
+
'binds explicitly under playbooks.<id>.roles. Equal player ids share one',
|
|
1319
|
+
'provider conversation; distinct ids remain isolated.',
|
|
1320
|
+
'Legacy playbooks.<id>.players is rejected and is not auto-migrated,',
|
|
1321
|
+
'because choosing new ids decides sharing versus isolation.',
|
|
1322
|
+
'An ordinary continued run restores the stored structure and working',
|
|
1323
|
+
'directory, then reads current config and overlays for model and effort.',
|
|
1324
|
+
'Uncertain retry instead uses its exact recorded input and settings.',
|
|
1157
1325
|
'',
|
|
1158
|
-
'
|
|
1159
|
-
'
|
|
1160
|
-
'
|
|
1161
|
-
'
|
|
1162
|
-
' session
|
|
1326
|
+
'Options:',
|
|
1327
|
+
' --with <path> overlay a generic config fragment (repeatable)',
|
|
1328
|
+
' --no-provision do not provision thin filesystem registry engines',
|
|
1329
|
+
' --continue reply to the latest durable Captain session',
|
|
1330
|
+
' --session <id> reply to one durable Captain session UUID',
|
|
1331
|
+
' --retry-uncertain retry that session\'s exact recorded uncertain input',
|
|
1332
|
+
' --discard-uncertain discard that session\'s uncertain attempt',
|
|
1333
|
+
' --json print exactly {"sessionId", "reply"}',
|
|
1334
|
+
' --verbose print Captain telemetry topics to stderr',
|
|
1335
|
+
' -h, --help print this help without reading input or config',
|
|
1163
1336
|
'',
|
|
1164
1337
|
].join('\n');
|
|
1165
1338
|
}
|