@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/traits.js ADDED
@@ -0,0 +1,589 @@
1
+ import { BramboError, BRAMBO_ERROR_CODES, USAGE_ABSENCE_REASONS } from '@skanl/brambo-contracts';
2
+ import { isRecord, usageAbsence, usageObservation, validateRunRequest } from '@skanl/brambo-contracts';
3
+ import { createNodeChildSpawner, routesThroughCmdShim } from './node-child-spawner.js';
4
+ const SUMMARY_MAX_LENGTH = 200;
5
+ const BYTE_ORDER_MARK = '\uFEFF';
6
+ // Conservative argv bounds: win32 caps a whole command line at 32767 chars,
7
+ // Linux caps a SINGLE argument at 128 KiB. Refusing just under the smaller of
8
+ // the two turns an unattributable OS spawn error into a coded envelope that
9
+ // names the limit.
10
+ const ARGUMENT_PROMPT_MAX_LENGTH = process.platform === 'win32' ? 30_000 : 100_000;
11
+ // Keys the engine itself writes into `envelope.data`; a metadata key colliding
12
+ // with one of them would silently overwrite the real result, hide truncation, or
13
+ // — since M3.C — forge the figure a cost cap is enforced on.
14
+ const RESERVED_DATA_KEYS = ['result', 'stdoutTruncated', 'stderrTruncated', 'usage', 'malformedStreamLines'];
15
+ /** The engine-owned `data` key a settled cost is read back from. */
16
+ export const USAGE_DATA_KEY = 'usage';
17
+ /**
18
+ * The engine-owned `data` key counting stream lines that were not JSON (E6).
19
+ *
20
+ * Written ONLY when the count is non-zero, exactly like `stdoutTruncated`. A bad
21
+ * line must never discard a run that completed — but a run whose stream brambo
22
+ * could only partly read is not the same run as one it read whole, and silence
23
+ * there is the difference nobody can see afterwards.
24
+ */
25
+ // Not exported, unlike `USAGE_DATA_KEY` beside it: that one has a real consumer
26
+ // in `plugin.ts`, and this one has none. A constant on the package surface that
27
+ // nothing outside reads is surface nobody asked for.
28
+ const MALFORMED_LINES_DATA_KEY = 'malformedStreamLines';
29
+ export function createCliExecutorAdapter(traits, options = {}) {
30
+ validateExecutorTraits(traits);
31
+ return new TraitDrivenAdapter(traits, options);
32
+ }
33
+ // "Adding an executor is adding a record" makes the record the API surface, so
34
+ // it gets the same factory-time validation Story 2.3 gives projection traits.
35
+ // Each rejected shape below is one that fails SILENTLY at runtime rather than
36
+ // loudly: an empty resultPath resolves to the record itself, and an empty
37
+ // errorStatusPrefix marks every single run failed.
38
+ function validateExecutorTraits(traits) {
39
+ const reject = (detail) => {
40
+ throw new BramboError(BRAMBO_ERROR_CODES.contractEnvelopeInvalid, `executor traits for '${traits.executorId}' are invalid: ${detail}`);
41
+ };
42
+ if (traits.executorId.trim().length === 0)
43
+ reject("'executorId' must be a non-empty string");
44
+ if (traits.command.trim().length === 0)
45
+ reject("'command' must be a non-empty string");
46
+ const output = traits.output;
47
+ if (output.resultPath.length === 0)
48
+ reject("'output.resultPath' must name at least one property");
49
+ if (output.errorStatusPrefix !== undefined && output.errorStatusPrefix.length === 0) {
50
+ reject("'output.errorStatusPrefix' must be non-empty — an empty prefix marks every run failed");
51
+ }
52
+ if (output.errorFlagPath !== undefined && output.errorFlagPath.length === 0) {
53
+ reject("'output.errorFlagPath' must name at least one property");
54
+ }
55
+ for (const key of Object.keys(output.metadata ?? {})) {
56
+ if (RESERVED_DATA_KEYS.includes(key))
57
+ reject(`'output.metadata' key '${key}' collides with an engine-owned data key`);
58
+ }
59
+ if (output.usagePaths !== undefined) {
60
+ // An empty list declares "this vendor reports usage" and then never produces
61
+ // a figure — the inert shape this story exists to refuse. An empty PATH
62
+ // resolves to the record itself, which is never a number, so it would make
63
+ // every run silently unsettleable.
64
+ if (output.usagePaths.length === 0)
65
+ reject("'output.usagePaths' must name at least one path, or be omitted");
66
+ for (const path of output.usagePaths) {
67
+ if (path.length === 0)
68
+ reject("'output.usagePaths' entries must each name at least one property");
69
+ }
70
+ // Required, because the figure is SUMMED across matching records: an
71
+ // undiscriminated sum bills every record that happens to fit the shape.
72
+ if (output.usageWhen === undefined) {
73
+ reject("'output.usageWhen' is required beside 'output.usagePaths' — a summed figure needs a bounded set of records");
74
+ }
75
+ }
76
+ if (output.usageWhen !== undefined && output.usageWhen.path.length === 0) {
77
+ reject("'output.usageWhen.path' must name at least one property");
78
+ }
79
+ if (output.failureWhen !== undefined && output.failureWhen.path.length === 0) {
80
+ reject("'output.failureWhen.path' must name at least one property");
81
+ }
82
+ const windows = output.usageWindows;
83
+ if (windows !== undefined) {
84
+ // Each rejected shape is one that would fail SILENTLY: an empty `when.path`
85
+ // resolves to the record itself and matches nothing, an empty `path`
86
+ // resolves to the whole event, and an empty key never names a field. All
87
+ // three produce "this executor reported no quota" forever, which is exactly
88
+ // the inert surface AD-5 forbids dressing up as an absence.
89
+ if (windows.when.path.length === 0)
90
+ reject("'output.usageWindows.when.path' must name at least one property");
91
+ if (windows.path.length === 0)
92
+ reject("'output.usageWindows.path' must name at least one property");
93
+ if (windows.utilizationKey.length === 0)
94
+ reject("'output.usageWindows.utilizationKey' must be a non-empty string");
95
+ if (windows.resetsAtKey.length === 0)
96
+ reject("'output.usageWindows.resetsAtKey' must be a non-empty string");
97
+ }
98
+ }
99
+ class TraitDrivenAdapter {
100
+ #traits;
101
+ #spawner;
102
+ #command;
103
+ #onTiming;
104
+ #onUsageObservation;
105
+ constructor(traits, options) {
106
+ this.#traits = traits;
107
+ this.#spawner = options.spawner ?? createNodeChildSpawner();
108
+ this.#command = options.command ?? traits.command;
109
+ this.#onTiming = options.onTiming;
110
+ this.#onUsageObservation = options.onUsageObservation;
111
+ }
112
+ get executorId() {
113
+ return this.#traits.executorId;
114
+ }
115
+ async run(request) {
116
+ validateRunRequest(request);
117
+ const startedAt = performance.now();
118
+ // Measured before any child exists; overwritten after spawn on the real path.
119
+ let spawnSetupMs = performance.now() - startedAt;
120
+ if (request.signal?.aborted)
121
+ return this.#cancelled(startedAt, spawnSetupMs);
122
+ const refusal = this.#refuseArgumentPrompt(request.prompt);
123
+ if (refusal !== undefined)
124
+ return this.#finish(startedAt, spawnSetupMs, refusal);
125
+ // Everything between startedAt and here is adapter-added overhead (NFR-9);
126
+ // the OS-level process start itself is shared with a raw CLI invocation.
127
+ let child;
128
+ try {
129
+ child = this.#spawner.spawn(this.#command, this.#argv(request.prompt), { cwd: request.workspace.rootPath });
130
+ spawnSetupMs = performance.now() - startedAt;
131
+ }
132
+ catch (error) {
133
+ return this.#finish(startedAt, spawnSetupMs, this.#failed(`executor '${this.#command}' could not be spawned: ${describe(error)}`, BRAMBO_ERROR_CODES.executorUnavailable));
134
+ }
135
+ // Completion must be observed before an abort can claim cancellation: an
136
+ // abort landing AFTER the child already exited successfully yields the real
137
+ // ok/failed result, not a cancelled envelope. `child.settled` closes the
138
+ // window between `done` resolving and the `.then` microtask below running.
139
+ let completionSettled = false;
140
+ const completion = child.done.then((outcome) => {
141
+ completionSettled = true;
142
+ return outcome;
143
+ });
144
+ let cancelledInFlight = false;
145
+ const abort = () => {
146
+ if (completionSettled || child.settled)
147
+ return;
148
+ cancelledInFlight = true;
149
+ child.killTree();
150
+ };
151
+ request.signal?.addEventListener('abort', abort, { once: true });
152
+ try {
153
+ try {
154
+ if (this.#traits.promptDelivery === 'stdin')
155
+ child.writeStdin(request.prompt);
156
+ child.endStdin();
157
+ }
158
+ catch (error) {
159
+ // The child is alive and unreachable: without this it keeps running
160
+ // against the workspace after the envelope is returned.
161
+ child.killTree();
162
+ return this.#finish(startedAt, spawnSetupMs, this.#failed(`pipe to executor '${this.#command}' failed: ${describe(error)}`, BRAMBO_ERROR_CODES.executorRunFailed));
163
+ }
164
+ const outcome = await completion;
165
+ if (cancelledInFlight) {
166
+ // A killed child settles through `close` carrying everything it printed,
167
+ // so the tokens it already spent are right here. Charging a cancelled run
168
+ // its estimate while its own stdout says otherwise is a hole a caller can
169
+ // drive through by aborting late.
170
+ return this.#cancelled(startedAt, spawnSetupMs, this.#unstructuredData(outcome, this.#scan(outcome.stdout)));
171
+ }
172
+ return this.#fromOutcome(outcome, startedAt, spawnSetupMs);
173
+ }
174
+ finally {
175
+ request.signal?.removeEventListener('abort', abort);
176
+ }
177
+ }
178
+ #argv(prompt) {
179
+ if (this.#traits.promptDelivery !== 'argument')
180
+ return this.#traits.args;
181
+ const separator = this.#traits.promptArgSeparator;
182
+ return separator === undefined ? [...this.#traits.args, prompt] : [...this.#traits.args, separator, prompt];
183
+ }
184
+ /**
185
+ * Guards the argument-delivery path, which puts caller-supplied text in argv.
186
+ *
187
+ * The cmd.exe refusal is the important one: on win32 a `.cmd`/`.bat` command
188
+ * can only start by rerouting through a SHELL, and no amount of quoting stops
189
+ * cmd.exe from interpreting `&`, `|`, `>`, `^` or `%VAR%` in the prompt.
190
+ * Escaping for cmd.exe is not winnable, so the run fails closed instead.
191
+ */
192
+ #refuseArgumentPrompt(prompt) {
193
+ if (this.#traits.promptDelivery !== 'argument')
194
+ return undefined;
195
+ if (routesThroughCmdShim(this.#command)) {
196
+ return this.#failed(`executor '${this.#command}' can only start through cmd.exe, which would interpret shell metacharacters in the prompt argument; point 'command' at the real executable instead of the .cmd shim`, BRAMBO_ERROR_CODES.executorUnavailable);
197
+ }
198
+ if (prompt.length > ARGUMENT_PROMPT_MAX_LENGTH) {
199
+ return this.#failed(`prompt of ${prompt.length} characters exceeds the ${ARGUMENT_PROMPT_MAX_LENGTH}-character argument limit of executor '${this.#command}'`, BRAMBO_ERROR_CODES.executorRunFailed);
200
+ }
201
+ return undefined;
202
+ }
203
+ #fromOutcome(outcome, startedAt, spawnSetupMs) {
204
+ const finish = (envelope) => this.#finish(startedAt, spawnSetupMs, envelope);
205
+ // Scanned FIRST, and on every path below. A child that failed, was killed or
206
+ // was cut off still printed whatever it had already spent, and those bytes are
207
+ // in hand here — discarding them made failing and cancelling free, which is
208
+ // exactly the evasion a budget must not have. Measured before this moved: a
209
+ // cancelled run carrying 500,000 reported tokens in captured stdout was
210
+ // charged its estimate of 1.
211
+ const scan = this.#scan(outcome.stdout);
212
+ const truncation = this.#unstructuredData(outcome, scan);
213
+ if (outcome.spawnErrorMessage !== undefined) {
214
+ // The one path with genuinely nothing to read: no child ever started.
215
+ return finish(this.#failed(`executor '${this.#command}' is not available: ${outcome.spawnErrorMessage}`, BRAMBO_ERROR_CODES.executorUnavailable));
216
+ }
217
+ if (outcome.streamErrorMessage !== undefined) {
218
+ return finish(this.#failed(`pipe to or from executor '${this.#command}' failed: ${outcome.streamErrorMessage}`, BRAMBO_ERROR_CODES.executorRunFailed, truncation));
219
+ }
220
+ if (outcome.exitCode === null) {
221
+ return finish(this.#failed(`executor '${this.#command}' was terminated by an external signal before completing`, BRAMBO_ERROR_CODES.executorRunFailed, truncation));
222
+ }
223
+ // An executor that reports its own failure is believed regardless of exit
224
+ // code and regardless of WHERE in the stream it said so: OpenCode emits
225
+ // recoverable error events and keeps going, so a positional rule would drop
226
+ // the reason whenever any output followed it.
227
+ if (scan.failure !== undefined)
228
+ return finish(this.#failedFromRecord(scan.failure, outcome, scan));
229
+ if (outcome.exitCode !== 0) {
230
+ // codex and opencode exit non-zero exactly when they have printed their
231
+ // structured error, so the payload — not stderr noise — is the reason.
232
+ if (scan.result !== undefined)
233
+ return finish(this.#failedFromRecord(scan.result, outcome, scan));
234
+ const detail = outcome.stderr.trim().length > 0
235
+ ? outcome.stderr.trim()
236
+ : `executor '${this.#command}' exited with code ${outcome.exitCode}`;
237
+ return finish(this.#failed(detail, BRAMBO_ERROR_CODES.executorRunFailed, truncation));
238
+ }
239
+ // A cut stream can leave an earlier event as the last PARSEABLE one, so a
240
+ // truncated capture must never be reported as a complete answer.
241
+ if (outcome.stdoutTruncated === true) {
242
+ return finish(this.#failed(`executor '${this.#command}' produced more output than could be captured, so its result is incomplete`, BRAMBO_ERROR_CODES.executorRunFailed, truncation));
243
+ }
244
+ if (scan.result !== undefined)
245
+ return finish(this.#okFromRecord(scan.result, outcome, scan));
246
+ return finish(this.#failed(this.#noResultDetail(scan), BRAMBO_ERROR_CODES.executorRunFailed, truncation));
247
+ }
248
+ #noResultDetail(scan) {
249
+ if (!scan.sawRecord) {
250
+ return this.#traits.output.payload === 'single-object'
251
+ ? `executor '${this.#command}' printed unparseable output instead of JSON`
252
+ : `executor '${this.#command}' printed no JSON event carrying a '${this.#traits.output.resultPath.join('.')}' result`;
253
+ }
254
+ return `executor '${this.#command}' returned JSON without a usable '${this.#traits.output.resultPath.join('.')}' result`;
255
+ }
256
+ /**
257
+ * Reads stdout once and reports the two records that matter: the FIRST record
258
+ * reporting a failure, and the LAST record carrying a usable result.
259
+ *
260
+ * Event-type names (`item.completed`, `text`, …) are the executors' own
261
+ * evolving vocabularies; the engine never matches on them. It matches on the
262
+ * paths the trait record names, which is why trailing noise (blank lines,
263
+ * non-object lines, bookkeeping events) costs nothing.
264
+ */
265
+ #scan(stdout) {
266
+ const scan = {
267
+ failure: undefined,
268
+ result: undefined,
269
+ usage: undefined,
270
+ windows: undefined,
271
+ malformedLines: 0,
272
+ sawRecord: false,
273
+ };
274
+ const text = stdout.startsWith(BYTE_ORDER_MARK) ? stdout.slice(BYTE_ORDER_MARK.length) : stdout;
275
+ // Accumulated across every record `usageWhen` selects, and voided outright by
276
+ // any one of them that cannot be read — see `usagePaths`.
277
+ let usageTotal;
278
+ let usageVoid = false;
279
+ const consider = (record) => {
280
+ scan.sawRecord = true;
281
+ if (scan.failure === undefined && this.#reportsFailure(record))
282
+ scan.failure = record;
283
+ if (this.#resultText(record) !== undefined)
284
+ scan.result = record;
285
+ // LAST wins, like the result: a vendor that re-reports its quota mid-run
286
+ // has said something newer, and the newest reading is the true one.
287
+ const windows = this.#usageWindowsOf(record);
288
+ if (windows !== undefined)
289
+ scan.windows = windows;
290
+ if (usageVoid || !this.#reportsUsage(record))
291
+ return;
292
+ const usage = this.#usageOf(record);
293
+ if (usage === undefined) {
294
+ usageVoid = true;
295
+ return;
296
+ }
297
+ usageTotal = (usageTotal ?? 0) + usage;
298
+ };
299
+ if (this.#traits.output.payload === 'single-object') {
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(text);
303
+ }
304
+ catch {
305
+ this.#reportUsage(scan);
306
+ return scan;
307
+ }
308
+ if (isRecord(parsed))
309
+ consider(parsed);
310
+ scan.usage = usageVoid ? undefined : usageTotal;
311
+ this.#reportUsage(scan);
312
+ return scan;
313
+ }
314
+ for (const rawLine of text.split(/\r?\n/)) {
315
+ const line = rawLine.trim();
316
+ if (line.length === 0)
317
+ continue;
318
+ let parsed;
319
+ try {
320
+ parsed = JSON.parse(line);
321
+ }
322
+ catch {
323
+ // Skipped, and COUNTED (E6). One bad line in the middle of a stream must
324
+ // not throw away a run that reached its result — and it must not vanish
325
+ // either, or a partly-readable stream is indistinguishable from a whole one.
326
+ scan.malformedLines += 1;
327
+ continue;
328
+ }
329
+ if (isRecord(parsed))
330
+ consider(parsed);
331
+ }
332
+ scan.usage = usageVoid ? undefined : usageTotal;
333
+ this.#reportUsage(scan);
334
+ return scan;
335
+ }
336
+ /**
337
+ * Hands the caller what the vendor said about its own quota, once per run.
338
+ *
339
+ * Called from `#scan`, which is the one function every settled path goes
340
+ * through exactly once — reporting from the callers instead would be two
341
+ * places to forget it in.
342
+ *
343
+ * An executor whose traits declare no surface reports NOTHING here rather than
344
+ * a `noUsageSurface` absence: that answer is a property of the executor, not
345
+ * of any run, and `brambo status` states it from the catalogue without needing
346
+ * a run to have happened at all.
347
+ */
348
+ #reportUsage(scan) {
349
+ const report = this.#onUsageObservation;
350
+ const traits = this.#traits.output.usageWindows;
351
+ if (report === undefined || traits === undefined)
352
+ return;
353
+ if (scan.windows === undefined || scan.windows.length === 0) {
354
+ report(usageAbsence(this.#traits.executorId, USAGE_ABSENCE_REASONS.notReported, `executor '${this.#traits.executorId}' produced no readable '${traits.path.join('.')}' in this run`));
355
+ return;
356
+ }
357
+ report(usageObservation(this.#traits.executorId, scan.windows, new Date().toISOString()));
358
+ }
359
+ /**
360
+ * The vendor's named windows carried by one record, or undefined when it
361
+ * carries none.
362
+ *
363
+ * Per WINDOW rather than fail-closed as a whole, and that asymmetry with
364
+ * `#usageOf` is deliberate: a usage figure is a BILL, where a missing term
365
+ * silently under-charges, so a term it cannot read voids the sum. These are a
366
+ * REPORT of what the vendor said, where each window stands on its own — a
367
+ * vendor that adds a third window in a shape brambo does not know must not
368
+ * erase the two it does.
369
+ */
370
+ #usageWindowsOf(record) {
371
+ const traits = this.#traits.output.usageWindows;
372
+ if (traits === undefined || resolvePath(record, traits.when.path) !== traits.when.equals)
373
+ return undefined;
374
+ const map = resolvePath(record, traits.path);
375
+ if (!isRecord(map))
376
+ return undefined;
377
+ const windows = [];
378
+ for (const [name, value] of Object.entries(map)) {
379
+ if (!isRecord(value))
380
+ continue;
381
+ const utilization = value[traits.utilizationKey];
382
+ const resetsAt = value[traits.resetsAtKey];
383
+ // Copied across unchanged (D5): the vendor's own name, the vendor's own
384
+ // number, the vendor's own instant. Nothing here scales, averages, picks
385
+ // one window over another, or turns a reset into a countdown.
386
+ if (typeof utilization !== 'number' || !Number.isFinite(utilization))
387
+ continue;
388
+ if (typeof resetsAt !== 'number' || !Number.isFinite(resetsAt))
389
+ continue;
390
+ windows.push({ name, utilization, resetsAt });
391
+ }
392
+ return windows.length > 0 ? windows : undefined;
393
+ }
394
+ /** Whether this record is one of the ones this vendor reports usage on. */
395
+ #reportsUsage(record) {
396
+ const when = this.#traits.output.usageWhen;
397
+ return (this.#traits.output.usagePaths !== undefined &&
398
+ when !== undefined &&
399
+ resolvePath(record, when.path) === when.equals);
400
+ }
401
+ /**
402
+ * What one usage record reports, summed over its declared components, or
403
+ * undefined when it cannot be read.
404
+ *
405
+ * Undefined here VOIDS the whole run's figure rather than skipping the record
406
+ * (see `#scan`): a term the engine could not read is spend it cannot account
407
+ * for, and dropping it silently under-bills.
408
+ */
409
+ #usageOf(record) {
410
+ let total = 0;
411
+ for (const path of this.#traits.output.usagePaths ?? []) {
412
+ const value = resolvePath(record, path);
413
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
414
+ return undefined;
415
+ total += value;
416
+ }
417
+ return total;
418
+ }
419
+ /** The result text a record carries, or undefined when it carries none. */
420
+ #resultText(record) {
421
+ const match = this.#traits.output.resultWhen;
422
+ if (match !== undefined && resolvePath(record, match.path) !== match.equals)
423
+ return undefined;
424
+ const value = resolvePath(record, this.#traits.output.resultPath);
425
+ // A blank result must not shadow the real answer: a trailing empty-string
426
+ // event would otherwise win the scan and produce an empty summary.
427
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
428
+ }
429
+ // An executor can report failure while exiting 0 (Claude's `is_error` and
430
+ // `error_*` subtypes, Codex's and OpenCode's `error` events), so the payload —
431
+ // not the exit code alone — decides the envelope status.
432
+ #reportsFailure(record) {
433
+ const output = this.#traits.output;
434
+ const when = output.failureWhen;
435
+ if (when !== undefined && resolvePath(record, when.path) !== when.equals)
436
+ return false;
437
+ if (output.errorFlagPath !== undefined && resolvePath(record, output.errorFlagPath) === true)
438
+ return true;
439
+ const status = this.#status(record);
440
+ return output.errorStatusPrefix !== undefined && status !== undefined && status.startsWith(output.errorStatusPrefix);
441
+ }
442
+ #status(record) {
443
+ if (this.#traits.output.statusPath === undefined)
444
+ return undefined;
445
+ const status = resolvePath(record, this.#traits.output.statusPath);
446
+ return typeof status === 'string' ? status : undefined;
447
+ }
448
+ /**
449
+ * `envelope.data` for one payload record.
450
+ *
451
+ * `usage` arrives as a separate argument rather than being read off `record`
452
+ * because the vendor rarely reports it there: codex prints it on
453
+ * `turn.completed` and opencode on `step_finish`, both AFTER the record that
454
+ * carried the answer. It is written as a NUMBER — the one non-string value in
455
+ * this object, and the only one an accounting seam can add up.
456
+ */
457
+ /**
458
+ * `envelope.data` for a failure that has no payload record to report from —
459
+ * truncation flags and whatever usage the child printed before it died.
460
+ * `null` when there is nothing at all, which is what those paths used to return
461
+ * unconditionally.
462
+ */
463
+ #unstructuredData(outcome, scan) {
464
+ const truncation = truncationData(outcome);
465
+ const skipped = malformedData(scan);
466
+ if (truncation === null && skipped === null && scan.usage === undefined)
467
+ return null;
468
+ return {
469
+ ...(truncation ?? {}),
470
+ ...(skipped ?? {}),
471
+ ...(scan.usage === undefined ? {} : { [USAGE_DATA_KEY]: scan.usage }),
472
+ };
473
+ }
474
+ #data(record, outcome, scan) {
475
+ const result = resolvePath(record, this.#traits.output.resultPath);
476
+ const data = typeof result === 'string' ? { result } : {};
477
+ for (const [key, path] of Object.entries(this.#traits.output.metadata ?? {})) {
478
+ const value = resolvePath(record, path);
479
+ if (typeof value === 'string')
480
+ data[key] = value;
481
+ }
482
+ if (scan.usage !== undefined)
483
+ data[USAGE_DATA_KEY] = scan.usage;
484
+ return { ...data, ...(truncationData(outcome) ?? {}), ...(malformedData(scan) ?? {}) };
485
+ }
486
+ #failedFromRecord(record, outcome, scan) {
487
+ const output = this.#traits.output;
488
+ const status = this.#status(record);
489
+ const reported = stringifyDetail(resolvePath(record, output.errorMessagePath ?? output.resultPath));
490
+ const detail = [reported.trim(), outcome.stderr.trim()].find((part) => part.length > 0) ?? '';
491
+ const reason = `executor '${this.#command}' reported failure${status !== undefined ? ` (${status})` : ''}`;
492
+ return this.#failed(detail.length > 0 ? `${reason}: ${detail}` : reason, BRAMBO_ERROR_CODES.executorRunFailed, this.#data(record, outcome, scan));
493
+ }
494
+ #okFromRecord(record, outcome, scan) {
495
+ return {
496
+ status: 'ok',
497
+ data: this.#data(record, outcome, scan),
498
+ summary: this.#summarize(this.#resultText(record) ?? ''),
499
+ errors: [],
500
+ };
501
+ }
502
+ #summarize(result) {
503
+ const firstLine = result
504
+ .split('\n')
505
+ .map((line) => line.trim())
506
+ .find((line) => line.length > 0);
507
+ // executorId, not command: an overridden command is an absolute filesystem
508
+ // path, which has no business leaking into a user-facing summary.
509
+ return truncate(firstLine ?? `${this.#traits.executorId} completed the task`);
510
+ }
511
+ #cancelled(startedAt, spawnSetupMs, data = null) {
512
+ return this.#finish(startedAt, spawnSetupMs, {
513
+ status: 'cancelled',
514
+ data,
515
+ summary: 'execution cancelled before completion',
516
+ errors: [
517
+ {
518
+ message: 'the run was cancelled and its process tree terminated',
519
+ code: BRAMBO_ERROR_CODES.executorCancelled,
520
+ },
521
+ ],
522
+ });
523
+ }
524
+ #failed(message, code, data = null) {
525
+ return {
526
+ status: 'failed',
527
+ data,
528
+ summary: truncate(message),
529
+ errors: [{ message, code }],
530
+ };
531
+ }
532
+ #finish(startedAt, spawnSetupMs, envelope) {
533
+ this.#onTiming?.({ spawnSetupMs, runMs: performance.now() - startedAt });
534
+ return envelope;
535
+ }
536
+ }
537
+ function resolvePath(record, path) {
538
+ let current = record;
539
+ for (const segment of path) {
540
+ // Own properties only, so a segment named like an Object.prototype member
541
+ // ('constructor', 'toString', …) can never resolve an inherited value and
542
+ // make EVERY record qualify. Today `isRecord` already stops each of those
543
+ // one hop earlier — every Object.prototype member is a function — so this
544
+ // is the second lock rather than the first, and it is the one that keeps
545
+ // holding if the record shape or `isRecord` ever loosens.
546
+ if (!isRecord(current) || !Object.hasOwn(current, segment))
547
+ return undefined;
548
+ current = current[segment];
549
+ }
550
+ return current;
551
+ }
552
+ /**
553
+ * The skipped-line count, or null when nothing was skipped.
554
+ *
555
+ * Absent on a clean stream ON PURPOSE: the envelope this story's acceptance
556
+ * compares against the old single-object mode must be key-for-key the same one,
557
+ * and a counter that is structurally zero is a counter nobody can read anyway.
558
+ */
559
+ function malformedData(scan) {
560
+ return scan.malformedLines > 0 ? { [MALFORMED_LINES_DATA_KEY]: scan.malformedLines } : null;
561
+ }
562
+ function truncationData(outcome) {
563
+ return outcome.stdoutTruncated === true || outcome.stderrTruncated === true
564
+ ? { stdoutTruncated: outcome.stdoutTruncated === true, stderrTruncated: outcome.stderrTruncated === true }
565
+ : null;
566
+ }
567
+ // Failure details are not always strings: OpenCode reports `error` as an object.
568
+ // Stringifying keeps the reason inside the envelope instead of dropping it.
569
+ function stringifyDetail(value) {
570
+ if (value === undefined || value === null)
571
+ return '';
572
+ if (typeof value === 'string')
573
+ return value;
574
+ try {
575
+ return JSON.stringify(value) ?? '';
576
+ }
577
+ catch {
578
+ return String(value);
579
+ }
580
+ }
581
+ // Cuts on code points, not UTF-16 units: slicing mid-surrogate would persist a
582
+ // lone surrogate into the envelope summary.
583
+ function truncate(text) {
584
+ const points = Array.from(text);
585
+ return points.length > SUMMARY_MAX_LENGTH ? `${points.slice(0, SUMMARY_MAX_LENGTH).join('')}…` : text;
586
+ }
587
+ function describe(error) {
588
+ return error instanceof Error ? error.message : String(error);
589
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@skanl/brambo-adapter-cli",
3
+ "version": "0.1.1",
4
+ "description": "Every shipped ExecutorAdapter that drives an out-of-process coding CLI.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "brambo",
8
+ "adapter",
9
+ "claude-code",
10
+ "codex",
11
+ "opencode"
12
+ ],
13
+ "homepage": "https://github.com/SKANL/brambo#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/SKANL/brambo/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/SKANL/brambo.git",
20
+ "directory": "packages/adapter-cli"
21
+ },
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "brambo-source": "./src/index.ts",
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "@skanl/brambo-contracts": "0.1.1",
39
+ "@skanl/brambo-kernel": "0.1.1"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.13.3",
43
+ "typescript": "~7.0.2",
44
+ "vitest": "^4.1.11"
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "scripts": {
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "vitest run",
52
+ "lint": "eslint .",
53
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
54
+ }
55
+ }