@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 +21 -0
- package/README.md +183 -0
- package/dist/catalogue.d.ts +57 -0
- package/dist/catalogue.js +62 -0
- package/dist/executors/claude-code.d.ts +3 -0
- package/dist/executors/claude-code.js +125 -0
- package/dist/executors/codex.d.ts +3 -0
- package/dist/executors/codex.js +89 -0
- package/dist/executors/opencode.d.ts +3 -0
- package/dist/executors/opencode.js +74 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/node-child-spawner.d.ts +13 -0
- package/dist/node-child-spawner.js +287 -0
- package/dist/plugin.d.ts +88 -0
- package/dist/plugin.js +181 -0
- package/dist/spawn-seam.d.ts +38 -0
- package/dist/spawn-seam.js +5 -0
- package/dist/traits.d.ts +179 -0
- package/dist/traits.js +589 -0
- package/package.json +55 -0
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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|