@brutalsystems/muster 0.6.0 → 0.7.0

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.
@@ -38,6 +38,75 @@ with the keys `slugify`, `suffix`, `assign`, `resolve` in that order is:
38
38
  `a92936ef4b713641d29da3fbafccab0e6da79a54e27bfea3c6a779e801a925b3`.
39
39
  Both revisions produce this hash.
40
40
 
41
+ ## Privacy remediation edit — 2026-09-20
42
+
43
+ `test/fixtures/canonical-id.json` originally copied a real internal project
44
+ codename as an example `rawName`/`display`/`canonicalId` value. Tin Can made
45
+ the same change to its own copy and asked Muster to match; both projects
46
+ renamed the same two example strings to a neutral placeholder
47
+ (`billing-api`).
48
+
49
+ This is a **local text edit to the frozen 0.1.0 copy**, not a re-sync with
50
+ Tin Can's current upstream fixture. By the time of this edit, Tin Can's
51
+ fixture had moved to `tincan_version: 0.5.2` and gained real new content —
52
+ `opencode` suffix rules, new fixture cases, and new `CANONICAL_ID.md`
53
+ sections describing opencode session-id behavior. Muster's OpenCode
54
+ integration explicitly defers Tin Can/address-format work (see
55
+ `RELEASE_NOTES.md`), so adopting that upstream fixture wholesale would claim
56
+ verification Muster does not have. Per this document's own earlier
57
+ guidance — "their full-file hashes ... must not be compared to an entire
58
+ newer upstream fixture to infer a format change" — only the two leaked
59
+ strings were changed; every other byte of the 0.1.0-vintage fixture is
60
+ unchanged, including its 31 cases and `tincan_version: 0.1.0`.
61
+
62
+ New hash after this edit:
63
+
64
+ | File | SHA-256 |
65
+ | --- | --- |
66
+ | `test/fixtures/canonical-id.json` | `974cef9e06370a562cef588f1cd6d5c1d83b49afdb64808487759d6280bffff2` |
67
+
68
+ `CANONICAL_ID.md`'s hash is unchanged (`8cf4312d6af1c4d85ec0641d8739bec261be2feb35dd16bdd9d8be2b89551915`)
69
+ — the codename never appeared there.
70
+
71
+ Adopting Tin Can's opencode naming rules, if Muster's OpenCode/Tin Can
72
+ integration is ever un-deferred, is a separate, deliberate compatibility
73
+ check — not something this edit performs.
74
+
75
+ The `at commit fbaaea5842fd4a5c86849d7f51c8e16b8683b058` provenance line
76
+ above may no longer resolve in Tin Can's current history: Tin Can applied
77
+ the same codename remediation via a history rewrite (`git filter-repo
78
+ --replace-text`, force-pushed), which changes the hash of every commit
79
+ whose content or ancestry it touched. This is a historical acquisition
80
+ record, not a claim that the commit is still reachable.
81
+
82
+ ## Contract baseline moved to 0.5.2 — 2026-09-20
83
+
84
+ `@brutalsystems/tincan@0.2.0`, this document's verified baseline, was
85
+ withdrawn from the npm registry as part of Tin Can's own codename
86
+ remediation (every version through 0.5.1 carried the same leaked string
87
+ 0.7.0's fixture did, in Tin Can's README and fixture). Withdrawn version
88
+ numbers cannot be republished. This is a **registry availability problem,
89
+ not a format change** — Tin Can diffed `src/naming.ts` between 0.2.0 and
90
+ 0.5.2 and found `slugify`, `suffixOf`, `assignNames`, and `resolvePeer`
91
+ byte-identical; the only source change is the `RuntimeName` union widening
92
+ to add `'opencode'`. `test/fixtures/canonical-id.json` gained new opencode
93
+ cases with every pre-existing expectation unchanged.
94
+
95
+ Muster's own contract suite was run against a real installed
96
+ `@brutalsystems/tincan@0.5.2` and passed all four cases before this
97
+ re-baseline was made.
98
+
99
+ `0.3.0` through `0.5.1` were never verified by Muster's contract suite (see
100
+ "Upgrade boundary" below) and have also been withdrawn from the registry.
101
+ The verified, currently-available set is exactly `{0.5.2}`.
102
+
103
+ One caveat for anyone reconstructing a baseline from Tin Can's git history
104
+ rather than a published artifact: Tin Can's git tags predating 2026-09-20
105
+ no longer match what npm ever served at that version number, because the
106
+ history-rewrite in the section above applies retroactively to every commit,
107
+ tagged ones included. Git content at an old tag is not evidence of what a
108
+ withdrawn npm artifact actually contained.
109
+
41
110
  ## Upgrade boundary: listing and state
42
111
 
43
112
  The upstream release notes report that 0.3.0 expands
package/README.md CHANGED
@@ -2,21 +2,24 @@
2
2
 
3
3
  [![license](https://img.shields.io/github/license/BrutalSystems/muster)](./LICENSE)
4
4
 
5
- Muster launches instructed Codex or Claude Code agents on this machine. A
5
+ Muster launches instructed Codex, Claude Code, or OpenCode agents on this machine. A
6
6
  `session` returns an address only after its runtime is reachable. A `task`
7
7
  runs once, captures output, and never advertises a peer address.
8
8
 
9
9
  **Live launch verification:** Codex and Claude both launched and answered their
10
- initial prompts without terminal interaction. Claude requires a directory
11
- already trusted by the operator. The automated suite uses fake runtimes,
12
- not real models.
10
+ initial prompts without terminal interaction. OpenCode 1.18.31 passed the
11
+ loopback API and local-provider protocol checks. Claude requires a directory
12
+ already trusted by the operator. The automated suite uses fake runtimes, not
13
+ real models.
13
14
 
14
15
  ## Build and run locally
15
16
 
16
- Requires Node 22.12+ (tested with 24.16), TypeScript 5, and Codex 0.155.1 / Claude
17
- Code 2.1.267. Terminal drivers use tmux or node-pty. Process discovery currently
18
- requires a POSIX host with `ps` and `lsof`; the terminal interface itself is
19
- platform-neutral. The macOS Terminal driver is an unavailable v1 stub.
17
+ Requires Node 22.12+ (tested with 24.16), TypeScript 5, Codex 0.155.1, Claude
18
+ Code 2.1.267, and OpenCode 1.18.31 or newer. Newer OpenCode releases must retain
19
+ the CLI and loopback HTTP API used by Muster. Terminal drivers use tmux or
20
+ node-pty. Process discovery currently requires a POSIX host with `ps` and
21
+ `lsof`; the terminal interface itself is platform-neutral. The macOS Terminal
22
+ driver is an unavailable v1 stub.
20
23
 
21
24
  Before launching a Claude session, open Claude normally in the target directory
22
25
  and complete its workspace-trust review yourself, then exit that setup session.
@@ -29,7 +32,10 @@ npm ci
29
32
  npm run build
30
33
  node dist/muster.js run codex --prompt 'Review the authentication flow'
31
34
  node dist/muster.js run claude --prompt 'Summarize the project' --host tmux
35
+ node dist/muster.js run opencode --prompt 'Review this project'
36
+ node dist/muster.js run opencode --prompt 'Build a plan' -- --model coldfire-local/qwen3-30b-a3b
32
37
  node dist/muster.js run codex --kind task --prompt 'Explain the test layout'
38
+ node dist/muster.js run opencode --kind task --prompt 'Summarize the tests'
33
39
  node dist/muster.js list
34
40
  node dist/muster.js list --format human
35
41
  node dist/muster.js list --kind task
@@ -64,11 +70,25 @@ red for failures or nonzero exit codes, and cyan for follow-up commands. IDs
64
70
  and paths retain the normal text color. Set `NO_COLOR=1` to disable colors;
65
71
  piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
66
72
 
67
- `run` accepts an optional `--` followed by runtime arguments. The normal allowlist
68
- is `--model` / `-m`, plus Claude's `--effort`. Unknown options, bundled short
69
- options, config injection and raw permission overrides are refused, even with
70
- `allow_dangerous_flags` enabled. Use Muster's normalized permission flags. Prompt strings
71
- are passed as a single literal argument after the runtime's option terminator.
73
+ `run` accepts an optional `--` followed by runtime arguments. The normal
74
+ allowlist is `--model` / `-m`, plus Claude's `--effort` and OpenCode's `--agent`
75
+ and `--variant`. Unknown options, bundled short options, config injection and
76
+ raw permission overrides are refused, even with `allow_dangerous_flags`
77
+ enabled. Use Muster's normalized permission flags. Prompt strings are always
78
+ passed as one literal argument. Codex and Claude launches and OpenCode tasks put
79
+ that value after `--`; OpenCode sessions use the dedicated `--prompt` value.
80
+
81
+ OpenCode inherits the provider and model definitions from its normal user and
82
+ project configuration, so configured local OpenAI-compatible providers such as
83
+ `coldfire-local/qwen3-30b-a3b` remain available. Muster never rewrites those
84
+ files. Put runtime options after `--`, as in the local-model example above.
85
+
86
+ ```sh
87
+ muster run opencode --prompt 'Review this project'
88
+ muster run opencode --prompt 'Build a plan' -- --model coldfire-local/qwen3-30b-a3b
89
+ muster run opencode --kind task --prompt 'Summarize the tests'
90
+ muster run opencode --mcp tincan --permissions auto --sandbox workspace-write --prompt 'Coordinate'
91
+ ```
72
92
 
73
93
  ## Terminal lifetime
74
94
 
@@ -77,6 +97,7 @@ Add `--open` to display the launched session in a new Terminal.app window on mac
77
97
  ```sh
78
98
  muster run codex --open --prompt "Review this project" --format human
79
99
  muster run claude --open --terminal ghostty --prompt "Review this project" --format human
100
+ muster run opencode --open --terminal iterm2 --prompt "Review this project" --format human
80
101
  muster run codex --open --terminal iterm2 --prompt "Review this project" --format human
81
102
  ```
82
103
 
@@ -116,6 +137,16 @@ and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
116
137
  or startup failure cleans the process tree and host window, logs failure, and
117
138
  returns an error instead of a peer record.
118
139
 
140
+ Each OpenCode session owns a separate HTTP server bound to `127.0.0.1` on a
141
+ Muster-selected port. Muster verifies that the listener belongs to the launched
142
+ process tree, stores its `server_url` and durable `session_id`, and never attaches
143
+ to an existing personal OpenCode server. `list` refreshes the session through
144
+ that endpoint; `stop` aborts it best-effort and still performs authoritative
145
+ process-tree cleanup. The URL is local control metadata, not a public address.
146
+ OpenCode sessions otherwise use the same tmux/pty, viewer, list, stop, and human
147
+ output behavior described above; OpenCode tasks use the same detached output
148
+ and exit-status lifecycle as other tasks.
149
+
119
150
  ## Configuration and permissions
120
151
 
121
152
  Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
@@ -146,11 +177,11 @@ muster run claude --permissions auto --sandbox workspace-write \
146
177
  --prompt 'Implement the change' --format human
147
178
  ```
148
179
 
149
- | Muster permissions | Codex translation | Claude Code translation |
150
- |---|---|---|
151
- | `deny` (default) | Approval policy `never` | Permission mode `dontAsk` |
152
- | `auto` | `--approve-for-me` | Permission mode `auto` |
153
- | `bypass` | Bypass approvals and sandbox | Permission mode `bypassPermissions` |
180
+ | Muster permissions | Codex translation | Claude Code translation | OpenCode translation |
181
+ | ------------------ | ---------------------------- | ----------------------------------- | ------------------------------------------------------------------------------- |
182
+ | `deny` (default) | Approval policy `never` | Permission mode `dontAsk` | Deny write, shell, task, external-directory, web, and unselected MCP operations |
183
+ | `auto` | `--approve-for-me` | Permission mode `auto` | `--auto` with explicit deny rules |
184
+ | `bypass` | Bypass approvals and sandbox | Permission mode `bypassPermissions` | Allow supported operations |
154
185
 
155
186
  `deny` refuses actions that would require approval; it does not prohibit tools
156
187
  already allowed by the sandbox or permission rules. `auto` delegates permission
@@ -175,6 +206,12 @@ launch settings, not a continuous attestation of remote policy or user changes.
175
206
  See [Codex auto-review](https://learn.chatgpt.com/docs/sandboxing/auto-review) and
176
207
  [Claude permission modes](https://code.claude.com/docs/en/permission-modes).
177
208
 
209
+ OpenCode permissions are tool policy, not an OS-level filesystem sandbox.
210
+ Muster's read-only OpenCode policy denies editing, shell, subagent,
211
+ external-directory, web, and MCP operations except explicitly selected MCP
212
+ tools; it does not claim kernel-enforced isolation. Auto still requires
213
+ `workspace-write`, and bypass still requires authorized `full-access`.
214
+
178
215
  The concurrency cap is shared by separate CLI/MCP processes, including pending
179
216
  launches. Settings apply to both kinds; tasks are read-only by default.
180
217
 
@@ -194,10 +231,17 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
194
231
  Claude permissions and its command sandbox are different mechanisms; neither
195
232
  claim implies that every external tool is OS-sandboxed.
196
233
 
234
+ OpenCode keeps normal provider/model configuration but launches with `--pure`,
235
+ which disables inherited external plugins. Before launch, Muster inspects the
236
+ effective OpenCode configuration, disables every inherited MCP server and tool
237
+ family, enables only the selected Muster definitions, and fails closed if a
238
+ higher-precedence managed policy prevents that isolation from being proven.
239
+
197
240
  These child restrictions prevent automatically propagating spawn authority.
198
241
  External tools are enabled only through the operator configuration and launch
199
- selection described below. The child's runtime inbox remains reachable from an
200
- external Tin Can even when it has no MCP tools of its own.
242
+ selection described below. Codex and Claude runtime inboxes remain reachable
243
+ from an external Tin Can even when they have no MCP tools of their own. OpenCode
244
+ delivery remains deferred as documented under Tin Can compatibility.
201
245
  No user-level runtime configuration is rewritten, and workspace-trust dialogs
202
246
  are never accepted automatically.
203
247
 
@@ -224,11 +268,13 @@ tools = ["peers", "send_peer", "message_log"]
224
268
  required = false
225
269
  ```
226
270
 
227
- Then either runtime can use it:
271
+ Then any runtime can use it:
228
272
 
229
273
  ```sh
230
274
  muster run codex --mcp tincan --prompt 'Coordinate with the other agent'
231
275
  muster run claude --mcp tincan --prompt 'Coordinate with the other agent'
276
+ muster run opencode --mcp tincan --permissions auto --sandbox workspace-write \
277
+ --prompt 'Coordinate'
232
278
  muster run codex --no-mcp --prompt 'Work without external tools'
233
279
  ```
234
280
 
@@ -261,6 +307,9 @@ Codex receives isolated definitions, using a fresh runtime name when an inherite
261
307
  server has the same name, and its native tool allowlist. Claude receives a private
262
308
  Muster stdio connection that filters both tool discovery and calls; newly added
263
309
  upstream tools cannot bypass the selection. Only selected tools are preapproved.
310
+ OpenCode disables inherited definitions and tool families in its verified
311
+ per-launch overlay, then enables only selected definitions. It also uses
312
+ `--pure`, so inherited plugins do not enter the child process.
264
313
  Claude connection specifications are stored with owner-only permissions under
265
314
  `~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
266
315
  session. MCP server access is separate from filesystem sandbox permissions: a
@@ -306,6 +355,11 @@ Codex `idle` means reachable and not known to be busy, not guaranteed free.
306
355
  The querying app-server can report `notLoaded` for a live thread; Muster maps
307
356
  that to `idle`. Claude state comes from its session registry.
308
357
 
358
+ OpenCode peers expose a durable `session_id` and loopback-only `server_url` so a
359
+ future Tin Can adapter has a stable integration seam. Tin Can does not yet send
360
+ to OpenCode through Muster: choosing native OpenCode messaging versus A2A, and
361
+ updating Tin Can for that transport, are explicitly deferred follow-up work.
362
+
309
363
  ## Verification
310
364
 
311
365
  ```sh
@@ -325,7 +379,7 @@ For reproducible verification, install that exact release outside Muster:
325
379
 
326
380
  ```sh
327
381
  contract_dir=$(mktemp -d)
328
- npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.2.0
382
+ npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.5.2
329
383
  MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
330
384
  ```
331
385
 
package/dist/format.js CHANGED
@@ -35,6 +35,8 @@ function recordHuman(record, color) {
35
35
  ];
36
36
  if (record.canonical_id)
37
37
  lines.push(field("Address", clean(record.canonical_id)));
38
+ if (record.runtime === "opencode" && record.server_url)
39
+ lines.push(field("Control URL", clean(record.server_url)));
38
40
  lines.push(field("Directory", clean(record.cwd)));
39
41
  if (record.permissions)
40
42
  lines.push(field("Permissions", clean(record.permissions)));
package/dist/guard.js CHANGED
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { mcpName, claudeMcpConfig } from "./mcp.js";
4
4
  export const runSchema = z
5
5
  .object({
6
- runtime: z.enum(["codex", "claude"]),
6
+ runtime: z.enum(["codex", "claude", "opencode"]),
7
7
  prompt: z
8
8
  .string()
9
9
  .refine((s) => s.trim().length > 0, "prompt must not be blank"),
@@ -37,12 +37,16 @@ export function resolvePermissions(req, config) {
37
37
  throw new Error("--permissions bypass requires --sandbox full-access");
38
38
  return { permissions, sandbox };
39
39
  }
40
- function extras(req) {
40
+ export function runtimeArgs(req) {
41
41
  const result = [];
42
+ const allowed = req.runtime === "claude"
43
+ ? ["--model", "-m", "--effort"]
44
+ : req.runtime === "opencode"
45
+ ? ["--model", "-m", "--agent", "--variant"]
46
+ : ["--model", "-m"];
42
47
  for (let i = 0; i < req.args.length; i++) {
43
48
  const arg = req.args[i], flag = arg.split("=")[0].toLowerCase().replaceAll("_", "-");
44
- if (!["--model", "-m", "--effort"].includes(flag) ||
45
- (flag === "--effort" && req.runtime !== "claude"))
49
+ if (!allowed.includes(flag))
46
50
  throw new Error(`Runtime option refused: ${arg}`);
47
51
  if (arg.includes("="))
48
52
  result.push(arg);
@@ -56,7 +60,7 @@ function extras(req) {
56
60
  return result;
57
61
  }
58
62
  export function launchArgs(req, config, mcp = [], bridgePath) {
59
- const extra = extras(req);
63
+ const extra = runtimeArgs(req);
60
64
  const policy = resolvePermissions(req, config);
61
65
  if (req.runtime === "codex")
62
66
  return [
@@ -0,0 +1,86 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { OpenCodeHttp, OpenCodeProtocolError, } from "../opencode-http.js";
3
+ import { command, delay, descendants, processRef } from "./processes.js";
4
+ const POLL_INTERVAL_MS = 50;
5
+ function rawName(session) {
6
+ return session.title.trim() === "" ? null : session.title;
7
+ }
8
+ export async function openCodeEndpointOwnedBy(rootPid, serverUrl, deadline) {
9
+ try {
10
+ const endpoint = new URL(serverUrl);
11
+ const port = Number(endpoint.port);
12
+ if (!Number.isInteger(port) || port <= 0 || deadline <= Date.now())
13
+ return false;
14
+ const family = new Set(await descendants(rootPid, deadline));
15
+ const output = await command("lsof", [
16
+ "-nP",
17
+ "-a",
18
+ `-iTCP@${endpoint.hostname}:${port}`,
19
+ "-sTCP:LISTEN",
20
+ "-Fp",
21
+ ], Math.max(1, Math.min(1000, deadline - Date.now())));
22
+ const listeners = output
23
+ .split("\n")
24
+ .filter((line) => /^p\d+$/.test(line))
25
+ .map((line) => Number(line.slice(1)));
26
+ return listeners.length > 0 && listeners.every((pid) => family.has(pid));
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, deadline) {
33
+ const resolvedCwd = await realpath(cwd);
34
+ const client = new OpenCodeHttp(serverUrl);
35
+ let ownershipProven = false;
36
+ while (Date.now() < deadline) {
37
+ if (!(await processRef(rootPid)))
38
+ return;
39
+ if (!ownershipProven &&
40
+ !(await openCodeEndpointOwnedBy(rootPid, serverUrl, deadline))) {
41
+ const remaining = deadline - Date.now();
42
+ if (remaining > 0)
43
+ await delay(Math.min(POLL_INTERVAL_MS, remaining));
44
+ continue;
45
+ }
46
+ ownershipProven = true;
47
+ try {
48
+ const health = await client.health(deadline);
49
+ if (health.healthy) {
50
+ const sessions = await client.sessions(deadline);
51
+ if (!(await processRef(rootPid)))
52
+ return;
53
+ const candidates = sessions.filter((session) => session.directory === resolvedCwd &&
54
+ session.time.created >= createdAfter);
55
+ const statuses = candidates.length
56
+ ? await client.statuses(deadline)
57
+ : {};
58
+ if (!(await processRef(rootPid)))
59
+ return;
60
+ if (!(await openCodeEndpointOwnedBy(rootPid, serverUrl, deadline))) {
61
+ ownershipProven = false;
62
+ continue;
63
+ }
64
+ const matches = candidates.filter((session) => Object.prototype.hasOwnProperty.call(statuses, session.id));
65
+ if (matches.length > 1)
66
+ throw new Error("Multiple OpenCode sessions matched the launch endpoint, cwd, and creation window");
67
+ const match = matches[0];
68
+ if (match)
69
+ return {
70
+ id: match.id,
71
+ pid: rootPid,
72
+ rawName: rawName(match),
73
+ cwd: resolvedCwd,
74
+ serverUrl,
75
+ };
76
+ }
77
+ }
78
+ catch (error) {
79
+ if (!(error instanceof OpenCodeProtocolError))
80
+ throw error;
81
+ }
82
+ const remaining = deadline - Date.now();
83
+ if (remaining > 0)
84
+ await delay(Math.min(POLL_INTERVAL_MS, remaining));
85
+ }
86
+ }
package/dist/muster.js CHANGED
@@ -13,7 +13,7 @@ const listSchema = z
13
13
  .strict();
14
14
  const idSchema = z.object({ id: z.string().min(1) }).strict();
15
15
  const help = `Usage:
16
- muster run <codex|claude> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [-- RUNTIME_OPTIONS]
16
+ muster run <codex|claude|opencode> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [-- RUNTIME_OPTIONS]
17
17
  muster list [--kind session|task] [--format json|human]
18
18
  muster stop <id> [--format json|human]
19
19
  muster output <id>
@@ -65,7 +65,10 @@ async function main() {
65
65
  inputSchema: {
66
66
  type: "object",
67
67
  properties: {
68
- runtime: { type: "string", enum: ["codex", "claude"] },
68
+ runtime: {
69
+ type: "string",
70
+ enum: ["codex", "claude", "opencode"],
71
+ },
69
72
  prompt: { type: "string", minLength: 1 },
70
73
  cwd: { type: "string" },
71
74
  kind: {
package/dist/naming.js CHANGED
@@ -1,5 +1,6 @@
1
- /** Vendored from Tin Can src/naming.ts at fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). */
2
- /** Peer naming per the handoff §7: slug for humans, canonical id for the log. */
1
+ export function runtimeName(runtime) {
2
+ return runtime === "claude" ? "claude-code" : runtime;
3
+ }
3
4
  export function slugify(raw) {
4
5
  return raw
5
6
  .toLowerCase()
@@ -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
+ }
package/dist/run.js CHANGED
@@ -4,19 +4,23 @@ import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "node:child_process";
6
6
  import { loadConfig } from "./config.js";
7
- import { runSchema, launchArgs, launchEnv, resolvePermissions, } from "./guard.js";
7
+ import { runSchema, launchArgs, launchEnv, resolvePermissions, runtimeArgs, } from "./guard.js";
8
8
  import { Registry } from "./registry.js";
9
9
  import { LaunchLog } from "./log.js";
10
10
  import { hosts, selectHost } from "./hosts/index.js";
11
11
  import { processRef, isSame, stopTree, delay, descendants, } from "./identity/processes.js";
12
12
  import { resolveCodex } from "./identity/codex.js";
13
13
  import { resolveClaude } from "./identity/claude.js";
14
+ import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
14
15
  import { codexReachable } from "./reach/codex.js";
15
16
  import { claudeReachable } from "./reach/claude.js";
17
+ import { openCodeReachable } from "./reach/opencode.js";
16
18
  import { assertClaudePolicy } from "./claude-policy.js";
17
19
  import { codexPolicyArgs } from "./codex-policy.js";
18
20
  import { CodexRpc } from "./codex-rpc.js";
19
- import { assignNames, resolvePeer } from "./naming.js";
21
+ import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
22
+ import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
23
+ import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
20
24
  import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
21
25
  export class Muster {
22
26
  config;
@@ -64,7 +68,11 @@ export class Muster {
64
68
  throw new Error("--open requires a tmux session on macOS");
65
69
  const permissions = resolvePermissions(req, this.config);
66
70
  const selectedMcp = selectMcp(req, this.config);
67
- let argv = launchArgs(req, this.config);
71
+ let argv = [];
72
+ if (req.runtime === "opencode")
73
+ runtimeArgs(req);
74
+ else
75
+ argv = launchArgs(req, this.config);
68
76
  const driver = req.kind === "session"
69
77
  ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
70
78
  : undefined;
@@ -74,6 +82,8 @@ export class Muster {
74
82
  let policy = [];
75
83
  if (req.runtime === "claude")
76
84
  await assertClaudePolicy(this.env);
85
+ if (req.runtime === "opencode")
86
+ await assertOpenCodeVersion(this.env, "1.18.31", Date.now() + 4000);
77
87
  const reservation = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent);
78
88
  this.owned.add(reservation.launchId);
79
89
  let entry = reservation;
@@ -96,7 +106,7 @@ export class Muster {
96
106
  ...permissions,
97
107
  });
98
108
  const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
99
- const runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
109
+ let runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
100
110
  entry = await this.registry.update(entry.launchId, prepared.summary);
101
111
  let bridgePath;
102
112
  if (req.runtime === "claude" && prepared.servers.length) {
@@ -108,11 +118,29 @@ export class Muster {
108
118
  });
109
119
  await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
110
120
  }
111
- argv = launchArgs(req, this.config, prepared.servers, bridgePath);
121
+ if (req.runtime !== "opencode")
122
+ argv = launchArgs(req, this.config, prepared.servers, bridgePath);
112
123
  if (req.runtime === "codex") {
113
124
  policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers);
114
125
  argv.splice(1, 0, ...policy);
115
126
  }
127
+ else if (req.runtime === "opencode" && req.kind === "session") {
128
+ const port = await allocateLoopbackPort();
129
+ const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
130
+ const launch = await openCodeSessionLaunch(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline);
131
+ argv = launch.argv;
132
+ runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
133
+ entry = await this.registry.update(entry.launchId, {
134
+ server_url: launch.serverUrl,
135
+ opencode_port: launch.port,
136
+ });
137
+ }
138
+ else if (req.runtime === "opencode" && req.kind === "task") {
139
+ const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
140
+ const launch = await openCodeTaskLaunch(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline);
141
+ argv = launch.argv;
142
+ runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
143
+ }
116
144
  if (this.closing)
117
145
  throw new Error("Launch cancelled: Muster is closing");
118
146
  if (req.kind === "task")
@@ -133,7 +161,9 @@ export class Muster {
133
161
  await this.registry.update(entry.launchId, entry);
134
162
  let diagnostic = req.runtime === "claude"
135
163
  ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
136
- : "no descendant runtime identity found";
164
+ : req.runtime === "opencode"
165
+ ? "no OpenCode session matched the launch endpoint, cwd, and creation window"
166
+ : "no descendant runtime identity found";
137
167
  if (req.runtime === "codex")
138
168
  rpc = new CodexRpc(this.env, req.cwd);
139
169
  while (Date.now() < deadline) {
@@ -169,15 +199,21 @@ export class Muster {
169
199
  return false;
170
200
  }
171
201
  })
172
- : await resolveClaude(root.pid, this.env, deadline);
202
+ : req.runtime === "opencode"
203
+ ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
204
+ : await resolveClaude(root.pid, this.env, deadline);
173
205
  if (identity) {
174
206
  diagnostic =
175
207
  req.runtime === "codex"
176
208
  ? "thread id reserved but no rollout"
177
- : "registry exists but inbox socket is not accepting connections";
209
+ : req.runtime === "opencode"
210
+ ? "OpenCode session exists but its endpoint status is not readable"
211
+ : "registry exists but inbox socket is not accepting connections";
178
212
  let metadata;
179
213
  if (req.runtime === "codex")
180
214
  metadata = await codexReachable(rpc, identity.id, deadline);
215
+ else if (req.runtime === "opencode")
216
+ metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
181
217
  else {
182
218
  const claude = identity;
183
219
  if (!(await claudeReachable(claude.socketPath, deadline)))
@@ -188,7 +224,10 @@ export class Muster {
188
224
  throw new Error("readiness completed after launch deadline");
189
225
  if (!(await isSame(root)) || !(await processRef(identity.pid)))
190
226
  throw new Error("process exited before readiness completed");
191
- const runtime = req.runtime === "codex" ? "codex" : "claude-code";
227
+ if (req.runtime === "opencode" &&
228
+ !(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
229
+ throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
230
+ const runtime = runtimeName(req.runtime);
192
231
  const named = assignNames([
193
232
  { runtime, uuid: identity.id, rawName: metadata.rawName },
194
233
  ])[0];
@@ -203,7 +242,12 @@ export class Muster {
203
242
  cwd: req.cwd,
204
243
  ...(req.runtime === "codex"
205
244
  ? { thread_id: identity.id }
206
- : { session_id: identity.id }),
245
+ : req.runtime === "opencode"
246
+ ? {
247
+ session_id: identity.id,
248
+ server_url: entry.server_url,
249
+ }
250
+ : { session_id: identity.id }),
207
251
  pid: identity.pid,
208
252
  host: driver.id,
209
253
  capabilities: driver.capabilities(),
@@ -318,6 +362,17 @@ export class Muster {
318
362
  }
319
363
  async stopEntry(entry) {
320
364
  try {
365
+ const openCodeSessionId = entry.peer?.session_id;
366
+ const abortDeadline = Date.now() + 1000;
367
+ if (entry.runtime === "opencode" &&
368
+ entry.root &&
369
+ entry.server_url &&
370
+ typeof openCodeSessionId === "string" &&
371
+ (await isSame(entry.root)) &&
372
+ (await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, abortDeadline)))
373
+ await new OpenCodeHttp(entry.server_url)
374
+ .abort(openCodeSessionId, abortDeadline)
375
+ .catch(() => { });
321
376
  await this.stopEntryProcesses(entry);
322
377
  }
323
378
  finally {
@@ -353,7 +408,7 @@ export class Muster {
353
408
  throw new Error(`Ambiguous durable id: ${exact.map((e) => e.runtime + ":" + e.id).join(", ")}`);
354
409
  const peers = all.filter((e) => e.kind === "session" && e.status === "running" && e.peer);
355
410
  const named = peers.map((e) => ({
356
- runtime: e.runtime === "codex" ? "codex" : "claude-code",
411
+ runtime: runtimeName(e.runtime),
357
412
  uuid: e.id,
358
413
  rawName: String(e.peer.name),
359
414
  slug: String(e.peer.canonical_id)
@@ -389,6 +444,23 @@ export class Muster {
389
444
  rpc = new CodexRpc(this.env, entry.cwd);
390
445
  metadata = await codexReachable(rpc, entry.id, deadline);
391
446
  }
447
+ else if (entry.runtime === "opencode") {
448
+ if (!entry.server_url)
449
+ throw new Error("session endpoint missing");
450
+ if (!(await isSame(entry.root)) ||
451
+ !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
452
+ throw new Error("session endpoint is not owned by the launch");
453
+ metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), {
454
+ id: entry.id,
455
+ pid: entry.root.pid,
456
+ rawName: null,
457
+ cwd: entry.cwd,
458
+ serverUrl: entry.server_url,
459
+ }, deadline);
460
+ if (!(await isSame(entry.root)) ||
461
+ !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
462
+ throw new Error("session endpoint ownership changed during refresh");
463
+ }
392
464
  else {
393
465
  const identity = await resolveClaude(entry.root.pid, this.env, deadline);
394
466
  if (!identity ||
@@ -399,7 +471,7 @@ export class Muster {
399
471
  }
400
472
  const named = assignNames([
401
473
  {
402
- runtime: entry.runtime === "codex" ? "codex" : "claude-code",
474
+ runtime: runtimeName(entry.runtime),
403
475
  uuid: entry.id,
404
476
  rawName: metadata.rawName,
405
477
  },
@@ -410,6 +482,9 @@ export class Muster {
410
482
  state: metadata.state,
411
483
  name: named.display,
412
484
  canonical_id: named.canonicalId,
485
+ ...(entry.runtime === "opencode"
486
+ ? { server_url: entry.server_url }
487
+ : {}),
413
488
  },
414
489
  });
415
490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -62,5 +62,8 @@
62
62
  "CANONICAL_ID.md",
63
63
  "CONTRACT_PROVENANCE.md",
64
64
  "test/fixtures/canonical-id.json"
65
- ]
65
+ ],
66
+ "allowScripts": {
67
+ "node-pty@1.1.0": true
68
+ }
66
69
  }
@@ -31,8 +31,8 @@
31
31
  },
32
32
  {
33
33
  "case": "claude peers are qualified with their runtime",
34
- "peers": [ { "runtime": "claude-code", "rawName": "cohort-api", "uuid": "5af69d42-2214-41d9-b13f-9c3177eb60ce" } ],
35
- "expect": [ { "display": "cohort-api", "canonicalId": "claude-code:cohort-api.0ce" } ]
34
+ "peers": [ { "runtime": "claude-code", "rawName": "billing-api", "uuid": "5af69d42-2214-41d9-b13f-9c3177eb60ce" } ],
35
+ "expect": [ { "display": "billing-api", "canonicalId": "claude-code:billing-api.0ce" } ]
36
36
  },
37
37
  {
38
38
  "case": "collision: every colliding peer is suffixed, and only those",