@skanl/brambo-adapter-cli 0.1.1

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/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createCliExecutorAdapter } from './traits.js';
2
+ export { CLAUDE_CODE_TRAITS, createClaudeCodeAdapter } from './executors/claude-code.js';
3
+ export { CODEX_TRAITS, createCodexAdapter } from './executors/codex.js';
4
+ export { OPENCODE_TRAITS, createOpenCodeAdapter } from './executors/opencode.js';
5
+ export { createNodeChildSpawner, routesThroughCmdShim } from './node-child-spawner.js';
6
+ export { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, availableExecutorIds, createExecutorAdapter, unknownExecutor, } from './catalogue.js';
7
+ export { DEFAULT_EXECUTOR_ACTION_COST, EXECUTOR_CONFIG_KEY, EXECUTOR_PLUGIN_ID, EXECUTOR_SERVICE, createExecutorPlugin, } from './plugin.js';
@@ -0,0 +1,13 @@
1
+ import type { ChildProcessSpawner } from './spawn-seam.ts';
2
+ /**
3
+ * True when this command can only be started by rerouting through `cmd.exe`.
4
+ *
5
+ * win32 cannot exec .cmd/.bat shims directly (Node refuses them since the
6
+ * shell-injection hardening, raising EINVAL), so the spawner reroutes them.
7
+ * That reroute hands the argv to a SHELL, which interprets `&`, `|`, `>`, `^`,
8
+ * `%VAR%` and newlines — Node's CRT-style quoting does not neutralise any of
9
+ * them. Callers that would put untrusted text in argv must consult this and
10
+ * refuse rather than let the shell see it.
11
+ */
12
+ export declare function routesThroughCmdShim(command: string): boolean;
13
+ export declare function createNodeChildSpawner(): ChildProcessSpawner;
@@ -0,0 +1,287 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+ // stdout/stderr capture cap per stream; beyond it we truncate and flag, so a
4
+ // chatty executor can never grow the parent's memory without bound. JSONL
5
+ // executors emit an event per token, so this cap is reached in practice, not
6
+ // only in theory — the truncation flag is what stops a chopped stream from
7
+ // being read as a complete answer.
8
+ const STREAM_CAPTURE_CAP_BYTES = 1024 * 1024;
9
+ // Real spawner backed by node:child_process.
10
+ //
11
+ // Tree-kill semantics per platform:
12
+ // - win32: `taskkill /pid <pid> /T /F` walks and force-terminates the whole tree;
13
+ // Node signals cannot reach grandchildren on Windows, so taskkill is the only
14
+ // reliable option for spawned .exe/.cmd trees. If taskkill itself fails, the
15
+ // child gets a direct kill fallback so `done` always settles.
16
+ // - posix: the child is spawned detached into its own process group; killing
17
+ // -pid takes down every descendant in one signal.
18
+ function killTreeOf(child) {
19
+ const pid = child.pid;
20
+ if (pid === undefined)
21
+ return;
22
+ if (process.platform === 'win32') {
23
+ execFile('taskkill', ['/pid', String(pid), '/T', '/F'], (error) => {
24
+ if (error) {
25
+ try {
26
+ child.kill('SIGKILL');
27
+ }
28
+ catch {
29
+ // Already exited.
30
+ }
31
+ }
32
+ });
33
+ return;
34
+ }
35
+ try {
36
+ process.kill(-pid, 'SIGKILL');
37
+ }
38
+ catch {
39
+ // The group may already be gone; fall through to the direct kill below.
40
+ }
41
+ try {
42
+ child.kill('SIGKILL');
43
+ }
44
+ catch {
45
+ // Already exited.
46
+ }
47
+ }
48
+ /**
49
+ * True when this command can only be started by rerouting through `cmd.exe`.
50
+ *
51
+ * win32 cannot exec .cmd/.bat shims directly (Node refuses them since the
52
+ * shell-injection hardening, raising EINVAL), so the spawner reroutes them.
53
+ * That reroute hands the argv to a SHELL, which interprets `&`, `|`, `>`, `^`,
54
+ * `%VAR%` and newlines — Node's CRT-style quoting does not neutralise any of
55
+ * them. Callers that would put untrusted text in argv must consult this and
56
+ * refuse rather than let the shell see it.
57
+ */
58
+ export function routesThroughCmdShim(command) {
59
+ return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
60
+ }
61
+ /*
62
+ * The environment a child receives (see `nodeOptions.env` below): brambo's own,
63
+ * with the ONE variable that claims to name the working directory corrected to
64
+ * the directory the child is actually given.
65
+ *
66
+ * `PWD` is not decoration. A tool that resolves relative paths against
67
+ * `process.env.PWD` instead of `process.cwd()` writes wherever `PWD` points,
68
+ * and an inherited `PWD` points at the directory BRAMBO was launched from —
69
+ * which is how a workspace stops being a boundary. Measured for M4.A against
70
+ * the real binaries with `PWD` aimed at a decoy directory outside the child's
71
+ * cwd: `opencode` created its file in the decoy, twice; `claude` used its cwd
72
+ * and ignored it.
73
+ *
74
+ * Corrected rather than deleted, because a shell-hosted tool may legitimately
75
+ * read `$PWD` and would break on its absence; a `PWD` that agrees with `cwd`
76
+ * cannot mislead anything. Every other inherited variable is left alone. Of the
77
+ * 95 a child receives on the machine this was measured on, 39 hold a single
78
+ * absolute path — `HOME`, `USERPROFILE`, `APPDATA`, `TEMP`, `INIT_CWD`,
79
+ * `OLDPWD` among them — and exactly one of them CLAIMS to name the child's
80
+ * working directory. Only that one is brambo's to correct. `INIT_CWD`, the
81
+ * ledger's named suspect, was RULED OUT by the same measurement: it pointed at
82
+ * a second, different decoy that stayed empty through every run, and deleting a
83
+ * variable measured not to matter would be the silent scrub this story exists
84
+ * to avoid.
85
+ *
86
+ * What this does NOT do: it does not confine anything. An executor that asks
87
+ * for an absolute path outside the workspace gets it — measured, with codex —
88
+ * and `HOME` still points at the real one, so per-user executor state (opencode
89
+ * keeps ONE SQLite database there) is shared by every concurrent session. brambo
90
+ * makes the workspace TRUE for a workspace-relative write; it is not a sandbox.
91
+ */
92
+ export function createNodeChildSpawner() {
93
+ return {
94
+ spawn(command, args, options) {
95
+ // Resolved once, so a RELATIVE cwd cannot become two different absolute
96
+ // paths: node resolves `cwd` against the parent's directory, and a relative
97
+ // `PWD` would be re-resolved against the CHILD's. `resolve` normalises but
98
+ // does not canonicalise, so `PWD` is the LOGICAL path — through a symlinked
99
+ // root it names the link where `process.cwd()` names the target, which is
100
+ // the ordinary shell convention for `$PWD` and not an escape.
101
+ const cwd = resolve(options.cwd);
102
+ const nodeOptions = {
103
+ cwd,
104
+ env: { ...process.env, PWD: cwd },
105
+ windowsHide: true,
106
+ detached: process.platform !== 'win32',
107
+ stdio: ['pipe', 'pipe', 'pipe'],
108
+ };
109
+ // Resolving twice is a no-op: whichever settle path lands first wins.
110
+ let resolveDone;
111
+ const done = new Promise((resolveOutcome) => {
112
+ resolveDone = resolveOutcome;
113
+ });
114
+ let settled = false;
115
+ const settle = (outcome) => {
116
+ if (settled)
117
+ return;
118
+ settled = true;
119
+ resolveDone(outcome);
120
+ };
121
+ let stdoutTruncated = false;
122
+ let stderrTruncated = false;
123
+ function createCapture(markTruncated) {
124
+ const targetChunks = [];
125
+ let bytes = 0;
126
+ return {
127
+ push(chunk) {
128
+ // Once at the cap nothing more is retained — and discarding is
129
+ // itself truncation, including when an earlier chunk filled the cap
130
+ // exactly. Failing to advance `bytes` here would let every later
131
+ // chunk append another cap-sized slice, i.e. no cap at all.
132
+ if (bytes >= STREAM_CAPTURE_CAP_BYTES) {
133
+ markTruncated();
134
+ return;
135
+ }
136
+ const remaining = STREAM_CAPTURE_CAP_BYTES - bytes;
137
+ if (chunk.length > remaining) {
138
+ targetChunks.push(chunk.subarray(0, remaining));
139
+ bytes = STREAM_CAPTURE_CAP_BYTES;
140
+ markTruncated();
141
+ return;
142
+ }
143
+ bytes += chunk.length;
144
+ targetChunks.push(chunk);
145
+ },
146
+ text() {
147
+ return Buffer.concat(targetChunks).toString('utf8');
148
+ },
149
+ };
150
+ }
151
+ const stdout = createCapture(() => {
152
+ stdoutTruncated = true;
153
+ });
154
+ const stderr = createCapture(() => {
155
+ stderrTruncated = true;
156
+ });
157
+ // No-op error listeners keep async pipe failures (EPIPE etc.) from crashing
158
+ // the parent as unhandled 'error' events; stdin breakage is recorded so the
159
+ // adapter can classify the run as failed instead of trusting the outcome.
160
+ let streamErrorMessage;
161
+ // Everything written to stdin is BUFFERED, because the cmd.exe reroute
162
+ // below arrives asynchronously — by then the caller has usually already
163
+ // written the prompt and closed stdin on the first child. Without replay
164
+ // the rerouted child waits on stdin forever and the run hangs.
165
+ const stdinChunks = [];
166
+ let stdinEnded = false;
167
+ let flushedChunks = 0;
168
+ let flushedEnd = false;
169
+ let currentSource;
170
+ let routedThroughCmd = routesThroughCmdShim(command);
171
+ const cmdArgs = () => ['/d', '/s', '/c', command, ...args];
172
+ function flushStdin() {
173
+ const source = currentSource;
174
+ if (source === undefined || settled || streamErrorMessage !== undefined)
175
+ return;
176
+ try {
177
+ while (flushedChunks < stdinChunks.length) {
178
+ source.stdin?.write(stdinChunks[flushedChunks]);
179
+ flushedChunks++;
180
+ }
181
+ if (stdinEnded && !flushedEnd) {
182
+ source.stdin?.end();
183
+ flushedEnd = true;
184
+ }
185
+ }
186
+ catch (error) {
187
+ streamErrorMessage = error instanceof Error ? error.message : String(error);
188
+ }
189
+ }
190
+ const settleFatal = (message) => {
191
+ settle({ exitCode: null, stdout: '', stderr: '', spawnErrorMessage: message });
192
+ };
193
+ function attach(source) {
194
+ source.stdout?.on('data', (chunk) => stdout.push(chunk));
195
+ source.stderr?.on('data', (chunk) => stderr.push(chunk));
196
+ source.stdin?.on('error', (error) => {
197
+ streamErrorMessage ??= error instanceof Error ? error.message : String(error);
198
+ });
199
+ source.stdout?.on('error', () => { });
200
+ source.stderr?.on('error', () => { });
201
+ source.on('error', (error) => {
202
+ // Node raises EINVAL asynchronously for shell-restricted commands;
203
+ // reroute once through cmd.exe while keeping tree-kill semantics.
204
+ const code = error.code;
205
+ if (code === 'EINVAL' && process.platform === 'win32' && !routedThroughCmd) {
206
+ routedThroughCmd = true;
207
+ launch('cmd.exe', cmdArgs());
208
+ return;
209
+ }
210
+ // Spawn failures (ENOENT etc.) may never be followed by 'close', so the
211
+ // error settles the child on its own.
212
+ settle({
213
+ exitCode: null,
214
+ stdout: '',
215
+ stderr: '',
216
+ spawnErrorMessage: error instanceof Error ? error.message : String(error),
217
+ });
218
+ });
219
+ source.on('close', (exitCode) => {
220
+ settle({
221
+ exitCode,
222
+ stdout: stdout.text(),
223
+ stderr: stderr.text(),
224
+ ...(streamErrorMessage !== undefined ? { streamErrorMessage } : {}),
225
+ ...(stdoutTruncated ? { stdoutTruncated: true } : {}),
226
+ ...(stderrTruncated ? { stderrTruncated: true } : {}),
227
+ });
228
+ });
229
+ // A rerouted child has received nothing yet: replay from the start.
230
+ currentSource = source;
231
+ flushedChunks = 0;
232
+ flushedEnd = false;
233
+ flushStdin();
234
+ }
235
+ function launch(executable, launchArgs) {
236
+ try {
237
+ attach(spawn(executable, [...launchArgs], nodeOptions));
238
+ return true;
239
+ }
240
+ catch (error) {
241
+ settleFatal(error instanceof Error ? error.message : String(error));
242
+ return false;
243
+ }
244
+ }
245
+ if (routedThroughCmd)
246
+ launch('cmd.exe', cmdArgs());
247
+ else if (!launch(command, args))
248
+ return failedChild(done);
249
+ return {
250
+ get pid() {
251
+ return currentSource?.pid;
252
+ },
253
+ get settled() {
254
+ return settled;
255
+ },
256
+ writeStdin(chunk) {
257
+ if (stdinEnded)
258
+ return;
259
+ stdinChunks.push(chunk);
260
+ flushStdin();
261
+ },
262
+ endStdin() {
263
+ stdinEnded = true;
264
+ flushStdin();
265
+ },
266
+ killTree() {
267
+ // Signalling after the child settled could hit a RECYCLED pid, which
268
+ // on win32 means taskkill /T /F on somebody else's process tree.
269
+ if (settled || currentSource === undefined)
270
+ return;
271
+ killTreeOf(currentSource);
272
+ },
273
+ done,
274
+ };
275
+ },
276
+ };
277
+ }
278
+ function failedChild(done) {
279
+ return {
280
+ pid: undefined,
281
+ settled: true,
282
+ writeStdin() { },
283
+ endStdin() { },
284
+ killTree() { },
285
+ done,
286
+ };
287
+ }
@@ -0,0 +1,88 @@
1
+ import type { ExecutorAdapter, ResultEnvelope, RunRequest } from '@skanl/brambo-contracts';
2
+ import type { PluginFactory, PluginManifest } from '@skanl/brambo-kernel';
3
+ import { type CliExecutorAdapterOptions } from './traits.ts';
4
+ /** The service name this plugin provides. */
5
+ export declare const EXECUTOR_SERVICE = "executor";
6
+ /** The plugin id this plugin registers under. */
7
+ export declare const EXECUTOR_PLUGIN_ID = "executor";
8
+ /** The key this plugin reads out of the kernel's composed configuration. */
9
+ export declare const EXECUTOR_CONFIG_KEY = "executor";
10
+ /**
11
+ * What one executor run is ADMITTED at when nothing configures otherwise, before
12
+ * the vendor says what it actually spent.
13
+ *
14
+ * ponytail: still a flat 1. Brambo may not invent a token figure — estimating or
15
+ * tokenizing is the exact thing this story removes — so the only honest pre-run
16
+ * number is a placeholder in whatever unit the caller's caps are denominated in.
17
+ * A host budgeting tokens passes its own `cost`; the settlement then replaces it
18
+ * with the vendor's own figure either way. Upgrade path: a per-executor estimate,
19
+ * which is per-model weighting and Ask-First (deferred-work.md).
20
+ */
21
+ export declare const DEFAULT_EXECUTOR_ACTION_COST = 1;
22
+ /**
23
+ * What `kernel.getService('executor')` hands back.
24
+ *
25
+ * Deliberately NOT an `ExecutorAdapter`. The honest limit is stated on
26
+ * `createExecutorPlugin` and in `deferred-work.md`: this closes the CONTAINER's
27
+ * surface, not the process — any package may still import a vendor factory from
28
+ * this one and drive an adapter itself.
29
+ */
30
+ export interface ExecutorService {
31
+ /** Which shipped executor this is driving; a host-supplied adapter reports `'(injected)'`. */
32
+ readonly executorId: string;
33
+ /**
34
+ * Runs one request through the KERNEL's interception waterfall.
35
+ *
36
+ * `actionId` names this invocation in the record stream, and the caller owns
37
+ * it because the caller owns run identity — the session scopes it to the
38
+ * workspace so two sessions on one pipeline stay distinguishable. The COST is
39
+ * the plugin's, never the caller's: a caller that could price its own run
40
+ * could price it at zero and walk through a cost cap. So is the SETTLEMENT —
41
+ * the plugin observed the vendor, the caller did not, and a caller that could
42
+ * reconcile its own run to zero has defeated the budget just as thoroughly.
43
+ */
44
+ run(actionId: string, request: RunRequest): Promise<ResultEnvelope>;
45
+ }
46
+ export interface ExecutorPluginOptions {
47
+ /**
48
+ * Adapter seam; a host that built its own executor passes it here and the
49
+ * catalogue is never consulted. It still runs through the waterfall — the
50
+ * seam replaces WHICH executor runs, never WHETHER the pipeline sees it.
51
+ */
52
+ readonly createAdapter?: () => ExecutorAdapter;
53
+ /**
54
+ * Options handed to the SELECTED adapter: a child-process spawner, or a binary
55
+ * path that overrides the trait's command. Ignored when `createAdapter` is
56
+ * supplied, because then the caller built the adapter itself.
57
+ */
58
+ readonly adapterOptions?: CliExecutorAdapterOptions;
59
+ /** What one run costs this kernel's budget. Defaults to `DEFAULT_EXECUTOR_ACTION_COST`. */
60
+ readonly cost?: number;
61
+ }
62
+ export interface ExecutorPlugin {
63
+ readonly manifest: PluginManifest;
64
+ readonly factory: PluginFactory;
65
+ }
66
+ /**
67
+ * The executor adapter as a kernel plugin: a manifest providing the `executor`
68
+ * service, a factory that resolves WHICH adapter from the kernel's own composed
69
+ * configuration, and a disposer that drops it.
70
+ *
71
+ * Misconfiguration REJECTS activation (a contained start failure naming this
72
+ * plugin), never a mid-run surprise — the same rule `@skanl/brambo-registry`'s plugin
73
+ * follows, and the reason a bad `executor` key cannot take the kernel down.
74
+ *
75
+ * The honest scope of the no-bypass claim, corrected on review: this SERVICE
76
+ * exports no path around the waterfall, and the object it hands back is frozen
77
+ * so no other caller can be taken off it either. Three routes remain open and
78
+ * are named in `deferred-work.md` rather than narrated away:
79
+ * - anyone who installs THIS package can call `createExecutorAdapter(id)` or
80
+ * any of the three vendor factories and drive an adapter directly;
81
+ * - this `factory` is a `PluginFactory`, so a holder can invoke it with an
82
+ * `ActivationContext` of their own and get a real adapter wired to their own
83
+ * pipeline — inherent to the plugin shape, and the reason `@skanl/brambo-session`
84
+ * does not re-export it;
85
+ * - `kernel.swap` runs a caller-supplied factory against the live registry, so
86
+ * a kernel holder can replace this service outright.
87
+ */
88
+ export declare function createExecutorPlugin(options?: ExecutorPluginOptions): ExecutorPlugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,181 @@
1
+ import { BRAMBO_ERROR_CODES, BramboError, defineStandardSchema } from '@skanl/brambo-contracts';
2
+ import { isNonEmptyString, isRecord, issue } from '@skanl/brambo-contracts/validation';
3
+ import { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, availableExecutorIds, createExecutorAdapter, unknownExecutor, } from './catalogue.js';
4
+ import { USAGE_DATA_KEY } from './traits.js';
5
+ // The executor adapter, mounted as a kernel plugin (Story M3.B).
6
+ //
7
+ // It is the interesting one of the two this story adds, because of what its
8
+ // SERVICE is. A service that handed back an `ExecutorAdapter` would put `.run()`
9
+ // on the surface of the container, and every consumer of the kernel could then
10
+ // spawn an executor with no budget, no guard and no record — which is exactly
11
+ // the hole AD-10 exists to close. So the service is a RUNNER: the adapter is
12
+ // closed over and never handed out, and the only exported way to reach it
13
+ // registers the invocation on the kernel's own pipeline and invokes it there.
14
+ //
15
+ // Configuration comes from the plugin's own key of the kernel's layered config —
16
+ // `executor`, the same key brambo's `.brambo/config.json` already spells, so one
17
+ // composed document decides both what `brambo run` reports and what this mounts.
18
+ /** The service name this plugin provides. */
19
+ export const EXECUTOR_SERVICE = 'executor';
20
+ /** The plugin id this plugin registers under. */
21
+ export const EXECUTOR_PLUGIN_ID = 'executor';
22
+ /** The key this plugin reads out of the kernel's composed configuration. */
23
+ export const EXECUTOR_CONFIG_KEY = 'executor';
24
+ /**
25
+ * What one executor run is ADMITTED at when nothing configures otherwise, before
26
+ * the vendor says what it actually spent.
27
+ *
28
+ * ponytail: still a flat 1. Brambo may not invent a token figure — estimating or
29
+ * tokenizing is the exact thing this story removes — so the only honest pre-run
30
+ * number is a placeholder in whatever unit the caller's caps are denominated in.
31
+ * A host budgeting tokens passes its own `cost`; the settlement then replaces it
32
+ * with the vendor's own figure either way. Upgrade path: a per-executor estimate,
33
+ * which is per-model weighting and Ask-First (deferred-work.md).
34
+ */
35
+ export const DEFAULT_EXECUTOR_ACTION_COST = 1;
36
+ /**
37
+ * The vendor's own usage figure, as the adapter put it on the envelope.
38
+ *
39
+ * Forwarded whatever it is, never sanitised here: an absent key means "nothing
40
+ * observed this run" and charges the estimate, while a PRESENT but broken value
41
+ * is a coded rejection the pipeline owns and records. Quietly turning the second
42
+ * into the first would hide a lying adapter behind a normal-looking run.
43
+ */
44
+ function reportedUsage(envelope) {
45
+ const data = envelope.data;
46
+ if (!isRecord(data) || !Object.hasOwn(data, USAGE_DATA_KEY))
47
+ return undefined;
48
+ return data[USAGE_DATA_KEY];
49
+ }
50
+ /**
51
+ * The plugin's own subtree of the kernel's layered configuration.
52
+ *
53
+ * That subtree is a STRING, not an object, and that is the point: brambo's
54
+ * `.brambo/config.json` already spells its executor selection as
55
+ * `{"executor": "codex"}`, so the plugin reads the document the user already
56
+ * writes rather than a parallel one invented for the container.
57
+ */
58
+ const EXECUTOR_CONFIG_SCHEMA = defineStandardSchema((value) => {
59
+ if (value === undefined)
60
+ return { value: undefined };
61
+ if (!isNonEmptyString(value)) {
62
+ return {
63
+ issues: [
64
+ issue(`'${EXECUTOR_CONFIG_KEY}' must be a string naming one of: ${availableExecutorIds().join(', ')}`),
65
+ ],
66
+ };
67
+ }
68
+ const executorId = value.trim();
69
+ if (!EXECUTOR_CATALOGUE.has(executorId)) {
70
+ return { issues: [issue(unknownExecutor(executorId).message)] };
71
+ }
72
+ return { value: executorId };
73
+ });
74
+ /**
75
+ * The executor adapter as a kernel plugin: a manifest providing the `executor`
76
+ * service, a factory that resolves WHICH adapter from the kernel's own composed
77
+ * configuration, and a disposer that drops it.
78
+ *
79
+ * Misconfiguration REJECTS activation (a contained start failure naming this
80
+ * plugin), never a mid-run surprise — the same rule `@skanl/brambo-registry`'s plugin
81
+ * follows, and the reason a bad `executor` key cannot take the kernel down.
82
+ *
83
+ * The honest scope of the no-bypass claim, corrected on review: this SERVICE
84
+ * exports no path around the waterfall, and the object it hands back is frozen
85
+ * so no other caller can be taken off it either. Three routes remain open and
86
+ * are named in `deferred-work.md` rather than narrated away:
87
+ * - anyone who installs THIS package can call `createExecutorAdapter(id)` or
88
+ * any of the three vendor factories and drive an adapter directly;
89
+ * - this `factory` is a `PluginFactory`, so a holder can invoke it with an
90
+ * `ActivationContext` of their own and get a real adapter wired to their own
91
+ * pipeline — inherent to the plugin shape, and the reason `@skanl/brambo-session`
92
+ * does not re-export it;
93
+ * - `kernel.swap` runs a caller-supplied factory against the live registry, so
94
+ * a kernel holder can replace this service outright.
95
+ */
96
+ export function createExecutorPlugin(options = {}) {
97
+ // ONE read of every caller-supplied field, at CONSTRUCTION. `createActionPipeline`
98
+ // states the discipline verbatim — "a budget a caller can raise after
99
+ // construction by mutating the object it handed in is not a budget" — and
100
+ // reading `cost` inside the factory reopened exactly that: mutating the
101
+ // options object between here and `kernel.start()` priced a run at 0 under a
102
+ // 0.5 cap. Measured, then closed.
103
+ const { createAdapter, adapterOptions, cost = DEFAULT_EXECUTOR_ACTION_COST } = options;
104
+ const manifest = {
105
+ id: EXECUTOR_PLUGIN_ID,
106
+ version: '0.0.0',
107
+ provides: [EXECUTOR_SERVICE],
108
+ consumes: [],
109
+ configSchema: EXECUTOR_CONFIG_SCHEMA,
110
+ };
111
+ const factory = (context) => {
112
+ // Since M7.C the KERNEL resolves `config.resolve()[manifest.id]`, validates
113
+ // it against this plugin's own `configSchema`, and refuses the plugin before
114
+ // this body runs — so the read, the subtree pick, the validate call, the
115
+ // promise check and the issue mapping that used to live here are gone. What
116
+ // arrives is the schema's own `value`, already checked.
117
+ //
118
+ // The plugin id IS the config key (`executor` both), which is the rule the
119
+ // kernel now states rather than the convention three plugins each spelled out.
120
+ const selected = context.settings;
121
+ const adapter = createAdapter === undefined ? createExecutorAdapter(selected, adapterOptions) : createAdapter();
122
+ const executorId = createAdapter === undefined ? selected ?? DEFAULT_EXECUTOR_ID : '(injected)';
123
+ let disposed = false;
124
+ // FROZEN, and not merely `readonly`: `getService` hands every caller the
125
+ // same object, so an un-frozen one let a kernel holder who never had the
126
+ // adapter overwrite `run` and take every OTHER caller off the waterfall —
127
+ // measured at three runs past a cap of 1 with an empty record stream. The
128
+ // pipeline freezes its handle and its descriptor for the same reason.
129
+ const service = Object.freeze({
130
+ executorId,
131
+ async run(actionId, request) {
132
+ if (disposed) {
133
+ // Coded, and it names the plugin: a handle kept past `kernel.stop()`
134
+ // reaching a live adapter is the disposal leak this pairing exists to
135
+ // prevent, and `undefined` is what it would otherwise look like.
136
+ throw new BramboError(BRAMBO_ERROR_CODES.kernelPluginInactive, `plugin '${EXECUTOR_PLUGIN_ID}' is inactive: the '${EXECUTOR_SERVICE}' service was disposed with its plugin`);
137
+ }
138
+ // Registered per invocation, because `ActionDefinition.run` takes no
139
+ // arguments: the pipeline reads the operation ONCE at registration and
140
+ // holds it, which is what stops a caller swapping the operation after
141
+ // the price was agreed. A per-request handle is the only shape that
142
+ // survives that rule.
143
+ //
144
+ // ponytail: a pipeline remembers every id it ever registered, so a
145
+ // long-lived kernel running many sessions grows that set. Workspace ids
146
+ // are UUIDs, so collisions are not the concern — retention is. Upgrade
147
+ // path: a pipeline that can retire a handle, which is the same mechanism
148
+ // a post-hoc cost adjustment needs (deferred-work.md).
149
+ const handle = context.actions.register({
150
+ id: actionId,
151
+ cost,
152
+ run: () => adapter.run(request),
153
+ // Admitted at the estimate, reconciled to what the vendor reported.
154
+ // Declared HERE, beside `cost` and `run`, because the pipeline reads
155
+ // all three once at registration and the caller supplies none of them.
156
+ //
157
+ // A failed or cancelled run still resolves an envelope, and the adapter
158
+ // now reads the vendor's figure off EVERY outcome — a killed child
159
+ // settles carrying what it had already printed — so whatever it spent
160
+ // before it gave up is charged. A run that produced no figure keeps its
161
+ // estimate, and the pipeline floors a settlement at that estimate, so
162
+ // there is no path on which failing, cancelling or under-reporting is
163
+ // cheaper than reporting honestly.
164
+ settle: reportedUsage,
165
+ });
166
+ return await handle.invoke();
167
+ },
168
+ });
169
+ return {
170
+ status: 'activated',
171
+ services: { [EXECUTOR_SERVICE]: service },
172
+ dispose: () => {
173
+ // The adapter itself owns no resource between runs (each run spawns and
174
+ // reaps its own child), so disposal is about the SERVICE: a handle kept
175
+ // past `kernel.stop()` must not still be able to spawn.
176
+ disposed = true;
177
+ },
178
+ };
179
+ };
180
+ return { manifest, factory };
181
+ }
@@ -0,0 +1,38 @@
1
+ export interface SpawnOutcome {
2
+ /** null when the process was killed by a signal or never spawned. */
3
+ readonly exitCode: number | null;
4
+ readonly stdout: string;
5
+ readonly stderr: string;
6
+ /** Set only when the process could not be spawned at all (e.g. missing binary). */
7
+ readonly spawnErrorMessage?: string;
8
+ /** Set when a pipe to/from the child failed mid-run (e.g. EPIPE on stdin write). */
9
+ readonly streamErrorMessage?: string;
10
+ readonly stdoutTruncated?: boolean;
11
+ readonly stderrTruncated?: boolean;
12
+ }
13
+ export interface SpawnedChild {
14
+ /** undefined when the platform refused to start the process. */
15
+ readonly pid: number | undefined;
16
+ /**
17
+ * Synchronously true once `done` has been resolved. Callers need this to
18
+ * decide SYNCHRONOUSLY whether a run already finished: `done.then(...)` only
19
+ * runs a microtask later, and an abort dispatched inside that window would
20
+ * otherwise discard a completed run as cancelled.
21
+ */
22
+ readonly settled: boolean;
23
+ writeStdin(chunk: string): void;
24
+ endStdin(): void;
25
+ /**
26
+ * Terminate the child AND every descendant it spawned. Idempotent; safe to
27
+ * call even when the process already exited.
28
+ */
29
+ killTree(): void;
30
+ /** Resolves exactly once, after the whole tree has settled. Never rejects. */
31
+ readonly done: Promise<SpawnOutcome>;
32
+ }
33
+ export interface SpawnOptions {
34
+ readonly cwd: string;
35
+ }
36
+ export interface ChildProcessSpawner {
37
+ spawn(command: string, args: readonly string[], options: SpawnOptions): SpawnedChild;
38
+ }
@@ -0,0 +1,5 @@
1
+ // The child-process seam. The adapter talks to executors exclusively through this
2
+ // interface, so unit tests and the contract suite run against fake children while
3
+ // the real Node spawner (with Windows-safe process-tree termination) is exercised
4
+ // by the overhead measurement and the env-gated live smoke.
5
+ export {};