@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.
@@ -0,0 +1,159 @@
1
+ import { BRAMBO_ERROR_CODES, BramboError, validateSandboxExecutionRequest, validateToolExecutionContext, validateToolInvocationForExecution, validateToolResult } from '@skanl/brambo-contracts';
2
+ const MCP_PROTOCOL_VERSION = '2024-11-05';
3
+ function invalidResponse(message) {
4
+ return new BramboError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, `invalid MCP response: ${message}`);
5
+ }
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function isNonEmptyString(value) {
10
+ return typeof value === 'string' && value.length > 0;
11
+ }
12
+ function validateError(value) {
13
+ if (!isRecord(value) || !Number.isInteger(value['code']) || !isNonEmptyString(value['message'])) {
14
+ throw invalidResponse('error must contain an integer code and non-empty message');
15
+ }
16
+ }
17
+ function parseResponse(frame, expectedId) {
18
+ let value;
19
+ try {
20
+ value = JSON.parse(frame);
21
+ }
22
+ catch (error) {
23
+ throw invalidResponse(`frame is not JSON (${error instanceof Error ? error.message : 'parse failure'})`);
24
+ }
25
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
26
+ throw invalidResponse('response must be an object');
27
+ const response = value;
28
+ if (response['jsonrpc'] !== '2.0')
29
+ throw invalidResponse("response 'jsonrpc' must be '2.0'");
30
+ if (response['id'] !== expectedId)
31
+ throw invalidResponse(`response id does not match request ${expectedId}`);
32
+ const hasResult = Object.hasOwn(response, 'result');
33
+ const hasError = Object.hasOwn(response, 'error');
34
+ if (hasResult === hasError)
35
+ throw invalidResponse('response must contain exactly one of result or error');
36
+ if (hasError)
37
+ validateError(response['error']);
38
+ return response;
39
+ }
40
+ function validateInitializeResult(value) {
41
+ if (!isRecord(value))
42
+ throw invalidResponse("initialize result must be an object");
43
+ const serverInfo = value['serverInfo'];
44
+ if (!isNonEmptyString(value['protocolVersion']))
45
+ throw invalidResponse("initialize result 'protocolVersion' must be a non-empty string");
46
+ if (!isRecord(value['capabilities']))
47
+ throw invalidResponse("initialize result 'capabilities' must be an object");
48
+ if (!isRecord(serverInfo) || !isNonEmptyString(serverInfo['name']) || !isNonEmptyString(serverInfo['version'])) {
49
+ throw invalidResponse("initialize result 'serverInfo' must contain non-empty name and version strings");
50
+ }
51
+ if (value['instructions'] !== undefined && typeof value['instructions'] !== 'string') {
52
+ throw invalidResponse("initialize result 'instructions' must be a string when present");
53
+ }
54
+ }
55
+ function validateToolCallResult(value) {
56
+ if (!isRecord(value) || !Array.isArray(value['content']))
57
+ throw invalidResponse("tools/call result 'content' must be an array");
58
+ for (const [index, content] of value['content'].entries()) {
59
+ if (!isRecord(content) || !isNonEmptyString(content['type'])) {
60
+ throw invalidResponse(`tools/call result content[${index}] must contain a non-empty type string`);
61
+ }
62
+ if (content['type'] === 'text' && typeof content['text'] !== 'string') {
63
+ throw invalidResponse(`tools/call result content[${index}] text must be a string`);
64
+ }
65
+ }
66
+ if (value['isError'] !== undefined && typeof value['isError'] !== 'boolean') {
67
+ throw invalidResponse("tools/call result 'isError' must be a boolean when present");
68
+ }
69
+ if (value['structuredContent'] !== undefined && !isRecord(value['structuredContent'])) {
70
+ throw invalidResponse("tools/call result 'structuredContent' must be an object when present");
71
+ }
72
+ }
73
+ function validateResult(method, value) {
74
+ if (method === 'initialize') {
75
+ validateInitializeResult(value);
76
+ return;
77
+ }
78
+ if (method === 'tools/call')
79
+ validateToolCallResult(value);
80
+ }
81
+ async function request(stdio, id, method, params, signal) {
82
+ await stdio.sendFrame(JSON.stringify({ jsonrpc: '2.0', id, method, params }), signal);
83
+ const response = parseResponse(await stdio.receiveFrame(signal), id);
84
+ if (Object.hasOwn(response, 'error')) {
85
+ throw new BramboError(BRAMBO_ERROR_CODES.executorRunFailed, `MCP request '${method}' failed`);
86
+ }
87
+ validateResult(method, response['result']);
88
+ return response;
89
+ }
90
+ function mcpResult(session, value) {
91
+ return validateToolResult({
92
+ status: 'ok',
93
+ stdout: JSON.stringify(value),
94
+ stderr: '',
95
+ exitCode: 0,
96
+ enforcement: session.capabilities ?? {
97
+ version: 1,
98
+ providerId: session.providerId,
99
+ enforcement: 'simulated',
100
+ controls: { filesystem: 'none', network: 'none', process: 'none', resources: 'none' },
101
+ },
102
+ });
103
+ }
104
+ async function executeMcp(session, invocation, context) {
105
+ const requestContext = validateToolExecutionContext(context);
106
+ const sandboxRequest = validateSandboxExecutionRequest({ ...requestContext, argv: [...invocation.tool.argv] });
107
+ if (typeof session.openStdio !== 'function') {
108
+ throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, `sandbox session '${session.id}' does not expose stdio`);
109
+ }
110
+ const stdio = await session.openStdio(sandboxRequest);
111
+ try {
112
+ await requestMcp(stdio, sandboxRequest.signal);
113
+ const response = await request(stdio, 2, 'tools/call', { name: invocation.tool.name, arguments: invocation.arguments }, sandboxRequest.signal);
114
+ return mcpResult(session, response['result']);
115
+ }
116
+ finally {
117
+ await stdio.close();
118
+ }
119
+ }
120
+ async function requestMcp(stdio, signal) {
121
+ await request(stdio, 1, 'initialize', {
122
+ protocolVersion: MCP_PROTOCOL_VERSION,
123
+ capabilities: {},
124
+ clientInfo: { name: 'brambo', version: '0.1.0' },
125
+ }, signal);
126
+ await stdio.sendFrame(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }), signal);
127
+ }
128
+ /**
129
+ * Binds a caller-owned sandbox session to tool execution. Discovery stays
130
+ * elsewhere: accepting a ToolProvider here would turn listing into authority.
131
+ * This executor never owns or disposes the supplied session.
132
+ */
133
+ async function executeRemoteMcp(client, invocation, context, session) {
134
+ if (client === undefined)
135
+ throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'remote MCP execution requires an injected client');
136
+ const executionContext = validateToolExecutionContext(context);
137
+ const response = await client.request(invocation.tool.url, 'tools/call', { name: invocation.tool.name, arguments: invocation.arguments }, executionContext.signal);
138
+ return mcpResult(session, response.result);
139
+ }
140
+ export function createToolExecutor(session, remoteMcpClient) {
141
+ return Object.freeze({
142
+ async execute(invocation, context) {
143
+ // Both validations finish before the first await and therefore before the
144
+ // sandbox provider can create or signal a process.
145
+ const executionContext = validateToolExecutionContext(context);
146
+ const tool = validateToolInvocationForExecution(invocation, executionContext);
147
+ if (tool.tool.kind === 'mcp-stdio')
148
+ return executeMcp(session, tool, context);
149
+ if (tool.tool.kind === 'mcp-streamable-http')
150
+ return executeRemoteMcp(remoteMcpClient, tool, context, session);
151
+ const localInvocation = tool;
152
+ const request = validateSandboxExecutionRequest({
153
+ ...executionContext,
154
+ argv: [...localInvocation.tool.argv, ...localInvocation.arguments],
155
+ });
156
+ return validateToolResult(await session.execute(request));
157
+ },
158
+ });
159
+ }
@@ -0,0 +1,30 @@
1
+ import type { UsageReport } from '@skanl/brambo-contracts';
2
+ /** Where the observations live: brambo's own directory, one document. */
3
+ export declare function usageObservationsPath(homeDir?: string): string;
4
+ export interface UsageStoreOptions {
5
+ /** Root of the machine scope; `<homeDir>/.brambo` is brambo's own directory. */
6
+ readonly homeDir?: string;
7
+ }
8
+ /**
9
+ * Writes down what one run observed, replacing that executor's previous reading.
10
+ *
11
+ * One reading per executor and no history: the question `brambo status` answers is
12
+ * "how much is left", which only the NEWEST reading answers. A log of past
13
+ * utilisations is a different feature, and nothing reads it.
14
+ *
15
+ * ponytail: read-modify-write, not atomic. Two `brambo run` invocations finishing
16
+ * in the same instant can lose one of the two observations, which costs a stale
17
+ * row until the next run. Upgrade path: write to a sibling temp file and rename,
18
+ * the way `@skanl/brambo-projection` writes ledgers, if concurrent runs become normal.
19
+ */
20
+ export declare function recordUsageObservation(report: UsageReport, options?: UsageStoreOptions): Promise<void>;
21
+ /**
22
+ * One report per executor brambo ships, in catalogue order. Reads only; it
23
+ * invokes nothing and writes nothing (D6/D7).
24
+ *
25
+ * Three answers, and every one of them is TYPED (AD-5). There is deliberately no
26
+ * fourth answer in which a row is blank or reads `0`: a zero for an executor
27
+ * brambo cannot measure is worse than no row, because it looks like a measurement
28
+ * that was taken.
29
+ */
30
+ export declare function readUsageReports(options?: UsageStoreOptions): Promise<readonly UsageReport[]>;
package/dist/usage.js ADDED
@@ -0,0 +1,110 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { EXECUTOR_CATALOGUE } from '@skanl/brambo-adapter-cli';
5
+ import { USAGE_ABSENCE_REASONS, isUsageReport, usageAbsence } from '@skanl/brambo-contracts';
6
+ // The recorded side of Story M15.A's D7.
7
+ //
8
+ // A quota reading arrives DURING a real invocation, so a report that took one
9
+ // would cost the user the very thing it reports on — and would be unrunnable on
10
+ // exactly the day they most want it. So the run that already paid for the number
11
+ // writes it down, and the report reads what was written. Writing it costs
12
+ // nothing more, and the report answers instantly and offline.
13
+ //
14
+ // What is stored is an OBSERVATION, not a measurement brambo owns: the vendor's
15
+ // own window names, the vendor's own numbers, plus the instant brambo read them.
16
+ // A utilisation is only true as of its reading.
17
+ const STORE_VERSION = 1;
18
+ /** Where the observations live: brambo's own directory, one document. */
19
+ export function usageObservationsPath(homeDir = homedir()) {
20
+ return join(homeDir, '.brambo', 'usage-observations.json');
21
+ }
22
+ /**
23
+ * What the document holds, keyed by executor id, or an empty map.
24
+ *
25
+ * Unreadable, unparseable, or stamped with a version this build does not speak
26
+ * all mean the same thing here and it is not a failure: brambo has no observation
27
+ * to report, which `readUsageReports` already states as typed absence. This is a
28
+ * CACHE of readings brambo can take again by running; refusing to answer because
29
+ * of it would be the report failing over its own bookkeeping.
30
+ */
31
+ async function readStored(path) {
32
+ let text;
33
+ try {
34
+ text = await readFile(path, 'utf8');
35
+ }
36
+ catch {
37
+ return {};
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(text);
42
+ }
43
+ catch {
44
+ return {};
45
+ }
46
+ const document = parsed;
47
+ if (document === null || typeof document !== 'object' || document.version !== STORE_VERSION)
48
+ return {};
49
+ const reports = document.reports;
50
+ if (reports === null || typeof reports !== 'object')
51
+ return {};
52
+ const kept = {};
53
+ for (const [executorId, report] of Object.entries(reports)) {
54
+ // Per ENTRY, not per document: one record brambo can no longer understand
55
+ // must not throw away the others beside it.
56
+ if (isUsageReport(report) && report.executorId === executorId)
57
+ kept[executorId] = report;
58
+ }
59
+ return kept;
60
+ }
61
+ /**
62
+ * Writes down what one run observed, replacing that executor's previous reading.
63
+ *
64
+ * One reading per executor and no history: the question `brambo status` answers is
65
+ * "how much is left", which only the NEWEST reading answers. A log of past
66
+ * utilisations is a different feature, and nothing reads it.
67
+ *
68
+ * ponytail: read-modify-write, not atomic. Two `brambo run` invocations finishing
69
+ * in the same instant can lose one of the two observations, which costs a stale
70
+ * row until the next run. Upgrade path: write to a sibling temp file and rename,
71
+ * the way `@skanl/brambo-projection` writes ledgers, if concurrent runs become normal.
72
+ */
73
+ export async function recordUsageObservation(report, options = {}) {
74
+ const path = usageObservationsPath(options.homeDir);
75
+ const reports = { ...(await readStored(path)), [report.executorId]: report };
76
+ await mkdir(dirname(path), { recursive: true });
77
+ await writeFile(path, `${JSON.stringify({ version: STORE_VERSION, reports }, null, 2)}\n`, 'utf8');
78
+ }
79
+ /**
80
+ * One report per executor brambo ships, in catalogue order. Reads only; it
81
+ * invokes nothing and writes nothing (D6/D7).
82
+ *
83
+ * Three answers, and every one of them is TYPED (AD-5). There is deliberately no
84
+ * fourth answer in which a row is blank or reads `0`: a zero for an executor
85
+ * brambo cannot measure is worse than no row, because it looks like a measurement
86
+ * that was taken.
87
+ */
88
+ export async function readUsageReports(options = {}) {
89
+ const stored = await readStored(usageObservationsPath(options.homeDir));
90
+ return [...EXECUTOR_CATALOGUE.entries()].map(([executorId, shipped]) => {
91
+ // Derived from the trait record rather than from a list of executor names
92
+ // written beside it — the parallel-name-list defect this repo has already
93
+ // shipped once. An executor gains a quota row by declaring a surface.
94
+ if (shipped.traits.output.usageWindows === undefined) {
95
+ return usageAbsence(executorId, USAGE_ABSENCE_REASONS.noUsageSurface, `executor '${executorId}' publishes no usage surface in its output, so brambo has no reading to report for it`);
96
+ }
97
+ return (stored[executorId] ??
98
+ usageAbsence(executorId, USAGE_ABSENCE_REASONS.notObserved,
99
+ // Deliberately does NOT open with brambo's own name. The sentence is a
100
+ // template literal, and packages/cli/test/printed-commands.test.ts reads
101
+ // an opening backtick followed by that name as a COMMAND — so a sentence
102
+ // beginning with it is scanned as a verb that does not exist. Measured:
103
+ // the earlier wording turned this into a fabricated two-word command and
104
+ // reddened that invariant.
105
+ //
106
+ // The command this DOES name is backticked on purpose, so the same
107
+ // invariant dispatches it and E4's exit cannot rot into prose.
108
+ `no usage reading has been recorded for '${executorId}' yet; \`brambo run "<prompt>" --executor ${executorId}\` records one`));
109
+ });
110
+ }
@@ -0,0 +1,63 @@
1
+ import type { ConfigLayer, LayeredConfig } from '@skanl/brambo-kernel';
2
+ import type { WorkspacePlugin } from '@skanl/brambo-workspace-local';
3
+ /** The key inside the `workspace` subtree that names the provider. */
4
+ export declare const WORKSPACE_PROVIDER_CONFIG_KEY = "provider";
5
+ /**
6
+ * Where a project's workspaces live: the `workspace.rootDir` `runSession` seeds,
7
+ * as ONE function rather than two `join` calls.
8
+ *
9
+ * It exists because a second caller arrived. `brambo workspace remove` has to
10
+ * find the ledger and the trees a run created, and a CLI that spelled
11
+ * `.brambo/workspaces` for itself would be a second answer to where brambo's
12
+ * worktrees are — right until a run wrote them somewhere else. The session
13
+ * decides this path; everyone else asks.
14
+ */
15
+ export declare function worktreeStateDir(projectRoot: string): string;
16
+ /** What brambo mounts when nothing selects otherwise. */
17
+ export declare const DEFAULT_WORKSPACE_PROVIDER_ID = "local";
18
+ /** The `git worktree`-backed provider (`@skanl/brambo-workspace-git-worktree`). */
19
+ export declare const GIT_WORKTREE_PROVIDER_ID = "git-worktree";
20
+ /**
21
+ * What the mount needs that no configuration document supplies.
22
+ *
23
+ * `repoPath` is the repository worktrees are cut from. It is a MOUNT input
24
+ * rather than a config key because it is not a choice a user makes in a
25
+ * document — it is the project the host is already running in, the same `cwd`
26
+ * `workspace.rootDir` is computed from. See the plugin's own note.
27
+ */
28
+ export interface WorkspaceMountContext {
29
+ readonly repoPath: string;
30
+ }
31
+ /** Every provider id a selection may name, in catalogue order. */
32
+ export declare function availableWorkspaceProviderIds(): readonly string[];
33
+ export interface WorkspaceProviderSelection {
34
+ /** The id that won; always a key of the catalogue. */
35
+ readonly providerId: string;
36
+ /**
37
+ * The layer that supplied it, taken from the layered config's OWN `dump()`.
38
+ * Never recomputed: provenance derived a second time is how a report starts
39
+ * disagreeing with the thing it reports on.
40
+ */
41
+ readonly layer: ConfigLayer;
42
+ /** Every id a selection may name, for a host that has to render a choice. */
43
+ readonly available: readonly string[];
44
+ }
45
+ /**
46
+ * The workspace provider an already-seeded configuration decides, with the layer
47
+ * that decided it. Pure: it reads the composed view and touches no file.
48
+ *
49
+ * Reading the filesystem here is the thing this must not do. `executors.ts`
50
+ * states the rule for its twin: a session primitive whose behaviour depends on
51
+ * files under the running user's home is not usable from a host that already
52
+ * knows what it wants. The caller seeds layers; this reads the composed view.
53
+ */
54
+ export declare function selectWorkspaceProvider(config: LayeredConfig): WorkspaceProviderSelection;
55
+ /**
56
+ * The plugin for one catalogue id.
57
+ *
58
+ * Separate from the selection so the selection stays a pure value a host can
59
+ * report on without constructing anything — and so there is exactly one place a
60
+ * provider id turns into a plugin. An id the catalogue does not hold is a coded
61
+ * failure, never a fallback.
62
+ */
63
+ export declare function createSelectedWorkspacePlugin(providerId: string, context: WorkspaceMountContext): WorkspacePlugin;
@@ -0,0 +1,126 @@
1
+ import { join } from 'node:path';
2
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
3
+ import { createGitWorktreeWorkspacePlugin } from '@skanl/brambo-workspace-git-worktree';
4
+ import { WORKSPACE_CONFIG_KEY, createWorkspacePlugin } from '@skanl/brambo-workspace-local';
5
+ // Workspace provider SELECTION: which shipped `WorkspaceProvider` this run
6
+ // mounts, decided through the layered configuration brambo already owns.
7
+ //
8
+ // It is the executor selection's twin on purpose (`executors.ts`), down to
9
+ // taking the value and its layer from ONE `dump()` entry. What differs is only
10
+ // the key path: `workspace` is ALREADY an object-namespaced subtree — the
11
+ // session seeds `workspace.rootDir` into it as a layer — so the selection
12
+ // belongs at `workspace.provider` rather than at a second root key that would
13
+ // split one plugin's configuration across two places.
14
+ //
15
+ // Two plugins providing the service `workspace` would be
16
+ // `BRAMBO_KERNEL_SERVICE_CONFLICT`, so this does not compose providers: it
17
+ // CHOOSES one, and the chosen one is the only one registered.
18
+ /** The key inside the `workspace` subtree that names the provider. */
19
+ export const WORKSPACE_PROVIDER_CONFIG_KEY = 'provider';
20
+ /**
21
+ * Where a project's workspaces live: the `workspace.rootDir` `runSession` seeds,
22
+ * as ONE function rather than two `join` calls.
23
+ *
24
+ * It exists because a second caller arrived. `brambo workspace remove` has to
25
+ * find the ledger and the trees a run created, and a CLI that spelled
26
+ * `.brambo/workspaces` for itself would be a second answer to where brambo's
27
+ * worktrees are — right until a run wrote them somewhere else. The session
28
+ * decides this path; everyone else asks.
29
+ */
30
+ export function worktreeStateDir(projectRoot) {
31
+ return join(projectRoot, '.brambo', 'workspaces');
32
+ }
33
+ /** What brambo mounts when nothing selects otherwise. */
34
+ export const DEFAULT_WORKSPACE_PROVIDER_ID = 'local';
35
+ /** The `git worktree`-backed provider (`@skanl/brambo-workspace-git-worktree`). */
36
+ export const GIT_WORKTREE_PROVIDER_ID = 'git-worktree';
37
+ /**
38
+ * Every workspace provider brambo ships, keyed by the id a document may name.
39
+ *
40
+ * A MAP from id to plugin factory, not a set of ids beside a `switch`. That
41
+ * shape is the one `EXECUTOR_CATALOGUE` arrived at after a parallel name list
42
+ * drifted from the thing it named and shipped an executor nothing ever
43
+ * exercised: here the id list, the closed-catalogue check and the mount are all
44
+ * read out of this one object, so an id cannot exist without a factory and a
45
+ * factory cannot be unreachable by name.
46
+ */
47
+ const WORKSPACE_PROVIDER_CATALOGUE = new Map([
48
+ // The local plugin takes its `rootDir` from the composed document and needs
49
+ // nothing from the mount, which is why it ignores the context rather than
50
+ // being handed a narrower one.
51
+ [DEFAULT_WORKSPACE_PROVIDER_ID, () => createWorkspacePlugin()],
52
+ [GIT_WORKTREE_PROVIDER_ID, ({ repoPath }) => createGitWorktreeWorkspacePlugin({ repoPath })],
53
+ ]);
54
+ /** Every provider id a selection may name, in catalogue order. */
55
+ export function availableWorkspaceProviderIds() {
56
+ return [...WORKSPACE_PROVIDER_CATALOGUE.keys()];
57
+ }
58
+ const DOTTED_KEY = `${WORKSPACE_CONFIG_KEY}.${WORKSPACE_PROVIDER_CONFIG_KEY}`;
59
+ function unusableSelection(detail) {
60
+ // `configurationUnusable` rather than a new code: brambo's own document exists
61
+ // and holds a value brambo cannot use, which is exactly what that code is for
62
+ // (see its note in `@skanl/brambo-contracts`). There is no workspace twin of
63
+ // `BRAMBO_EXECUTOR_NOT_FOUND` and this story does not invent one — the message
64
+ // carries the closed catalogue, which is the half a user acts on.
65
+ return new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo's '${DOTTED_KEY}' configuration cannot be used: ${detail}`);
66
+ }
67
+ /**
68
+ * The catalogue's refusal for a name brambo ships no provider under.
69
+ *
70
+ * ONE spelling, called from the selection and from the mount, because the two
71
+ * would otherwise drift — the failure `EXECUTOR_CATALOGUE`'s own note records.
72
+ */
73
+ function unknownWorkspaceProvider(providerId) {
74
+ return unusableSelection(`brambo has no workspace provider named '${providerId}'; available providers: ${availableWorkspaceProviderIds().join(', ')}`);
75
+ }
76
+ /**
77
+ * The workspace provider an already-seeded configuration decides, with the layer
78
+ * that decided it. Pure: it reads the composed view and touches no file.
79
+ *
80
+ * Reading the filesystem here is the thing this must not do. `executors.ts`
81
+ * states the rule for its twin: a session primitive whose behaviour depends on
82
+ * files under the running user's home is not usable from a host that already
83
+ * knows what it wants. The caller seeds layers; this reads the composed view.
84
+ */
85
+ export function selectWorkspaceProvider(config) {
86
+ // The value AND its provenance from ONE dump entry, so the two cannot disagree.
87
+ const decided = config
88
+ .dump()
89
+ .find((entry) => entry.path.length === 2 &&
90
+ entry.path[0] === WORKSPACE_CONFIG_KEY &&
91
+ entry.path[1] === WORKSPACE_PROVIDER_CONFIG_KEY);
92
+ if (decided === undefined) {
93
+ // Reachable, unlike its executor twin: `dump()` reports LEAVES, so a
94
+ // document writing an OBJECT at this path leaves no entry here at all. A
95
+ // default taken on this path would mount `local` for a user whose document
96
+ // says something else — silently running somewhere other than where they
97
+ // asked, which is the failure the whole selection exists to remove.
98
+ throw unusableSelection(`no provider is resolvable at this path (a '${DOTTED_KEY}' that is an object rather than one of: ${availableWorkspaceProviderIds().join(', ')} does this)`);
99
+ }
100
+ if (typeof decided.value !== 'string') {
101
+ // Type-checked, never coerced: `String(42)` would turn a typo into the
102
+ // catalogue lookup's problem and report it as an unknown provider name.
103
+ throw unusableSelection(`it must be a string naming one of: ${availableWorkspaceProviderIds().join(', ')}, but the '${decided.layer}' layer supplies ${typeof decided.value}`);
104
+ }
105
+ if (!WORKSPACE_PROVIDER_CATALOGUE.has(decided.value))
106
+ throw unknownWorkspaceProvider(decided.value);
107
+ return {
108
+ providerId: decided.value,
109
+ layer: decided.layer,
110
+ available: availableWorkspaceProviderIds(),
111
+ };
112
+ }
113
+ /**
114
+ * The plugin for one catalogue id.
115
+ *
116
+ * Separate from the selection so the selection stays a pure value a host can
117
+ * report on without constructing anything — and so there is exactly one place a
118
+ * provider id turns into a plugin. An id the catalogue does not hold is a coded
119
+ * failure, never a fallback.
120
+ */
121
+ export function createSelectedWorkspacePlugin(providerId, context) {
122
+ const factory = WORKSPACE_PROVIDER_CATALOGUE.get(providerId);
123
+ if (factory === undefined)
124
+ throw unknownWorkspaceProvider(providerId);
125
+ return factory(context);
126
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@skanl/brambo-session",
3
+ "version": "0.1.1",
4
+ "description": "The brambo session: everything `brambo run` does except argv, JSON and exit codes.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "brambo",
8
+ "session",
9
+ "orchestration",
10
+ "executor"
11
+ ],
12
+ "homepage": "https://github.com/SKANL/brambo#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/SKANL/brambo/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/SKANL/brambo.git",
19
+ "directory": "packages/session"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "brambo-source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@skanl/brambo-adapter-cli": "0.1.1",
38
+ "@skanl/brambo-contracts": "0.1.1",
39
+ "@skanl/brambo-kernel": "0.1.1",
40
+ "@skanl/brambo-sandbox": "0.1.1",
41
+ "@skanl/brambo-workspace-git-worktree": "0.1.1",
42
+ "@skanl/brambo-workspace-local": "0.1.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^24.13.3",
46
+ "rolldown": "1.2.5",
47
+ "typescript": "~7.0.2",
48
+ "vitest": "^4.1.11"
49
+ },
50
+ "files": [
51
+ "dist"
52
+ ],
53
+ "scripts": {
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "lint": "eslint .",
57
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
58
+ }
59
+ }