@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SKANL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @skanl/brambo-adapter-cli
2
+
3
+ Every shipped `ExecutorAdapter` (`@skanl/brambo-contracts`) that drives an out-of-process coding CLI.
4
+ One generic engine spawns the binary headlessly inside a workspace's root path and maps its
5
+ output to a typed `ResultEnvelope`; each executor is a trait RECORD over that engine, never a
6
+ class of its own.
7
+
8
+ ## Trait table
9
+
10
+ `ExecutorTraits` = `{executorId, command, args, promptDelivery, promptArgSeparator?, output}`.
11
+ The two real structural axes are prompt delivery and payload shape — everything else is shared
12
+ machinery. The factory validates the record and rejects the shapes that would fail silently
13
+ (empty `command`, empty `resultPath`, empty `errorStatusPrefix`, `usagePaths` that is empty or
14
+ names an empty path, `usagePaths` without the `usageWhen` that bounds it, a `metadata` key colliding with
15
+ an engine-owned `data` key) with a coded error.
16
+
17
+ | Executor | Invocation | Prompt | Payload | Result |
18
+ |---|---|---|---|---|
19
+ | `claude-code` | `claude --print --output-format stream-json --verbose --no-session-persistence --dangerously-skip-permissions` | stdin | JSONL events | `result` where `type == "result"` |
20
+ | `codex` | `codex exec --json --skip-git-repo-check` | stdin (positional omitted) | JSONL events | `item.text` where `item.type == "agent_message"` |
21
+ | `opencode` | `opencode run --format json -- <prompt>` | final argv entry | JSONL events | `part.text` where `part.type == "text"` |
22
+
23
+ Adding a fourth executor means adding a record — `test/trait-stub.test.ts` proves it by passing
24
+ the whole clause suite with a trait record the engine has never seen.
25
+
26
+ All three are reachable from `brambo run --executor <id>` and from
27
+ `.brambo/config.json`'s `executor` key (Story 2.7c). This package owns the catalogue that maps an
28
+ id to its adapter — `EXECUTOR_CATALOGUE`, `DEFAULT_EXECUTOR_ID`, `createExecutorAdapter` — because
29
+ the kernel plugin below has to perform that lookup for itself.
30
+
31
+ ### As a kernel plugin
32
+
33
+ `createExecutorPlugin()` mounts an adapter on a `@skanl/brambo-kernel` container. It reads WHICH executor
34
+ from the kernel's composed configuration (its own `executor` key, the same one `.brambo/config.json`
35
+ spells), rejects activation when that key names nothing this package ships, and provides the
36
+ `executor` service.
37
+
38
+ That service is a **runner**, not an adapter: `{ executorId, run(actionId, request) }`. The adapter
39
+ is closed over and never handed out, so every run registers an action on the KERNEL's interception
40
+ waterfall and a cap refuses before a process is spawned. What that closes is the container's
41
+ surface — anyone who imports `createClaudeCodeAdapter` from here can still drive one directly, and
42
+ `deferred-work.md` keeps that open rather than claiming otherwise.
43
+
44
+ ### Finding the result in a JSONL stream
45
+
46
+ The engine reads the stream once and keeps two records: the first one that reports a failure, and
47
+ the last one carrying a usable result. Codex's `ThreadEvent` variants and OpenCode's event types
48
+ are their own evolving vocabularies, so matching on event NAMES would break brambo on their next
49
+ release; the engine only matches the paths a trait record names, which costs nothing when the
50
+ stream ends in trailing noise.
51
+
52
+ Failure detection is deliberately non-positional. OpenCode emits recoverable `error` events and
53
+ keeps going, so "last qualifying line wins" would silently drop a reported failure whenever any
54
+ output followed it.
55
+
56
+ A result path alone is not enough to identify the answer: codex `reasoning` items and opencode
57
+ `reasoning` parts both carry a `text` field, so both records add a discriminator. Without one the
58
+ adapter would confidently return chain-of-thought as the result.
59
+
60
+ ### Working directory
61
+
62
+ All three CLIs accept a cwd flag (`-C`, `--dir`), and none of them is passed: the spawn seam
63
+ starts the child in `workspace.rootPath`. Codex additionally needs `--skip-git-repo-check`
64
+ because a brambo workspace is not necessarily a git repository.
65
+
66
+ The cwd is **not** the only mechanism, and believing it was is what M4.A found. `opencode`
67
+ resolves its file tools against `$PWD` rather than against `process.cwd()`, so with brambo's own
68
+ `PWD` inherited it wrote into the directory brambo was launched from — twice, reproducibly. The
69
+ spawner therefore hands every child a `PWD` equal to the cwd it is given. Two mechanisms, and for
70
+ opencode the second one is the load-bearing one.
71
+
72
+ ### What the workspace is, and what it is not
73
+
74
+ Measured per executor against the real binaries (`test/confinement-live.test.ts`):
75
+
76
+ | Executor | A workspace-relative write | Notes |
77
+ |---|---|---|
78
+ | `claude-code` | lands in the workspace | resolves against its cwd; ignores a lying `PWD` |
79
+ | `codex` | never happens | `codex exec` defaults to the `read-only` sandbox, so **as brambo ships it codex cannot create or edit a file at all** |
80
+ | `opencode` | lands in the workspace | resolves against `$PWD`, which brambo sets to the cwd |
81
+
82
+ **brambo makes the workspace true; it does not enforce it.** Told to write to an ABSOLUTE path
83
+ outside the workspace, `claude` did so without hesitating — brambo runs it with
84
+ `--dangerously-skip-permissions` and spawns an ordinary child with the user's own privileges, and
85
+ nothing sits between the two. (`codex` refused, but that is codex's own sandbox, not brambo's.)
86
+ OS-level sandboxing is a deliberate non-goal here.
87
+
88
+ And `HOME` is passed through untouched, because scrubbing it would break all three: per-user
89
+ executor state is therefore SHARED across concurrent sessions — `opencode` keeps a single SQLite
90
+ database under `~/.local/share/opencode/`, `claude` a per-project directory under `~/.claude/`.
91
+ Two isolated workspaces do not imply two isolated executors.
92
+
93
+ ### Argument delivery is a trust boundary
94
+
95
+ Putting the prompt in argv means the OS — and on win32 possibly a shell — parses caller-supplied
96
+ text, so that path is guarded:
97
+
98
+ - A `--` separator (trait data) keeps a prompt starting with `-` out of flag position.
99
+ - A prompt beyond the platform's conservative argv bound is refused with a coded envelope naming
100
+ the limit, rather than surfacing as an unattributable spawn error.
101
+ - A win32 `.cmd`/`.bat` command can only start by rerouting through `cmd.exe`, which interprets
102
+ `&`, `|`, `>`, `^` and `%VAR%` no matter how the argument is quoted. The run fails closed with
103
+ a coded `executorUnavailable` naming the way out (point `command` at the real executable).
104
+ Escaping for `cmd.exe` is not a winnable game and is not attempted.
105
+
106
+ ## Envelope guarantees
107
+
108
+ Identical for every adapter: coded `executorUnavailable` when the binary cannot spawn,
109
+ `executorRunFailed` on pipe/stream errors, non-zero exits, unparseable output and
110
+ executor-reported failures (even at exit code 0), and a `cancelled` envelope with a non-empty
111
+ `errors` array on abort. Completion is observed before an abort can claim cancellation, so an
112
+ abort landing after a successful exit yields the real `ok` envelope. Cancellation kills the whole
113
+ process tree (`taskkill /T /F` on win32, process-group SIGKILL on posix) and never signals a pid
114
+ that has already exited.
115
+
116
+ A structured payload survives a non-zero exit: codex and opencode exit non-zero exactly when they
117
+ have printed their error event, so the envelope is built from that payload rather than from
118
+ stderr noise. A capture that hit the stream cap is reported as a coded failure naming the
119
+ truncation — an `ok` built from the last event that happened to survive the cut would be a
120
+ confident wrong answer.
121
+
122
+ ## Child-process seam
123
+
124
+ All spawning goes through `ChildProcessSpawner` (`spawn-seam.ts`). Production uses
125
+ `createNodeChildSpawner()`; every adapter suite injects a fake, so no executor is ever really
126
+ started by a test. The spawner is also the orphan-detection surface: after cancellation, and
127
+ after a broken stdin pipe, no child may remain unsettled-and-unkilled.
128
+
129
+ Two suites do spawn real processes, and neither runs an executor: `test/overhead.test.ts` and
130
+ `test/tree-kill.test.ts` spawn `process.execPath` (part of `pnpm check`, no network, no auth).
131
+ Four suites run a real coding CLI, and each one spends account credit:
132
+ `test/live-smoke.test.ts`, `test/usage-live.test.ts`, `test/confinement-live.test.ts` and
133
+ `test/stream-mode-live.test.ts`. The authoritative roster is
134
+ `packages/contracts/test/live-suite-naming.test.ts`, which reddens when a live suite
135
+ escapes the exclusion glob — this sentence used to say "three", and the fourth was
136
+ the one the glob had already learned to catch twice.
137
+
138
+ The spawner also decides the child's ENVIRONMENT, which is otherwise inherited whole. Exactly one
139
+ variable is changed: `PWD` is set to the cwd the child is given, because it is the only inherited
140
+ variable that claims to name the working directory and a stale one redirects `opencode`'s writes.
141
+ Nothing is removed — `INIT_CWD` was the suspect M3.C named and was ruled out by measurement, and
142
+ deleting a variable measured not to matter is a scrub, not a fix.
143
+
144
+ ## Spawn-overhead instrumentation
145
+
146
+ Pass `onTiming` to receive `{ spawnSetupMs, runMs }` per run. NFR-9 budgets adapter-added overhead
147
+ at ≤150ms above raw CLI startup; the deterministic measurement lives in `test/overhead.test.ts`
148
+ (adapter vs direct spawn of the same trivial command — no network, no auth, no flake).
149
+
150
+ ## Live smoke
151
+
152
+ `test/live-smoke.test.ts` runs one tiny real task end-to-end when the `claude` binary is detected
153
+ and authenticated; otherwise it skips with an explicit reason (never silently passes).
154
+ Set `BRAMBO_LIVE_SMOKE=0` to disable it explicitly. It is env-gated by design and is never part of
155
+ what `pnpm check` guarantees.
156
+
157
+ ## Confinement
158
+
159
+ `test/confinement-live.test.ts` measures, per executor, where a file the executor was told to
160
+ create actually lands. The same rule applies: a missing or non-answering binary skips with its
161
+ reason, an authenticated-but-logged-out one skips, and `BRAMBO_LIVE_CONFINEMENT=0` disables it.
162
+ Because that means CI — where none of the three binaries exists — runs it green while measuring
163
+ nothing, its last case PRINTS which executors were measured; read that line before trusting a
164
+ green run. Its deterministic half (what environment the spawner hands a child) runs everywhere and
165
+ needs no binary.
166
+
167
+ ## Token usage
168
+
169
+ A trait record may declare where its vendor reports what a run spent:
170
+
171
+ - `usageWhen` — the records that carry usage (`{"type": "turn.completed"}` for codex,
172
+ `{"type": "step_finish"}` for opencode, `{"type": "result"}` for claude-code).
173
+ - `usagePaths` — the numeric fields inside such a record. They are summed within a record
174
+ **and across every matching record**, because a vendor that works in steps bills each step.
175
+
176
+ The total lands on `envelope.data.usage` as a NUMBER — the one non-string value there, and the
177
+ reason `usage` is an engine-owned key a `metadata` key may not collide with. Absent means the
178
+ vendor reported nothing; the figure is never faked, never estimated and never tokenized by brambo.
179
+ It fails closed as a whole: if any billed record cannot be read, the run reports no figure rather
180
+ than a sum missing a term.
181
+
182
+ Every shipped figure is verified against the real binary by `test/usage-live.test.ts`, which is
183
+ also what keeps a fourth adapter from shipping a `usagePaths` nobody ever exercised.
@@ -0,0 +1,57 @@
1
+ import { BramboError } from '@skanl/brambo-contracts';
2
+ import type { CliExecutorAdapter, CliExecutorAdapterOptions, ExecutorTraits } from './traits.ts';
3
+ /**
4
+ * One shipped adapter: its OWN trait record, and the factory that builds it.
5
+ *
6
+ * The pair is what the catalogue stores, and the trait record is what supplies
7
+ * the key. There is deliberately no `id` field here to write beside it — a
8
+ * second spelling of the name is the thing this file exists to prevent.
9
+ */
10
+ export interface ShippedExecutor {
11
+ readonly traits: ExecutorTraits;
12
+ readonly create: (options?: CliExecutorAdapterOptions) => CliExecutorAdapter;
13
+ }
14
+ /**
15
+ * Every adapter brambo ships, keyed by each adapter's own `executorId` TRAIT.
16
+ *
17
+ * Keyed from the traits, never from a list of string literals written beside
18
+ * them: Story 2.7a shipped an executor that was never once exercised because a
19
+ * parallel name list drifted from the thing it named. A name that exists here is
20
+ * a name whose trait record supplied it, so a fourth adapter appears by being
21
+ * shipped rather than by being listed twice.
22
+ *
23
+ * The key alone does not close the whole hole — a mis-paired factory would key
24
+ * codex's traits to opencode's constructor — so `packages/session/test/executors.test.ts`
25
+ * builds every entry and asserts the ADAPTER answers with the key it was found under.
26
+ *
27
+ * It lives HERE rather than in `@skanl/brambo-session` since M3.B: the executor plugin
28
+ * turns a configured id into an adapter, and a plugin whose package could not
29
+ * perform its own lookup would have to be handed a constructor by whoever
30
+ * mounted it — which is the direct construction this story exists to remove.
31
+ * `@skanl/brambo-session` re-exports the whole set, so its callers see no move.
32
+ */
33
+ export declare const EXECUTOR_CATALOGUE: ReadonlyMap<string, ShippedExecutor>;
34
+ /**
35
+ * What brambo runs when nothing selects otherwise. Taken from the trait record,
36
+ * so it is one of the catalogue's own keys by construction, and used as the
37
+ * `defaults` LAYER rather than as a constructor fallback — the difference being
38
+ * that a layer can be overridden and reported on, and a constructor cannot.
39
+ */
40
+ export declare const DEFAULT_EXECUTOR_ID: string;
41
+ /** Every id a selection may name, in catalogue order. */
42
+ export declare function availableExecutorIds(): readonly string[];
43
+ /** Brambo ships no adapter under the name that was asked for. */
44
+ export declare function unknownExecutor(executorId: string): BramboError;
45
+ /**
46
+ * The adapter for one catalogue id, or brambo's default when none is named.
47
+ *
48
+ * The default flows from `DEFAULT_EXECUTOR_ID` through the same catalogue lookup
49
+ * every other id takes, so there is no path on which a hardcoded constructor
50
+ * runs. An id the catalogue does not hold is a coded failure, never a fallback.
51
+ *
52
+ * `options` is the adapter's OWN seam — a child-process spawner, or a binary
53
+ * path that overrides the trait's command. `SessionOptions.adapterOptions`
54
+ * threads it through from `runSession` and from `brambo run`, so it is a live
55
+ * seam rather than flexibility no caller could reach.
56
+ */
57
+ export declare function createExecutorAdapter(executorId?: string, options?: CliExecutorAdapterOptions): CliExecutorAdapter;
@@ -0,0 +1,62 @@
1
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
2
+ import { CLAUDE_CODE_TRAITS, createClaudeCodeAdapter } from './executors/claude-code.js';
3
+ import { CODEX_TRAITS, createCodexAdapter } from './executors/codex.js';
4
+ import { OPENCODE_TRAITS, createOpenCodeAdapter } from './executors/opencode.js';
5
+ const SHIPPED = [
6
+ { traits: CLAUDE_CODE_TRAITS, create: createClaudeCodeAdapter },
7
+ { traits: CODEX_TRAITS, create: createCodexAdapter },
8
+ { traits: OPENCODE_TRAITS, create: createOpenCodeAdapter },
9
+ ];
10
+ /**
11
+ * Every adapter brambo ships, keyed by each adapter's own `executorId` TRAIT.
12
+ *
13
+ * Keyed from the traits, never from a list of string literals written beside
14
+ * them: Story 2.7a shipped an executor that was never once exercised because a
15
+ * parallel name list drifted from the thing it named. A name that exists here is
16
+ * a name whose trait record supplied it, so a fourth adapter appears by being
17
+ * shipped rather than by being listed twice.
18
+ *
19
+ * The key alone does not close the whole hole — a mis-paired factory would key
20
+ * codex's traits to opencode's constructor — so `packages/session/test/executors.test.ts`
21
+ * builds every entry and asserts the ADAPTER answers with the key it was found under.
22
+ *
23
+ * It lives HERE rather than in `@skanl/brambo-session` since M3.B: the executor plugin
24
+ * turns a configured id into an adapter, and a plugin whose package could not
25
+ * perform its own lookup would have to be handed a constructor by whoever
26
+ * mounted it — which is the direct construction this story exists to remove.
27
+ * `@skanl/brambo-session` re-exports the whole set, so its callers see no move.
28
+ */
29
+ export const EXECUTOR_CATALOGUE = new Map(SHIPPED.map((executor) => [executor.traits.executorId, executor]));
30
+ /**
31
+ * What brambo runs when nothing selects otherwise. Taken from the trait record,
32
+ * so it is one of the catalogue's own keys by construction, and used as the
33
+ * `defaults` LAYER rather than as a constructor fallback — the difference being
34
+ * that a layer can be overridden and reported on, and a constructor cannot.
35
+ */
36
+ export const DEFAULT_EXECUTOR_ID = CLAUDE_CODE_TRAITS.executorId;
37
+ /** Every id a selection may name, in catalogue order. */
38
+ export function availableExecutorIds() {
39
+ return [...EXECUTOR_CATALOGUE.keys()];
40
+ }
41
+ /** Brambo ships no adapter under the name that was asked for. */
42
+ export function unknownExecutor(executorId) {
43
+ return new BramboError(BRAMBO_ERROR_CODES.executorNotFound, `brambo has no adapter named '${executorId}'; available executors: ${availableExecutorIds().join(', ')}`);
44
+ }
45
+ /**
46
+ * The adapter for one catalogue id, or brambo's default when none is named.
47
+ *
48
+ * The default flows from `DEFAULT_EXECUTOR_ID` through the same catalogue lookup
49
+ * every other id takes, so there is no path on which a hardcoded constructor
50
+ * runs. An id the catalogue does not hold is a coded failure, never a fallback.
51
+ *
52
+ * `options` is the adapter's OWN seam — a child-process spawner, or a binary
53
+ * path that overrides the trait's command. `SessionOptions.adapterOptions`
54
+ * threads it through from `runSession` and from `brambo run`, so it is a live
55
+ * seam rather than flexibility no caller could reach.
56
+ */
57
+ export function createExecutorAdapter(executorId = DEFAULT_EXECUTOR_ID, options) {
58
+ const shipped = EXECUTOR_CATALOGUE.get(executorId);
59
+ if (shipped === undefined)
60
+ throw unknownExecutor(executorId);
61
+ return shipped.create(options);
62
+ }
@@ -0,0 +1,3 @@
1
+ import type { CliExecutorAdapter, CliExecutorAdapterOptions, ExecutorTraits } from '../traits.ts';
2
+ export declare const CLAUDE_CODE_TRAITS: ExecutorTraits;
3
+ export declare function createClaudeCodeAdapter(options?: CliExecutorAdapterOptions): CliExecutorAdapter;
@@ -0,0 +1,125 @@
1
+ import { createCliExecutorAdapter } from '../traits.js';
2
+ // Claude Code trait record.
3
+ //
4
+ // Headless print mode: `--print --output-format stream-json --verbose` prints
5
+ // newline-delimited EVENTS on stdout, and the prompt arrives via stdin (the
6
+ // CLI's piped-input convention). Session persistence is off so no session state
7
+ // outlives the workspace; permissions are bypassed because headless execution
8
+ // has no interactive approver.
9
+ //
10
+ // The stream, MEASURED (M15.A) on 2.1.260 by running the binary and reading its
11
+ // own stdout, exactly as the `--output-format json` mode below was:
12
+ // `--verbose` is REQUIRED. Without it, `--print --output-format stream-json`
13
+ // exits 1 printing "Error: When using --print, --output-format=stream-json
14
+ // requires --verbose" and produces no output at all. Measured by running it;
15
+ // it costs no quota, because the refusal is argument validation.
16
+ // With it: exit 0, and stderr carried NOTHING.
17
+ // The 14 events of a one-word task were, in order: four `system/hook_started`,
18
+ // `system/hook_response` x2, `system/hook_progress`, `system/hook_response` x2,
19
+ // `system/init`, `system/informational`, `assistant`, `rate_limit_event`, and
20
+ // the terminal `result/success`.
21
+ //
22
+ // WHY THE MODE CHANGED, and what did not: `--output-format json` carries NO
23
+ // quota surface. Its top-level keys were dumped in full and the list is the
24
+ // control that the search saw the object: duration_api_ms, stop_reason,
25
+ // session_id, total_cost_usd, usage, modelUsage, ... and no `rate_limit_info`.
26
+ // The stream's terminal `result/success` event carries every one of those same
27
+ // fields, so the ENVELOPE is fed from it rather than rebuilt, and it is
28
+ // identical to the one the single-object mode produced. `test/stream-mode-live.test.ts`
29
+ // proves that against the old mode by running both, rather than asserting it.
30
+ //
31
+ // `failureWhen` is what keeps that equivalence true. The single-object mode had
32
+ // exactly one record, so "the record that reports failure" was the result by
33
+ // construction; the stream's `system/*` events carry a `subtype` of their own,
34
+ // and without the discriminator the `error` prefix would be tested against
35
+ // nine records that are not the result.
36
+ //
37
+ // Quota, MEASURED in the same run (Story M15.A): the `rate_limit_event` carries
38
+ // `rate_limit_info.unifiedWindows`, a MAP of the vendor's own window names to
39
+ // `{utilization, resetsAt}` — `five_hour {0.13, 1788491400}` and
40
+ // `seven_day {0.22, 1788728400}`. `resetsAt` is a Unix epoch in SECONDS, a
41
+ // NUMBER, and `utilization` is a fraction rather than a percentage. Both are
42
+ // reported verbatim under the vendor's own names; brambo converts neither.
43
+ // It arrives WITHOUT `--include-hook-events`, which the original measurement
44
+ // happened to pass and which brambo therefore does not.
45
+ //
46
+ // Failure shape: print-mode payloads carry `is_error` plus a `subtype`
47
+ // ('success', 'error_max_turns', …). Both must surface as FAILED envelopes even
48
+ // when the process exited 0, which is exactly what the flag/status-prefix
49
+ // traits below express.
50
+ //
51
+ // Usage, VERIFIED by running `claude --print --output-format json` (2.1.246) and
52
+ // reading its own stdout: the result object carries a `usage` object with
53
+ // input_tokens, output_tokens,
54
+ // cache_creation_input_tokens, cache_read_input_tokens
55
+ // Observed on a one-word task: 2 / 4 / 42206 / 17630. The four are DISJOINT, and
56
+ // the vendor's own printout proves it rather than the name suggesting it: pricing
57
+ // each component at its own published rate reconstructs the `total_cost_usd` the
58
+ // same payload reports, to the last digit. `input_tokens` counts only the uncached
59
+ // input, so charging it alone would have priced that run at 2 tokens instead of
60
+ // 59842 — the budget figure is their sum.
61
+ //
62
+ // The one result object is the only record here, and `usageWhen` pins it to
63
+ // `type == "result"` so a future print-mode event carrying a `usage` of its own
64
+ // cannot join the sum.
65
+ //
66
+ // Confinement, MEASURED (M4.A) by running the real binary and looking at the
67
+ // filesystem, not by reading a flag: told to create a file, claude created it in
68
+ // the cwd it was spawned in while `PWD` named a directory OUTSIDE that cwd and
69
+ // `INIT_CWD` a third one. claude resolves the write against its cwd. It confines
70
+ // a workspace-relative write, which is not the same as being confined: MEASURED
71
+ // in the same story, claude asked for an ABSOLUTE path outside the workspace
72
+ // created the file there without hesitating. brambo runs it with
73
+ // `--dangerously-skip-permissions` and spawns an ordinary child with the user's
74
+ // own privileges, so there is nothing between the two. Epic 4 inherits that.
75
+ //
76
+ // `test/confinement-live.test.ts` keeps this true, and it spawns claude
77
+ // deliberately OUTSIDE brambo's spawner to do it: brambo now hands every child a
78
+ // `PWD` equal to its cwd, so a claude that started following `$PWD` tomorrow
79
+ // would still land in the workspace and a through-brambo check could never
80
+ // notice. The lie has to reach the child for the claim to be falsifiable.
81
+ //
82
+ // The same payload also reports `total_cost_usd` and a per-model `modelUsage`
83
+ // (which restates the four figures under different spellings, and is what the live
84
+ // check reads as an INDEPENDENT oracle); money is Ask-First, so neither ships.
85
+ export const CLAUDE_CODE_TRAITS = {
86
+ executorId: 'claude-code',
87
+ command: 'claude',
88
+ args: Object.freeze([
89
+ '--print',
90
+ '--output-format',
91
+ 'stream-json',
92
+ // Not optional and not cosmetic: `stream-json` under `--print` exits 1
93
+ // without it. `test/executors.test.ts` pins the pair (E7).
94
+ '--verbose',
95
+ '--no-session-persistence',
96
+ '--dangerously-skip-permissions',
97
+ ]),
98
+ promptDelivery: 'stdin',
99
+ output: {
100
+ payload: 'jsonl',
101
+ resultPath: ['result'],
102
+ resultWhen: { path: ['type'], equals: 'result' },
103
+ failureWhen: { path: ['type'], equals: 'result' },
104
+ errorFlagPath: ['is_error'],
105
+ statusPath: ['subtype'],
106
+ errorStatusPrefix: 'error',
107
+ metadata: { subtype: ['subtype'], session_id: ['session_id'] },
108
+ usageWhen: { path: ['type'], equals: 'result' },
109
+ usagePaths: [
110
+ ['usage', 'input_tokens'],
111
+ ['usage', 'output_tokens'],
112
+ ['usage', 'cache_creation_input_tokens'],
113
+ ['usage', 'cache_read_input_tokens'],
114
+ ],
115
+ usageWindows: {
116
+ when: { path: ['type'], equals: 'rate_limit_event' },
117
+ path: ['rate_limit_info', 'unifiedWindows'],
118
+ utilizationKey: 'utilization',
119
+ resetsAtKey: 'resetsAt',
120
+ },
121
+ },
122
+ };
123
+ export function createClaudeCodeAdapter(options = {}) {
124
+ return createCliExecutorAdapter(CLAUDE_CODE_TRAITS, options);
125
+ }
@@ -0,0 +1,3 @@
1
+ import type { CliExecutorAdapter, CliExecutorAdapterOptions, ExecutorTraits } from '../traits.ts';
2
+ export declare const CODEX_TRAITS: ExecutorTraits;
3
+ export declare function createCodexAdapter(options?: CliExecutorAdapterOptions): CliExecutorAdapter;
@@ -0,0 +1,89 @@
1
+ import { createCliExecutorAdapter } from '../traits.js';
2
+ // Codex trait record.
3
+ //
4
+ // `codex exec [OPTIONS] [PROMPT]` reads the instructions from STDIN when the
5
+ // positional PROMPT is omitted, and `--json` prints its ThreadEvent stream to
6
+ // stdout as JSONL.
7
+ //
8
+ // Result location, verified against codex-rs/exec/src/exec_events.rs: items
9
+ // arrive as `{"type":"item.completed","item":{…}}`, where `ThreadItemDetails`
10
+ // is serialized `#[serde(tag = "type", rename_all = "snake_case")]`. The answer
11
+ // is `AgentMessageItem { text }` at `item.type == "agent_message"`. The
12
+ // discriminator is NOT optional here: `ReasoningItem` also has a `text` field,
13
+ // so without it the scan would happily return the model's chain-of-thought as
14
+ // the result.
15
+ //
16
+ // `--skip-git-repo-check` is required because a brambo workspace is not
17
+ // necessarily a git repository, and codex exec otherwise refuses to run. No cwd
18
+ // flag: the spawn seam already starts the child in `workspace.rootPath`.
19
+ //
20
+ // Failure shape: fatal problems arrive as `{"type":"error","message":"…"}`, so
21
+ // the status prefix rule matches on `type` and the detail comes from `message`.
22
+ //
23
+ // Usage, VERIFIED by running `codex exec --json --skip-git-repo-check`
24
+ // (codex-cli 0.149.1) and reading its own stdout: usage is NOT on the answer
25
+ // item. It arrives on a later event of its own,
26
+ // {"type":"turn.completed","usage":{input_tokens, cached_input_tokens,
27
+ // cache_write_input_tokens, output_tokens, reasoning_output_tokens}}
28
+ // Observed on a one-word task: 28451 / 6912 / 0 / 61 / 54. Only `input_tokens`
29
+ // and `output_tokens` are summed: `cached_input_tokens` is a BREAKDOWN of the
30
+ // input already counted (6912 of the 28451), and `reasoning_output_tokens` is
31
+ // likewise a share of the output. Codex says so itself — its session rollout
32
+ // records `total_tokens` as input + output with the cached figure EXCLUDED
33
+ // (27189 = 27184 + 5 on a measured run); summing it would have billed 53557 for
34
+ // a 28512-token turn.
35
+ //
36
+ // Confinement, MEASURED (M4.A) by running the real binary and looking at the
37
+ // filesystem. The verdict has to be stated about the argv ABOVE, not about codex
38
+ // in general, and they differ:
39
+ //
40
+ // AS BRAMBO SHIPS IT, codex writes nothing at all. `codex exec` defaults to the
41
+ // `read-only` sandbox: asked to create a file it completes its turn and reports
42
+ // that write access is denied, leaving the workspace empty. So brambo's workspace
43
+ // boundary is never tested by codex, and `brambo run --executor codex` is a
44
+ // coding agent that cannot edit code. That is the fact a user meets, so that is
45
+ // the fact `test/confinement-live.test.ts` guards — with these exact args, and
46
+ // it goes red the day codex ships a writable default.
47
+ //
48
+ // MEASURED ONCE with `-s workspace-write`, which brambo does not pass: codex
49
+ // wrote through `apply_patch` to an ABSOLUTE path resolved from its own cwd,
50
+ // ignoring a `PWD` aimed elsewhere. Recorded, NOT guarded — a standing check on
51
+ // argv brambo never sends would be testing a configuration nobody ships.
52
+ // ponytail: if brambo ever ships codex writable, that measurement becomes a
53
+ // claim and needs its own case.
54
+ //
55
+ // Also measured under `-s workspace-write`: told to write to an ABSOLUTE path
56
+ // outside the workspace, codex REFUSED — "I can't write outside the permitted
57
+ // workspace". That is codex's own sandbox, not brambo's; claude, which brambo runs
58
+ // with `--dangerously-skip-permissions`, wrote the same file without hesitating.
59
+ // So of the three, codex is the only one that enforces anything, and it does so
60
+ // by not writing at all in the mode brambo ships.
61
+ //
62
+ // Loosening the shipped sandbox is a vendor-configuration decision, deliberately
63
+ // not taken here.
64
+ //
65
+ // `usageWhen` pins the sum to `turn.completed`. `codex exec` runs one turn, so
66
+ // today that is one record; summing across them is the right answer if a future
67
+ // stream reports more, because each carries its own turn's spend.
68
+ export const CODEX_TRAITS = {
69
+ executorId: 'codex',
70
+ command: 'codex',
71
+ args: Object.freeze(['exec', '--json', '--skip-git-repo-check']),
72
+ promptDelivery: 'stdin',
73
+ output: {
74
+ payload: 'jsonl',
75
+ resultPath: ['item', 'text'],
76
+ resultWhen: { path: ['item', 'type'], equals: 'agent_message' },
77
+ statusPath: ['type'],
78
+ errorStatusPrefix: 'error',
79
+ errorMessagePath: ['message'],
80
+ usageWhen: { path: ['type'], equals: 'turn.completed' },
81
+ usagePaths: [
82
+ ['usage', 'input_tokens'],
83
+ ['usage', 'output_tokens'],
84
+ ],
85
+ },
86
+ };
87
+ export function createCodexAdapter(options = {}) {
88
+ return createCliExecutorAdapter(CODEX_TRAITS, options);
89
+ }
@@ -0,0 +1,3 @@
1
+ import type { CliExecutorAdapter, CliExecutorAdapterOptions, ExecutorTraits } from '../traits.ts';
2
+ export declare const OPENCODE_TRAITS: ExecutorTraits;
3
+ export declare function createOpenCodeAdapter(options?: CliExecutorAdapterOptions): CliExecutorAdapter;
@@ -0,0 +1,74 @@
1
+ import { createCliExecutorAdapter } from '../traits.js';
2
+ // OpenCode trait record.
3
+ //
4
+ // `opencode run [message..]` takes the prompt as a POSITIONAL argument — there
5
+ // is no stdin path — and `--format json` streams raw JSON events, one per line.
6
+ // `--` separates the prompt from the flags so a prompt starting with `-` is not
7
+ // parsed as one; `run` joins everything after it into the message.
8
+ //
9
+ // Result location: every event shares the envelope `{type, timestamp,
10
+ // sessionID, …}`, and assistant output arrives as
11
+ // `{"type":"text","part":{"type":"text","text":"…"}}`. The `part.type` guard
12
+ // matters because `reasoning` events (emitted with `--thinking`) carry a `part`
13
+ // with a `text` field too. The `step_finish` event that follows carries no
14
+ // text, so the scan steps over it either way.
15
+ //
16
+ // Failure shape: `{"type":"error","error":{…}}` — the detail is an OBJECT, which
17
+ // the engine stringifies rather than dropping. OpenCode also emits RECOVERABLE
18
+ // error events and keeps going, which is why failure detection is non-positional.
19
+ //
20
+ // Usage, VERIFIED by running `opencode run --format json` (1.18.23) and reading
21
+ // its own stdout: the `step_finish` event carries
22
+ // part.tokens = { total, input, output, reasoning, cache: { write, read } }
23
+ // and `total` is opencode's OWN sum of its own components — on a one-word task,
24
+ // total 42599 = input 34390 + output 17 + reasoning 0 + cache.write 0 +
25
+ // cache.read 8192.
26
+ //
27
+ // `step_finish` is emitted PER STEP and each `total` is that step's own spend,
28
+ // not a running one. Measured on a three-step task: 42770, 42875 and 43025, each
29
+ // equal to its own components, for a run that really cost 128670. Taking the last
30
+ // record billed 43025, and a run whose final step is a one-line answer bills
31
+ // almost nothing — so the engine sums every `step_finish`, and `usageWhen` is
32
+ // what bounds which records join that sum.
33
+ //
34
+ // The event also carries `cost`, which is money and Ask-First.
35
+ //
36
+ // Confinement, MEASURED (M4.A) by running the real binary and looking at the
37
+ // filesystem. opencode does NOT bind its file tools to its working directory: it
38
+ // resolves them against `$PWD`. Told to create a file "in the current working
39
+ // directory" while `PWD` named a decoy outside its cwd, its own `write` tool
40
+ // call carried the decoy's absolute path and the file landed there — twice —
41
+ // which is the escape the M3.C ledger recorded from `packages/adapter-cli`.
42
+ // `node-child-spawner.ts` now hands every child a `PWD` equal to its cwd, and
43
+ // the same measurement then shows opencode confined, twice. So opencode confines
44
+ // BECAUSE brambo tells it the truth about where it is, not on its own account,
45
+ // and `test/confinement-live.test.ts` is what keeps that true: deleting the
46
+ // correction turns it red with opencode's own tool call as the evidence.
47
+ //
48
+ // Also measured, and NOT fixed: opencode keeps ONE SQLite database per USER
49
+ // (`~/.local/share/opencode/opencode.db` — 522 MB on the machine this was
50
+ // measured on), so two concurrent brambo sessions in two workspaces share it. Their FILES stay apart; their executor state does
51
+ // not. `HOME` is deliberately passed through untouched — scrubbing it would
52
+ // break all three executors — so this is a limit Epic 4 inherits rather than a
53
+ // defect this story can close.
54
+ export const OPENCODE_TRAITS = {
55
+ executorId: 'opencode',
56
+ command: 'opencode',
57
+ args: Object.freeze(['run', '--format', 'json']),
58
+ promptDelivery: 'argument',
59
+ promptArgSeparator: '--',
60
+ output: {
61
+ payload: 'jsonl',
62
+ resultPath: ['part', 'text'],
63
+ resultWhen: { path: ['part', 'type'], equals: 'text' },
64
+ statusPath: ['type'],
65
+ errorStatusPrefix: 'error',
66
+ errorMessagePath: ['error'],
67
+ metadata: { sessionID: ['sessionID'] },
68
+ usageWhen: { path: ['type'], equals: 'step_finish' },
69
+ usagePaths: [['part', 'tokens', 'total']],
70
+ },
71
+ };
72
+ export function createOpenCodeAdapter(options = {}) {
73
+ return createCliExecutorAdapter(OPENCODE_TRAITS, options);
74
+ }
@@ -0,0 +1,9 @@
1
+ export { createCliExecutorAdapter } from './traits.ts';
2
+ export type { AdapterTiming, CliExecutorAdapter, CliExecutorAdapterOptions, ExecutorOutputTraits, ExecutorTraits, PathMatch, PayloadShape, PromptDelivery, UsageWindowTraits, } from './traits.ts';
3
+ export { CLAUDE_CODE_TRAITS, createClaudeCodeAdapter } from './executors/claude-code.ts';
4
+ export { CODEX_TRAITS, createCodexAdapter } from './executors/codex.ts';
5
+ export { OPENCODE_TRAITS, createOpenCodeAdapter } from './executors/opencode.ts';
6
+ export { createNodeChildSpawner, routesThroughCmdShim } from './node-child-spawner.ts';
7
+ export type { ChildProcessSpawner, SpawnedChild, SpawnOptions, SpawnOutcome, } from './spawn-seam.ts';
8
+ export { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, availableExecutorIds, createExecutorAdapter, unknownExecutor, type ShippedExecutor, } from './catalogue.ts';
9
+ export { DEFAULT_EXECUTOR_ACTION_COST, EXECUTOR_CONFIG_KEY, EXECUTOR_PLUGIN_ID, EXECUTOR_SERVICE, createExecutorPlugin, type ExecutorPlugin, type ExecutorPluginOptions, type ExecutorService, } from './plugin.ts';