@brutalsystems/muster 0.6.0 → 0.7.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,241 @@
1
+ import net from "node:net";
2
+ import { z } from "zod";
3
+ const REQUEST_TIMEOUT_MS = 2_000;
4
+ const healthSchema = z.object({
5
+ healthy: z.boolean(),
6
+ version: z.string().min(1),
7
+ });
8
+ const sessionSchema = z.object({
9
+ id: z.string().min(1),
10
+ title: z.string(),
11
+ directory: z.string().min(1),
12
+ time: z.object({
13
+ created: z.number(),
14
+ updated: z.number(),
15
+ }),
16
+ });
17
+ const statusSchema = z.discriminatedUnion("type", [
18
+ z.object({ type: z.literal("idle") }),
19
+ z.object({ type: z.literal("busy") }),
20
+ z.object({
21
+ type: z.literal("retry"),
22
+ attempt: z.number(),
23
+ message: z.string(),
24
+ next: z.number(),
25
+ action: z
26
+ .object({
27
+ reason: z.string(),
28
+ provider: z.string(),
29
+ title: z.string(),
30
+ message: z.string(),
31
+ label: z.string(),
32
+ link: z.string().optional(),
33
+ })
34
+ .optional(),
35
+ }),
36
+ ]);
37
+ const eventSchema = z
38
+ .object({
39
+ id: z.string().optional(),
40
+ type: z.string().min(1),
41
+ properties: z.unknown(),
42
+ })
43
+ .passthrough();
44
+ export class OpenCodeProtocolError extends Error {
45
+ code;
46
+ method;
47
+ path;
48
+ status;
49
+ name = "OpenCodeProtocolError";
50
+ constructor(message, code, method, path, status) {
51
+ super(`OpenCode protocol error: ${message}`);
52
+ this.code = code;
53
+ this.method = method;
54
+ this.path = path;
55
+ this.status = status;
56
+ }
57
+ }
58
+ function protocolError(message, code = "request", method, path, status) {
59
+ return new OpenCodeProtocolError(message, code, method, path, status);
60
+ }
61
+ function requestTimeout(deadline) {
62
+ const remaining = deadline - Date.now();
63
+ if (remaining <= 0)
64
+ throw protocolError("request timed out", "timeout");
65
+ return Math.min(remaining, REQUEST_TIMEOUT_MS);
66
+ }
67
+ function isJsonContentType(value) {
68
+ const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
69
+ return mediaType === "application/json" || mediaType?.endsWith("+json");
70
+ }
71
+ function isLoopbackHostname(hostname) {
72
+ return hostname === "127.0.0.1" || hostname === "[::1]";
73
+ }
74
+ /**
75
+ * Reserves an ephemeral loopback port only long enough to learn its number.
76
+ * The caller must still prove endpoint ownership during readiness because
77
+ * another process can claim the port after this function closes its listener.
78
+ */
79
+ export function allocateLoopbackPort() {
80
+ return new Promise((resolve, reject) => {
81
+ const server = net.createServer();
82
+ server.unref();
83
+ server.once("error", reject);
84
+ server.listen(0, "127.0.0.1", () => {
85
+ const address = server.address();
86
+ if (!address || typeof address === "string") {
87
+ server.close();
88
+ reject(protocolError("could not allocate a loopback port"));
89
+ return;
90
+ }
91
+ const port = address.port;
92
+ server.close((error) => {
93
+ if (error)
94
+ reject(protocolError("could not release allocated port"));
95
+ else
96
+ resolve(port);
97
+ });
98
+ });
99
+ });
100
+ }
101
+ export class OpenCodeHttp {
102
+ baseUrl;
103
+ constructor(baseUrl) {
104
+ let parsed;
105
+ try {
106
+ parsed = new URL(baseUrl);
107
+ }
108
+ catch {
109
+ throw protocolError("endpoint must be a loopback HTTP URL using a literal loopback IP", "endpoint");
110
+ }
111
+ if (parsed.protocol !== "http:" ||
112
+ !isLoopbackHostname(parsed.hostname) ||
113
+ parsed.username !== "" ||
114
+ parsed.password !== "") {
115
+ throw protocolError("endpoint must be a loopback HTTP URL using a literal loopback IP", "endpoint");
116
+ }
117
+ this.baseUrl = new URL(parsed.origin);
118
+ }
119
+ health(deadline) {
120
+ return this.requestJson("GET", "/global/health", deadline, healthSchema);
121
+ }
122
+ sessions(deadline) {
123
+ return this.requestJson("GET", "/session", deadline, z.array(sessionSchema));
124
+ }
125
+ session(id, deadline) {
126
+ return this.requestJson("GET", `/session/${encodeURIComponent(id)}`, deadline, sessionSchema);
127
+ }
128
+ statuses(deadline) {
129
+ return this.requestJson("GET", "/session/status", deadline, z.record(statusSchema));
130
+ }
131
+ abort(id, deadline) {
132
+ return this.requestJson("POST", `/session/${encodeURIComponent(id)}/abort`, deadline, z.boolean());
133
+ }
134
+ async *events(deadline) {
135
+ const method = "GET";
136
+ const path = "/event";
137
+ const controller = new AbortController();
138
+ let timer = setTimeout(() => controller.abort(), requestTimeout(deadline));
139
+ try {
140
+ const response = await fetch(new URL(path, this.baseUrl), {
141
+ method,
142
+ headers: { accept: "text/event-stream" },
143
+ signal: controller.signal,
144
+ });
145
+ clearTimeout(timer);
146
+ const streamTimeout = deadline - Date.now();
147
+ if (streamTimeout <= 0)
148
+ throw protocolError(`${method} ${path} timed out`, "timeout", method, path);
149
+ timer = setTimeout(() => controller.abort(), streamTimeout);
150
+ if (!response.ok)
151
+ throw protocolError(`${method} ${path} returned HTTP ${response.status}`, "http", method, path, response.status);
152
+ const mediaType = response.headers
153
+ .get("content-type")
154
+ ?.split(";", 1)[0]
155
+ ?.trim()
156
+ .toLowerCase();
157
+ if (mediaType !== "text/event-stream")
158
+ throw protocolError(`${method} ${path} returned the wrong content type`, "content_type", method, path);
159
+ if (!response.body)
160
+ throw protocolError(`${method} ${path} returned no event stream`, "invalid_payload", method, path);
161
+ const decoder = new TextDecoder();
162
+ let buffer = "";
163
+ for await (const chunk of response.body) {
164
+ buffer += decoder.decode(chunk, { stream: true });
165
+ let boundary;
166
+ while ((boundary = buffer.match(/\r?\n\r?\n/))) {
167
+ const block = buffer.slice(0, boundary.index);
168
+ buffer = buffer.slice(boundary.index + boundary[0].length);
169
+ const data = block
170
+ .split(/\r?\n/)
171
+ .filter((line) => line === "data" || line.startsWith("data:"))
172
+ .map((line) => line.slice(5).replace(/^ /, ""))
173
+ .join("\n");
174
+ if (data === "")
175
+ continue;
176
+ yield this.parseEvent(data);
177
+ }
178
+ }
179
+ }
180
+ catch (error) {
181
+ if (controller.signal.aborted)
182
+ throw protocolError(`${method} ${path} timed out`, "timeout", method, path);
183
+ if (error instanceof OpenCodeProtocolError)
184
+ throw error;
185
+ throw protocolError(`${method} ${path} request failed`, "request", method, path);
186
+ }
187
+ finally {
188
+ clearTimeout(timer);
189
+ controller.abort();
190
+ }
191
+ }
192
+ parseEvent(data) {
193
+ let value;
194
+ try {
195
+ value = JSON.parse(data);
196
+ }
197
+ catch {
198
+ throw protocolError("GET /event returned invalid JSON", "invalid_json", "GET", "/event");
199
+ }
200
+ const parsed = eventSchema.safeParse(value);
201
+ if (!parsed.success)
202
+ throw protocolError("GET /event returned an invalid event payload", "invalid_payload", "GET", "/event");
203
+ return parsed.data;
204
+ }
205
+ async requestJson(method, path, deadline, schema) {
206
+ const controller = new AbortController();
207
+ const timer = setTimeout(() => controller.abort(), requestTimeout(deadline));
208
+ try {
209
+ const response = await fetch(new URL(path, this.baseUrl), {
210
+ method,
211
+ headers: { accept: "application/json" },
212
+ signal: controller.signal,
213
+ });
214
+ if (!response.ok)
215
+ throw protocolError(`${method} ${path} returned HTTP ${response.status}`, "http", method, path, response.status);
216
+ if (!isJsonContentType(response.headers.get("content-type")))
217
+ throw protocolError(`${method} ${path} returned the wrong content type`, "content_type", method, path);
218
+ let value;
219
+ try {
220
+ value = await response.json();
221
+ }
222
+ catch {
223
+ throw protocolError(`${method} ${path} returned invalid JSON`, "invalid_json", method, path);
224
+ }
225
+ const parsed = schema.safeParse(value);
226
+ if (!parsed.success)
227
+ throw protocolError(`${method} ${path} returned an invalid payload`, "invalid_payload", method, path);
228
+ return parsed.data;
229
+ }
230
+ catch (error) {
231
+ if (controller.signal.aborted)
232
+ throw protocolError(`${method} ${path} timed out`, "timeout", method, path);
233
+ if (error instanceof OpenCodeProtocolError)
234
+ throw error;
235
+ throw protocolError(`${method} ${path} request failed`, "request", method, path);
236
+ }
237
+ finally {
238
+ clearTimeout(timer);
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,382 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { resolvePermissions, runtimeArgs } from "./guard.js";
4
+ const exec = promisify(execFile);
5
+ const DEBUG_CONFIG_ERROR = "Cannot verify effective OpenCode MCP configuration";
6
+ const ISOLATION_ERROR = "OpenCode MCP isolation could not be verified; refusing launch";
7
+ const INLINE_CONFIG_ERROR = "Cannot safely compose the existing inline OpenCode config";
8
+ function isRecord(value) {
9
+ return !!value && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+ function commandTimeout(deadline) {
12
+ const remaining = deadline - Date.now();
13
+ if (remaining <= 0)
14
+ throw new Error(DEBUG_CONFIG_ERROR);
15
+ return Math.min(4000, remaining);
16
+ }
17
+ async function resolvedConfig(env, cwd, deadline, errorMessage = DEBUG_CONFIG_ERROR) {
18
+ try {
19
+ const { stdout } = await exec("opencode", ["--pure", "debug", "config"], {
20
+ env,
21
+ cwd,
22
+ timeout: commandTimeout(deadline),
23
+ maxBuffer: 1024 * 1024,
24
+ encoding: "utf8",
25
+ });
26
+ const value = JSON.parse(stdout);
27
+ if (!isRecord(value))
28
+ throw new Error("invalid config");
29
+ return value;
30
+ }
31
+ catch {
32
+ // OpenCode diagnostics and resolved config may contain credentials. Never
33
+ // include child output, inline config, or the underlying error here.
34
+ throw new Error(errorMessage);
35
+ }
36
+ }
37
+ function fixedEnvironmentName(server, key) {
38
+ const encode = (value) => Buffer.from(value, "utf8").toString("hex").toUpperCase();
39
+ return `MUSTER_OPENCODE_MCP_${encode(server)}_${encode(key)}`;
40
+ }
41
+ function environmentReference(name) {
42
+ return `{env:${name}}`;
43
+ }
44
+ export function openCodeMcpConfig(server) {
45
+ const config = server.config;
46
+ if (config.command) {
47
+ const environment = Object.fromEntries([
48
+ ...Object.keys(config.env).map((key) => [
49
+ key,
50
+ environmentReference(fixedEnvironmentName(server.name, key)),
51
+ ]),
52
+ ...config.env_vars.map((key) => [key, environmentReference(key)]),
53
+ ]);
54
+ return {
55
+ type: "local",
56
+ command: [config.command, ...config.args],
57
+ ...(Object.keys(environment).length ? { environment } : {}),
58
+ timeout: config.startup_timeout_sec * 1000,
59
+ enabled: true,
60
+ };
61
+ }
62
+ return {
63
+ type: "remote",
64
+ url: config.url,
65
+ ...(config.bearer_token_env_var
66
+ ? {
67
+ headers: {
68
+ Authorization: `Bearer ${environmentReference(config.bearer_token_env_var)}`,
69
+ },
70
+ }
71
+ : {}),
72
+ timeout: config.startup_timeout_sec * 1000,
73
+ enabled: true,
74
+ };
75
+ }
76
+ function effectiveMcp(selected, inheritedMcpNames) {
77
+ const used = new Set(inheritedMcpNames);
78
+ return selected.map((server) => {
79
+ let name = server.name;
80
+ let suffix = 0;
81
+ while (used.has(name))
82
+ name = `muster_${server.name}_${++suffix}`;
83
+ used.add(name);
84
+ return { server, name };
85
+ });
86
+ }
87
+ function mcpPolicy(selected, inheritedMcpNames) {
88
+ const effective = effectiveMcp(selected, inheritedMcpNames);
89
+ const mcp = Object.fromEntries(inheritedMcpNames.map((name) => [name, { enabled: false }]));
90
+ const tools = Object.fromEntries(inheritedMcpNames.map((name) => [`${name}_*`, false]));
91
+ for (const { server, name } of effective) {
92
+ mcp[name] = openCodeMcpConfig(server);
93
+ tools[`${name}_*`] = true;
94
+ for (const tool of server.excludedTools)
95
+ tools[`${name}_${tool}`] = false;
96
+ }
97
+ return { effective, mcp, tools };
98
+ }
99
+ function permissions(req, config, tools) {
100
+ const policy = resolvePermissions(req, config);
101
+ const permission = {
102
+ "*": policy.permissions === "bypass"
103
+ ? "allow"
104
+ : policy.permissions === "auto"
105
+ ? "ask"
106
+ : "deny",
107
+ };
108
+ if (policy.permissions !== "bypass") {
109
+ for (const name of [
110
+ "read",
111
+ "glob",
112
+ "grep",
113
+ "list",
114
+ "lsp",
115
+ "todoread",
116
+ "question",
117
+ ])
118
+ permission[name] = "allow";
119
+ }
120
+ if (policy.permissions === "deny" || policy.sandbox === "read-only") {
121
+ for (const name of [
122
+ "edit",
123
+ "bash",
124
+ "task",
125
+ "external_directory",
126
+ "webfetch",
127
+ "websearch",
128
+ ])
129
+ permission[name] = "deny";
130
+ }
131
+ else if (policy.permissions === "auto") {
132
+ for (const name of ["external_directory", "webfetch", "websearch"])
133
+ permission[name] = "deny";
134
+ permission.question = "allow";
135
+ }
136
+ for (const [pattern, enabled] of Object.entries(tools))
137
+ permission[pattern] = enabled ? "allow" : "deny";
138
+ return permission;
139
+ }
140
+ export function openCodeConfig(req, config, selected, inheritedMcpNames) {
141
+ const { mcp, tools } = mcpPolicy(selected, inheritedMcpNames);
142
+ return {
143
+ share: "disabled",
144
+ server: { hostname: "127.0.0.1" },
145
+ permission: permissions(req, config, tools),
146
+ tools,
147
+ mcp,
148
+ };
149
+ }
150
+ function mergeRecords(base, override) {
151
+ return Object.fromEntries([...new Set([...Object.keys(base), ...Object.keys(override)])].map((key) => {
152
+ const original = base[key];
153
+ const replacement = override[key];
154
+ return [
155
+ key,
156
+ isRecord(original) && isRecord(replacement)
157
+ ? mergeRecords(original, replacement)
158
+ : replacement === undefined
159
+ ? original
160
+ : replacement,
161
+ ];
162
+ }));
163
+ }
164
+ function composeInlineConfig(env, overlay) {
165
+ let inherited = {};
166
+ const source = env.OPENCODE_CONFIG_CONTENT;
167
+ if (source !== undefined) {
168
+ try {
169
+ const value = JSON.parse(source);
170
+ if (!isRecord(value))
171
+ throw new Error("invalid inline config");
172
+ inherited = value;
173
+ }
174
+ catch {
175
+ throw new Error(INLINE_CONFIG_ERROR);
176
+ }
177
+ }
178
+ const inheritedMcp = inherited.mcp;
179
+ const overlayMcp = overlay.mcp;
180
+ if ((inheritedMcp !== undefined && !isRecord(inheritedMcp)) ||
181
+ !isRecord(overlayMcp))
182
+ throw new Error(INLINE_CONFIG_ERROR);
183
+ return {
184
+ ...inherited,
185
+ ...overlay,
186
+ // Preserve inherited inline definitions so provider/model configuration
187
+ // remains intact, but apply Muster's enabled state and selected definitions.
188
+ mcp: mergeRecords(inheritedMcp ?? {}, overlayMcp),
189
+ };
190
+ }
191
+ function envPatch(selected, overlay, env) {
192
+ const config = composeInlineConfig(env, overlay);
193
+ const patch = {
194
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
195
+ };
196
+ for (const server of selected)
197
+ for (const [key, value] of Object.entries(server.config.env))
198
+ patch[fixedEnvironmentName(server.name, key)] = value;
199
+ return patch;
200
+ }
201
+ function sameKeys(actual, expected) {
202
+ return (JSON.stringify(Object.keys(actual).sort()) ===
203
+ JSON.stringify(Object.keys(expected).sort()));
204
+ }
205
+ function resolveExpectedEnvironment(value, env) {
206
+ if (typeof value === "string")
207
+ return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => env[name] === undefined ? "" : env[name]);
208
+ if (Array.isArray(value))
209
+ return value.map((item) => resolveExpectedEnvironment(item, env));
210
+ if (isRecord(value))
211
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
212
+ key,
213
+ resolveExpectedEnvironment(item, env),
214
+ ]));
215
+ return value;
216
+ }
217
+ function equivalentMcpDefinition(actual, expected, env) {
218
+ const resolved = resolveExpectedEnvironment(expected, env);
219
+ return (isRecord(resolved) &&
220
+ sameKeys(actual, resolved) &&
221
+ JSON.stringify(actual) === JSON.stringify(resolved));
222
+ }
223
+ function denyOnly(value) {
224
+ if (value === "deny")
225
+ return true;
226
+ return (isRecord(value) &&
227
+ Object.values(value).length > 0 &&
228
+ Object.values(value).every(denyOnly));
229
+ }
230
+ async function verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline) {
231
+ const patch = envPatch(selected, overlay, env);
232
+ const effectiveConfig = await resolvedConfig({ ...env, ...patch }, cwd, deadline, ISOLATION_ERROR);
233
+ const actualMcp = isRecord(effectiveConfig.mcp) ? effectiveConfig.mcp : {};
234
+ const actualTools = isRecord(effectiveConfig.tools)
235
+ ? effectiveConfig.tools
236
+ : {};
237
+ const actualPermission = isRecord(effectiveConfig.permission)
238
+ ? effectiveConfig.permission
239
+ : {};
240
+ const expectedPermission = isRecord(overlay.permission)
241
+ ? overlay.permission
242
+ : {};
243
+ const expected = mcpPolicy(selected, inheritedMcpNames);
244
+ const selectedNames = new Set(expected.effective.map(({ name }) => name));
245
+ for (const name of inheritedMcpNames) {
246
+ const value = actualMcp[name];
247
+ if (!isRecord(value) || value.enabled !== false)
248
+ throw new Error(ISOLATION_ERROR);
249
+ }
250
+ for (const { server, name } of expected.effective) {
251
+ const value = actualMcp[name];
252
+ if (!isRecord(value) ||
253
+ !equivalentMcpDefinition(value, openCodeMcpConfig(server), {
254
+ ...env,
255
+ ...patch,
256
+ }))
257
+ throw new Error(ISOLATION_ERROR);
258
+ }
259
+ for (const [name, value] of Object.entries(actualMcp))
260
+ if (!selectedNames.has(name) &&
261
+ !inheritedMcpNames.includes(name) &&
262
+ (!isRecord(value) || value.enabled !== false))
263
+ throw new Error(ISOLATION_ERROR);
264
+ for (const [pattern, enabled] of Object.entries(expected.tools))
265
+ if (actualTools[pattern] !== enabled)
266
+ throw new Error(ISOLATION_ERROR);
267
+ for (const [pattern, enabled] of Object.entries(actualTools))
268
+ if (enabled === true && expected.tools[pattern] !== true)
269
+ throw new Error(ISOLATION_ERROR);
270
+ for (const [pattern, action] of Object.entries(expectedPermission))
271
+ if (actualPermission[pattern] !== action)
272
+ throw new Error(ISOLATION_ERROR);
273
+ for (const [pattern, action] of Object.entries(actualPermission))
274
+ if (!(pattern in expectedPermission) && !denyOnly(action))
275
+ throw new Error(ISOLATION_ERROR);
276
+ if (effectiveConfig.share !== "disabled")
277
+ throw new Error(ISOLATION_ERROR);
278
+ const expectedServer = overlay.server;
279
+ if (!isRecord(expectedServer) ||
280
+ !isRecord(effectiveConfig.server) ||
281
+ JSON.stringify(effectiveConfig.server) !== JSON.stringify(expectedServer))
282
+ throw new Error(ISOLATION_ERROR);
283
+ return patch;
284
+ }
285
+ export async function openCodeMcpNames(env, cwd, deadline) {
286
+ const config = await resolvedConfig(env, cwd, deadline);
287
+ if (config.mcp === undefined)
288
+ return [];
289
+ if (!isRecord(config.mcp))
290
+ throw new Error(DEBUG_CONFIG_ERROR);
291
+ return Object.keys(config.mcp);
292
+ }
293
+ export async function openCodeSessionLaunch(req, config, selected, inheritedMcpNames, port, env, cwd, deadline) {
294
+ const extra = runtimeArgs(req);
295
+ const policy = resolvePermissions(req, config);
296
+ const overlay = {
297
+ ...openCodeConfig(req, config, selected, inheritedMcpNames),
298
+ server: { hostname: "127.0.0.1", port },
299
+ };
300
+ const patch = await verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline);
301
+ return {
302
+ argv: [
303
+ "opencode",
304
+ "--pure",
305
+ "--hostname",
306
+ "127.0.0.1",
307
+ "--port",
308
+ String(port),
309
+ ...(policy.permissions === "auto" ? ["--auto"] : []),
310
+ ...extra,
311
+ "--prompt",
312
+ req.prompt,
313
+ ],
314
+ envPatch: patch,
315
+ port,
316
+ serverUrl: `http://127.0.0.1:${port}`,
317
+ };
318
+ }
319
+ export async function openCodeTaskLaunch(req, config, selected, inheritedMcpNames, env, cwd, deadline) {
320
+ const extra = runtimeArgs(req);
321
+ const policy = resolvePermissions(req, config);
322
+ const overlay = openCodeConfig(req, config, selected, inheritedMcpNames);
323
+ const patch = await verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline);
324
+ return {
325
+ argv: [
326
+ "opencode",
327
+ "run",
328
+ "--format",
329
+ "json",
330
+ "--pure",
331
+ ...(policy.permissions === "auto" ? ["--auto"] : []),
332
+ ...extra,
333
+ "--",
334
+ req.prompt,
335
+ ],
336
+ envPatch: patch,
337
+ };
338
+ }
339
+ function parseVersion(value) {
340
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
341
+ if (!match)
342
+ return undefined;
343
+ return {
344
+ major: Number(match[1]),
345
+ minor: Number(match[2]),
346
+ patch: Number(match[3]),
347
+ prerelease: match[4] !== undefined,
348
+ };
349
+ }
350
+ function atLeast(actual, minimum) {
351
+ for (const key of ["major", "minor", "patch"]) {
352
+ if (actual[key] !== minimum[key])
353
+ return actual[key] > minimum[key];
354
+ }
355
+ return !actual.prerelease || minimum.prerelease;
356
+ }
357
+ export async function assertOpenCodeVersion(env, minimum, deadline) {
358
+ const required = parseVersion(minimum);
359
+ if (!required)
360
+ throw new Error(`Invalid required OpenCode version: ${minimum}`);
361
+ let detected;
362
+ try {
363
+ const remaining = deadline - Date.now();
364
+ if (remaining <= 0)
365
+ throw new Error("deadline");
366
+ const { stdout } = await exec("opencode", ["--version"], {
367
+ env,
368
+ timeout: Math.min(4000, remaining),
369
+ maxBuffer: 64 * 1024,
370
+ encoding: "utf8",
371
+ });
372
+ detected = stdout.trim();
373
+ }
374
+ catch {
375
+ throw new Error(`OpenCode executable not found or version could not be determined; requires ${minimum}+`);
376
+ }
377
+ const actual = parseVersion(detected);
378
+ if (!actual)
379
+ throw new Error(`Unrecognized OpenCode version; requires compatible version ${minimum}+`);
380
+ if (!atLeast(actual, required))
381
+ throw new Error(`OpenCode ${detected} is unsupported; requires OpenCode ${minimum} or newer`);
382
+ }
@@ -0,0 +1,17 @@
1
+ export async function openCodeReachable(client, identity, deadline) {
2
+ const session = await client.session(identity.id, deadline);
3
+ if (session.id !== identity.id)
4
+ throw new Error("OpenCode session detail returned no matching session");
5
+ const statuses = await client.statuses(deadline);
6
+ const status = statuses[identity.id];
7
+ const rawName = session.title.trim() === "" ? null : session.title;
8
+ if (!status || status.type === "idle")
9
+ return { rawName, state: "idle" };
10
+ if (status.type === "retry")
11
+ return {
12
+ rawName,
13
+ state: "busy",
14
+ diagnostic: `OpenCode retry attempt ${status.attempt}: ${status.message}`,
15
+ };
16
+ return { rawName, state: "busy" };
17
+ }