@skanl/brambo-session 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,199 @@
1
+ # @skanl/brambo-session
2
+
3
+ The brambo session: everything `brambo run` does except argv, JSON and exit codes.
4
+ Compose through a kernel, create a workspace, run a prompt under a cancellation
5
+ signal through that kernel's interception waterfall, release and dispose.
6
+
7
+ The adapter and the workspace provider are **mounted as kernel plugins**, not
8
+ constructed: `createSessionKernel` registers both, seeds the kernel's layered
9
+ configuration from brambo's own documents, and `runSession` consumes the
10
+ `executor` and `workspace` services by name.
11
+
12
+ Installing `@skanl/brambo-cli` is not required — that is the point of this package. Nor
13
+ is installing `@skanl/brambo-contracts` or `@skanl/brambo-kernel`: every type and helper the
14
+ surface needs is re-exported from here.
15
+
16
+ ```ts
17
+ import { runSession } from '@skanl/brambo-session'
18
+
19
+ const envelope = await runSession({ prompt: 'list files in this workspace' })
20
+ console.log(envelope.status, envelope.summary)
21
+ ```
22
+
23
+ The returned value is the `ResultEnvelope` `brambo run` prints. Environment
24
+ failures throw instead, carrying a `code` — `BRAMBO_CONTRACT_*` from the workspace
25
+ port, `BRAMBO_KERNEL_*` from a budget refusal:
26
+
27
+ ```ts
28
+ try {
29
+ await runSession({ prompt: 'list files' })
30
+ } catch (error) {
31
+ console.error((error as { code?: string }).code)
32
+ }
33
+ ```
34
+
35
+ ## What it writes, and what it owns
36
+
37
+ - **Disk.** Each call creates `<cwd>/.brambo/workspaces/<uuid>`, and the session
38
+ itself never removes it: `release()` ends a lease and `dispose()` leaves the
39
+ tree so work survives. Removal is a separate, named capability this package
40
+ exports — `inspectLocalWorkspaces` / `removeLocalWorkspace` and
41
+ `inspectWorktrees` / `removeWorktree`, which is what `brambo workspace remove`
42
+ calls. It is deliberately NOT on the `WorkspaceProvider` port: `dispose()` is
43
+ documented in five places as preserving state, so making it the removal would
44
+ invert a published clause.
45
+ - **The provider.** The session disposes whatever `createProvider` returns, on
46
+ every path. Return a **fresh** provider per session; a pooled one comes back
47
+ disposed and the next session fails with `BRAMBO_CONTRACT_PROVIDER_DISPOSED`.
48
+ `createProvider` is **refused** beside a supplied `kernel` — a supplied kernel
49
+ already carries a provider, and pooling one behind its shared pipeline made the
50
+ second run fail `BRAMBO_KERNEL_ACTION_INVALID` on a repeated workspace id.
51
+ - **The kernel.** A kernel `runSession` built itself is stopped on every path,
52
+ which is what runs every mounted plugin's disposer. A kernel you passed in is
53
+ yours to stop; the session never does, so several sessions can share one.
54
+ - **Interrupts.** There is no default — a library that installs
55
+ `process.on('SIGINT')` steals the signal from its host. Pass `onInterrupt` to
56
+ wire your own (`@skanl/brambo-cli` passes its SIGINT/SIGTERM registration).
57
+
58
+ ## Budgets and the record stream
59
+
60
+ The executor invocation is an action on the **kernel's** waterfall, so
61
+ declarative caps apply to it and every invocation is recorded. `log` receives the
62
+ waterfall's records; the kernel's lifecycle records (manifest validation,
63
+ activation, disposal) stay in the kernel's own stream — build the kernel yourself
64
+ to read those, as the shared-kernel example below does:
65
+
66
+ ```ts
67
+ import { createMemoryLogSink, runSession, SESSION_ACTION_ID } from '@skanl/brambo-session'
68
+
69
+ const log = createMemoryLogSink()
70
+ try {
71
+ // A refusal happens BEFORE the executor process is started.
72
+ await runSession({ prompt: 'do not spawn', log, actionPolicy: { maxInvocations: 0 } })
73
+ } catch {
74
+ // `drain()` first: an async sink still has records in flight when runSession resolves.
75
+ await log.drain()
76
+ console.log(log.records.map((record) => record.event)) // ['action.refused']
77
+ }
78
+ ```
79
+
80
+ Records are subject-scoped to the workspace, so match with
81
+ `record.subject.startsWith(SESSION_ACTION_ID + '#')`.
82
+
83
+ ## Brambo's own configuration
84
+
85
+ `runSession` reads no files. Hand it the documents instead, and they seed the
86
+ kernel's layered configuration — the ONE composed document both the executor
87
+ selection and every mounted plugin read:
88
+
89
+ ```ts
90
+ import { readExecutorConfigLayers, runSession } from '@skanl/brambo-session'
91
+
92
+ const configLayers = await readExecutorConfigLayers({ projectDir: process.cwd() })
93
+ await runSession({ prompt: 'list files', configLayers })
94
+ ```
95
+
96
+ `resolveExecutor()` still exists and still answers *which executor*, but a
97
+ selection alone carries no document: `runSession({ executorId })` seeds only
98
+ brambo's defaults and that one choice, so nothing a user wrote reaches a mounted
99
+ plugin. Pass `configLayers` whenever you want the document to configure the run.
100
+
101
+ Resolution order is `defaults → global → project → agent → invocation`. Naming a
102
+ `cwd` makes the workspace root this invocation's answer; omitting one lets a
103
+ `workspace.rootDir` in the document decide. A key brambo reads and cannot use is
104
+ reported through `onWarning` and never fails the run.
105
+
106
+ ## One kernel, many sessions — one budget
107
+
108
+ `runSession` builds a kernel per call unless you hand it one. `createSessionKernel`
109
+ builds one you own, and then the pipeline, its caps and its record stream are
110
+ shared by every session on it:
111
+
112
+ ```ts
113
+ import { createMemoryLogSink, createSessionKernel, runSession } from '@skanl/brambo-session'
114
+
115
+ const log = createMemoryLogSink()
116
+ const kernel = createSessionKernel({
117
+ log, // the WHOLE kernel stream: activation and disposal, not only the waterfall
118
+ actionPolicy: { maxTotalCost: 1.5 },
119
+ executorId: 'codex',
120
+ onWarning: (message) => console.error(message),
121
+ })
122
+
123
+ await runSession({ prompt: 'first', kernel })
124
+ // Refused: BRAMBO_KERNEL_COST_CAP_EXCEEDED, before the executor is spawned.
125
+ await runSession({ prompt: 'second', kernel }).catch(() => {})
126
+ await kernel.stop() // disposes every mounted plugin, in reverse order
127
+ ```
128
+
129
+ `createSessionKernel` is the only composition surface this package exposes, and
130
+ that is deliberate: it hands back a started kernel and no plugin factory. A
131
+ `PluginFactory` a caller can invoke with an `ActivationContext` of its own
132
+ construction yields a real vendor adapter wired to the caller's own pipeline, so
133
+ re-exporting the factories put a bypass on the surface of a package whose reason
134
+ to exist is that the executor goes through the waterfall.
135
+
136
+ A kernel you supply owns its configuration, its plugins, its pipeline, its sink
137
+ and its provider, so `configLayers`, `cwd`, `executorId`, `adapterOptions`,
138
+ `createAdapter`, `createProvider`, `onSelection`, `log` and `actionPolicy` are
139
+ **refused** beside it rather than silently ignored.
140
+
141
+ ## Sandbox and tool-composition seam
142
+
143
+ SDK hosts may supply a `sandboxProvider`, `toolExecutor`, `toolPolicy`, approval,
144
+ and tool/sandbox event callbacks when composing a session. `executeTool()` is the
145
+ explicit SDK operation that validates the invocation and context, checks the
146
+ provider's declared capability facts, requests host approval, executes through
147
+ the supplied executor, and emits the normalized result.
148
+
149
+ The boundary is explicit: bind a
150
+ caller-owned sandbox session to `createToolExecutor`, pass `local` or
151
+ `mcp-stdio` exact argv, and dispose the session at the host's lifecycle boundary.
152
+ This is not an arbitrary JavaScript-handler API, and `ToolProvider` discovery is
153
+ not execution authority. The session seam also makes no OS-isolation or concrete
154
+ remote-protocol claim.
155
+
156
+ **Read the caps honestly.** A run is ADMITTED at `SESSION_ACTION_COST` (a flat 1)
157
+ and then SETTLED against the token figure the executor itself reported, so
158
+ `maxTotalCost` and `maxInvocations` now refuse on **different runs**: one
159
+ claude-code run settles at tens of thousands of tokens, which trips a cost cap
160
+ while the invocation count is still 1, and a cheap run trips an invocation cap
161
+ with the settled cost nowhere near its own limit. The error `code` still says
162
+ which cap fired.
163
+
164
+ Two ceilings remain, and both are tracked in
165
+ `_bmad-output/implementation-artifacts/deferred-work.md`. `maxConcurrent` is
166
+ still collapsed with the other two for a session that registers one action and
167
+ awaits it — nothing a single `brambo run` does puts two operations in flight. And
168
+ the ESTIMATE is a flat 1 while the settlement is in the vendor's tokens, so a
169
+ host that budgets in tokens should pass its own `cost` through
170
+ `createExecutorPlugin`; brambo will not invent a pre-run token figure.
171
+
172
+ The settlement is also what the record stream carries: with a policy configured,
173
+ each admitted run emits an `action.estimated` and, if the vendor reported a
174
+ figure, an `action.settled`, so the total is reconstructable from the records
175
+ alone. With no policy set, the stream is exactly what it was before.
176
+
177
+ ### What the session seam means today
178
+
179
+ The session package re-exports the SDK composition seam, but its current
180
+ executor run path does not contain a tool-call route. Supplying
181
+ `sandboxProvider`, `toolExecutor`, policy, approval, or callbacks therefore
182
+ validates/configures inputs but does not itself create a sandbox or execute a
183
+ tool. A host with an actual tool-call flow should create a provider-owned
184
+ session, call `createToolExecutor(session)`, and dispose it at the host
185
+ lifecycle boundary.
186
+
187
+ `ToolExecutor` preserves exact argv. A local descriptor such as
188
+ `{ kind: 'local', argv: ['node', 'server.mjs'] }` with `['--config', 'a b']`
189
+ reaches the provider as `['node', 'server.mjs', '--config', 'a b']`; no shell
190
+ parsing or arbitrary JavaScript handler is involved. An `mcp-stdio` descriptor
191
+ uses the same argv and provider-owned framed stdio channel for local MCP.
192
+
193
+ Policies requiring unproven controls or resource limits fail closed. The
194
+ current local provider evidence is conservative; the remote provider is an
195
+ injected transport adapter, not a bundled protocol. `danger-full-access`
196
+ requires explicit acknowledgement and may emit validated start/completion
197
+ audit events, but neither audit events nor current tests prove OS isolation.
198
+ Repository/provider tests are current-platform evidence. The optional
199
+ Linux/macOS/Windows host-conformance runs have not been claimed as executed.
@@ -0,0 +1,146 @@
1
+ import { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, EXECUTOR_CONFIG_KEY, availableExecutorIds, createExecutorAdapter } from '@skanl/brambo-adapter-cli';
2
+ import type { CliExecutorAdapterOptions, ShippedExecutor } from '@skanl/brambo-adapter-cli';
3
+ import type { ConfigLayer, LayeredConfig } from '@skanl/brambo-kernel';
4
+ export { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, EXECUTOR_CONFIG_KEY, availableExecutorIds, createExecutorAdapter, type ShippedExecutor, };
5
+ export type { CliExecutorAdapterOptions };
6
+ /** Brambo's own configuration document for a scope root. */
7
+ export declare function executorConfigPath(scopeDir: string): string;
8
+ export interface ResolveExecutorOptions {
9
+ /**
10
+ * Explicit override for this invocation, e.g. `brambo run --executor codex`.
11
+ * Set as the `invocation` LAYER, so it wins over both documents and is
12
+ * reported as having done so. Omitted, no invocation layer exists at all.
13
+ */
14
+ readonly executorId?: string;
15
+ /**
16
+ * Root of the machine scope; `<homeDir>/.brambo/config.json` is the `global`
17
+ * layer. A SEAM: it defaults to the OS home directory in production and every
18
+ * test points it at a temp directory, because a suite whose result depends on
19
+ * the `~/.brambo` of whoever runs it passes and fails for reasons having
20
+ * nothing to do with the code.
21
+ */
22
+ readonly homeDir?: string;
23
+ /**
24
+ * Root of the project scope; `<projectDir>/.brambo/config.json` is the
25
+ * `project` layer. The same seam, defaulting to `process.cwd()`.
26
+ */
27
+ readonly projectDir?: string;
28
+ }
29
+ export interface ExecutorSelection {
30
+ /** The id that won; always a key of `EXECUTOR_CATALOGUE`. */
31
+ readonly executorId: string;
32
+ /**
33
+ * The layer that supplied it, taken from the layered config's OWN `dump()`.
34
+ * Never recomputed here: provenance derived a second time is how a report
35
+ * starts disagreeing with the thing it reports on.
36
+ */
37
+ readonly layer: ConfigLayer;
38
+ /**
39
+ * Every id a selection may name. Here for a host that offers a CHOICE and has
40
+ * to render one; `@skanl/brambo-cli` does not print it, because on the one path where
41
+ * a user needs the list — an id brambo has no adapter for — the coded error's
42
+ * own message already carries it.
43
+ */
44
+ readonly available: readonly string[];
45
+ }
46
+ /** One of brambo's own configuration documents, and where it came from. */
47
+ export interface ExecutorConfigDocument {
48
+ /** The path it was read from, so a layer that rejects it can name the file. */
49
+ readonly filePath: string;
50
+ readonly document: unknown;
51
+ }
52
+ /**
53
+ * Brambo's own documents, READ but not yet composed.
54
+ *
55
+ * This exists so the documents are read ONCE per run. Story M3.B made the
56
+ * kernel's layered configuration the one the mounted plugins read, and the
57
+ * kernel is constructed inside `runSession` — so a caller that resolved a
58
+ * selection first and then ran would have read `.brambo/config.json` twice, with
59
+ * a window between them in which the two could disagree. Handing the SNAPSHOTS
60
+ * forward closes that window: `seedExecutorConfig` composes them into whichever
61
+ * configuration is going to be used, and nothing re-reads a file.
62
+ */
63
+ export interface ExecutorConfigLayers {
64
+ /**
65
+ * Values composed UNDER brambo's own built-in default, so any document can
66
+ * still override them. `@skanl/brambo-session` puts its computed workspace root here
67
+ * when the caller named no `cwd`, which is what lets a user's
68
+ * `workspace.rootDir` actually decide the directory.
69
+ */
70
+ readonly defaults?: unknown;
71
+ /** `<homeDir>/.brambo/config.json`, when it exists. */
72
+ readonly global?: ExecutorConfigDocument;
73
+ /** `<projectDir>/.brambo/config.json`, when it exists and is not the machine one. */
74
+ readonly project?: ExecutorConfigDocument;
75
+ /** This invocation's explicit override, e.g. `brambo run --executor codex`. */
76
+ readonly invocation?: unknown;
77
+ }
78
+ /**
79
+ * Reads brambo's own documents into layer snapshots. The ONLY filesystem access
80
+ * in executor selection.
81
+ *
82
+ * A MISSING document is an absent layer. A document that exists and cannot be
83
+ * used is a coded error — reading a corrupt file and shrugging back to the
84
+ * default runs a different agent than the user configured, silently, which is
85
+ * the failure this selection exists to remove.
86
+ */
87
+ export declare function readExecutorConfigLayers(options?: ResolveExecutorOptions): Promise<ExecutorConfigLayers>;
88
+ /**
89
+ * Composes brambo's defaults and the given documents into ONE layered
90
+ * configuration: `defaults` -> `global` -> `project` -> `invocation`.
91
+ *
92
+ * The `setLayer` calls are WRAPPED because the kernel's validation is what
93
+ * rejects a hostile document, and it names the offending KEY rather than the
94
+ * file: a `__proto__` key in the machine document and in the project document
95
+ * produced byte-identical stderr naming neither, and an unbounded nesting depth
96
+ * (~3000 levels) produced a bare `RangeError` carrying no `code` at all — an
97
+ * UNCODED crash on the exact input class the matrix says must be refused coded.
98
+ * The kernel's own error travels on as the `cause`, so its code is preserved in
99
+ * the chain rather than swallowed.
100
+ *
101
+ * Since Story M3.B this is what seeds the KERNEL's configuration, so the mounted
102
+ * plugins and the executor selection read one composed document rather than two.
103
+ */
104
+ /**
105
+ * A key brambo READ off disk and refused to admit into its layer, with what it
106
+ * is running instead.
107
+ *
108
+ * Typed rather than logged, because AD-5 is "typed absence over silence" and its
109
+ * opposite of IGNORED is TYPED AND REPORTED, not FATAL. The guard this replaces
110
+ * read that as a binary -- "REFUSED rather than ignored, per AD-5" -- and made a
111
+ * cloned repository able to deny service to the machine owner's own selection.
112
+ * `using` is `undefined` when nothing else selects one, which is a different
113
+ * sentence and has to stay tellable apart.
114
+ */
115
+ export interface DeclinedConfigKey {
116
+ readonly key: 'method';
117
+ /** What the project document recommended, verbatim. */
118
+ readonly specifier: string;
119
+ /** The document that recommended it, so the notice can name the file. */
120
+ readonly filePath: string;
121
+ /** What decides instead, taken from the COMPOSED view rather than guessed. */
122
+ readonly using: string | undefined;
123
+ }
124
+ export declare function seedExecutorConfig(config: LayeredConfig, layers?: ExecutorConfigLayers): DeclinedConfigKey | undefined;
125
+ /**
126
+ * The selection an already-seeded configuration decides, with the layer that
127
+ * decided it. Pure: it reads the composed view and touches no file.
128
+ */
129
+ export declare function selectExecutor(config: LayeredConfig): ExecutorSelection;
130
+ /**
131
+ * Which executor this run uses, resolved through the kernel's layered
132
+ * configuration: `defaults` -> `global` -> `project` -> `invocation`.
133
+ *
134
+ * This — not `runSession` — is what reads the filesystem. A session primitive
135
+ * whose behaviour depends on files under the running user's home is not usable
136
+ * from a host that already knows what it wants, and it would make every existing
137
+ * `brambo run` test depend on the `~/.brambo` of whoever ran the suite.
138
+ *
139
+ * Ships from `@skanl/brambo-session` beside `runSession`, so FR-29 holds: a third party
140
+ * imports this package and gets the selection AND the run, with no CLI involved.
141
+ *
142
+ * `brambo run` does NOT call this: it reads the layers once and hands them to
143
+ * `runSession`, which seeds the KERNEL's configuration and selects from that one
144
+ * composed document. The three steps are exactly the three this function performs.
145
+ */
146
+ export declare function resolveExecutor(options?: ResolveExecutorOptions): Promise<ExecutorSelection>;