@brutalsystems/muster 0.5.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
 
@@ -91,7 +112,10 @@ the resolved `terminal` (a launch event, not a live window-status check).
91
112
  Closing the Terminal window leaves the session running; use `muster stop <id>`
92
113
  to stop it. Terminal.app and iTerm2 may require macOS Automation permission.
93
114
  Ghostty opens a separate app instance that exits when its last window closes;
94
- it uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
115
+ that instance disables saved-window restoration so previous tabs and windows
116
+ are not duplicated. It uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
117
+ Terminal.app creates the requested window before activation
118
+ to avoid also opening a default shell window on startup.
95
119
  iTerm2 uses its [documented scripting interface](https://iterm2.com/documentation-scripting.html).
96
120
  If the launch request to the viewer fails,
97
121
  Muster stops the launched session and returns an error.
@@ -99,9 +123,10 @@ Muster stops the launched session and returns an error.
99
123
  Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
100
124
  and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
101
125
 
102
- - **tmux:** a window in the dedicated `muster` session on the `muster` tmux
103
- server. Watchable and attachable; survives the CLI or MCP server exiting.
104
- Use the returned attach hint, or `tmux -L muster attach -t muster`.
126
+ - **tmux:** each agent has its own session on the dedicated `muster` tmux
127
+ server. Opening or exiting one agent does not switch another agent's viewer.
128
+ Watchable and attachable; survives the CLI or MCP server exiting.
129
+ Use the returned attach hint to reconnect to that agent.
105
130
  - **pty:** not watchable or attachable. The CLI prints the peer record and stays
106
131
  running to own the terminal. Ctrl-C stops it. MCP-owned pty sessions stop when
107
132
  the MCP server disconnects. There is no persistent pty daemon.
@@ -112,6 +137,16 @@ and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
112
137
  or startup failure cleans the process tree and host window, logs failure, and
113
138
  returns an error instead of a peer record.
114
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
+
115
150
  ## Configuration and permissions
116
151
 
117
152
  Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
@@ -124,6 +159,7 @@ max_concurrent = 4
124
159
  permissions = "deny"
125
160
  sandbox = "read-only"
126
161
  allow_dangerous_flags = false
162
+ default_mcp = []
127
163
  ```
128
164
 
129
165
  Choose `sandbox = "workspace-write"` yourself when agents should edit files.
@@ -141,11 +177,11 @@ muster run claude --permissions auto --sandbox workspace-write \
141
177
  --prompt 'Implement the change' --format human
142
178
  ```
143
179
 
144
- | Muster permissions | Codex translation | Claude Code translation |
145
- |---|---|---|
146
- | `deny` (default) | Approval policy `never` | Permission mode `dontAsk` |
147
- | `auto` | `--approve-for-me` | Permission mode `auto` |
148
- | `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 |
149
185
 
150
186
  `deny` refuses actions that would require approval; it does not prohibit tools
151
187
  already allowed by the sandbox or permission rules. `auto` delegates permission
@@ -170,12 +206,18 @@ launch settings, not a continuous attestation of remote policy or user changes.
170
206
  See [Codex auto-review](https://learn.chatgpt.com/docs/sandboxing/auto-review) and
171
207
  [Claude permission modes](https://code.claude.com/docs/en/permission-modes).
172
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
+
173
215
  The concurrency cap is shared by separate CLI/MCP processes, including pending
174
216
  launches. Settings apply to both kinds; tasks are read-only by default.
175
217
 
176
218
  Codex defaults to an explicit sandbox and never-ask approval policy. Muster enumerates
177
- and explicitly disables inherited MCP servers, then verifies the effective
178
- configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
219
+ and explicitly disables inherited MCP servers, enables only the selected
220
+ operator definitions, then verifies the effective server selection. Hooks, plugins, app connectors, automatic skill-MCP installation
179
221
  and external notifications are disabled for the child. Unknown MCP names that
180
222
  cannot be addressed safely are refused.
181
223
 
@@ -189,9 +231,17 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
189
231
  Claude permissions and its command sandbox are different mechanisms; neither
190
232
  claim implies that every external tool is OS-sandboxed.
191
233
 
192
- These child restrictions deliberately prevent propagating spawn authority.
193
- Custom MCP tools—including Tin Can—are not automatically injected into the
194
- child. The child's runtime inbox remains reachable from an external Tin Can.
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
+
240
+ These child restrictions prevent automatically propagating spawn authority.
241
+ External tools are enabled only through the operator configuration and launch
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.
195
245
  No user-level runtime configuration is rewritten, and workspace-trust dialogs
196
246
  are never accepted automatically.
197
247
 
@@ -202,6 +252,69 @@ requester, runtime, kind and host. Registry and task outputs also live under
202
252
  closed: verify no Muster operation is running before removing that directory.
203
253
  There is no automatic time-based lock theft.
204
254
 
255
+ ## Optional tools for launched agents
256
+
257
+ MCP servers are optional. Muster does not require or install Tin Can (or any
258
+ other MCP server). Register only servers you want launched agents to use in
259
+ `~/.muster/config.toml`. For example, to use an already-installed Tin Can:
260
+
261
+ ```toml
262
+ # Optional personal defaults for sessions. Omit to keep launches MCP-free.
263
+ default_mcp = ["tincan"]
264
+
265
+ [mcp_servers.tincan]
266
+ command = "tincan"
267
+ tools = ["peers", "send_peer", "message_log"]
268
+ required = false
269
+ ```
270
+
271
+ Then any runtime can use it:
272
+
273
+ ```sh
274
+ muster run codex --mcp tincan --prompt 'Coordinate with the other agent'
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'
278
+ muster run codex --no-mcp --prompt 'Work without external tools'
279
+ ```
280
+
281
+ `--mcp NAME` is repeatable and **replaces** personal defaults. `--no-mcp` clears
282
+ them and cannot be combined with `--mcp`. MCP `run` takes `mcp: ["tincan"]`;
283
+ `mcp: []` disables all servers. Omitted selection uses `default_mcp` for sessions.
284
+ Tasks have no defaults, but accept an explicit selection.
285
+
286
+ Definitions support either `command` plus optional `args`, `env`, and `env_vars`
287
+ for stdio, or `url` plus optional `bearer_token_env_var` for Streamable HTTP.
288
+ `tools` is a required nonempty list of exact tool names. Use `env_vars` and
289
+ `bearer_token_env_var` to reference credentials in the launch environment;
290
+ credentials are not written to launch logs. HTTP OAuth setup shared across
291
+ runtimes, plugin/app tools, MCP resources and prompts are outside this interface.
292
+ Server and tool names must contain only letters, digits, underscores or hyphens.
293
+ Relative stdio commands run from the requested working directory; prefer an
294
+ absolute executable path or a command on PATH.
295
+
296
+ Before agent launch, Muster initializes each selected server and checks its tool
297
+ catalog. No tool is called during preflight. An explicit `--mcp` selection is
298
+ required: a missing server, missing credential, or missing configured tool fails
299
+ that launch. Defaults are optional unless their definition sets `required = true`;
300
+ an unavailable optional default is skipped with `mcp_warnings`. Each preflight is
301
+ bounded by `startup_timeout_sec` (default 10, maximum 60) and the launch deadline.
302
+ JSON results and `list` report selected logical names in `mcp`; human output shows
303
+ `MCP:` and any warnings. These describe launch configuration, not continuous
304
+ server health.
305
+
306
+ Codex receives isolated definitions, using a fresh runtime name when an inherited
307
+ server has the same name, and its native tool allowlist. Claude receives a private
308
+ Muster stdio connection that filters both tool discovery and calls; newly added
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.
313
+ Claude connection specifications are stored with owner-only permissions under
314
+ `~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
315
+ session. MCP server access is separate from filesystem sandbox permissions: a
316
+ selected external tool may have its own write or network capabilities.
317
+
205
318
  ## MCP installation
206
319
 
207
320
  Installed deliberately, in the one session that should hold spawn authority —
@@ -242,6 +355,11 @@ Codex `idle` means reachable and not known to be busy, not guaranteed free.
242
355
  The querying app-server can report `notLoaded` for a live thread; Muster maps
243
356
  that to `idle`. Claude state comes from its session registry.
244
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
+
245
363
  ## Verification
246
364
 
247
365
  ```sh
@@ -261,7 +379,7 @@ For reproducible verification, install that exact release outside Muster:
261
379
 
262
380
  ```sh
263
381
  contract_dir=$(mktemp -d)
264
- 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
265
383
  MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
266
384
  ```
267
385
 
@@ -1,7 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import TOML from "@iarna/toml";
4
+ import { codexMcpConfig } from "./mcp.js";
3
5
  const exec = promisify(execFile);
4
- export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
6
+ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = []) {
5
7
  const args = [
6
8
  "--disable",
7
9
  "hooks",
@@ -17,24 +19,48 @@ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
17
19
  "notify=[]",
18
20
  ];
19
21
  async function list(extra) {
20
- const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
21
- env,
22
- cwd,
23
- timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
24
- maxBuffer: 1024 * 1024,
25
- });
26
- const value = JSON.parse(stdout);
27
- if (!Array.isArray(value))
22
+ try {
23
+ const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
24
+ env,
25
+ cwd,
26
+ timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
27
+ maxBuffer: 1024 * 1024,
28
+ });
29
+ const value = JSON.parse(stdout);
30
+ if (!Array.isArray(value))
31
+ throw new Error("Cannot verify effective Codex MCP configuration");
32
+ return value;
33
+ }
34
+ catch {
28
35
  throw new Error("Cannot verify effective Codex MCP configuration");
29
- return value;
36
+ }
30
37
  }
31
- for (const server of await list(args)) {
38
+ const inherited = await list(args);
39
+ const usedNames = new Set(inherited.map((s) => s.name));
40
+ for (const server of inherited) {
32
41
  if (typeof server.name !== "string" ||
33
42
  !/^[a-zA-Z0-9_-]+$/.test(server.name))
34
43
  throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
35
44
  args.push("-c", `mcp_servers.${server.name}.enabled=false`);
36
45
  }
37
- if ((await list(args)).some((s) => s.enabled !== false))
38
- throw new Error("Inherited Codex MCP servers remain enabled; refusing launch");
46
+ const expected = [];
47
+ for (const server of selected) {
48
+ let name = server.name;
49
+ let suffix = 0;
50
+ // Config layers merge nested maps. A fresh key prevents inherited env,
51
+ // credentials or per-tool policy from surviving the requested definition.
52
+ while (usedNames.has(name))
53
+ name = `muster_${server.name}_${++suffix}`;
54
+ usedNames.add(name);
55
+ expected.push(name);
56
+ args.push("-c", `mcp_servers.${name}=${TOML.stringify.value(codexMcpConfig(server))}`);
57
+ }
58
+ const enabled = (await list(args))
59
+ .filter((s) => s.enabled !== false)
60
+ .map((s) => s.name)
61
+ .sort();
62
+ expected.sort();
63
+ if (JSON.stringify(enabled) !== JSON.stringify(expected))
64
+ throw new Error("Inherited Codex MCP servers remain enabled or requested servers are missing; refusing launch");
39
65
  return args;
40
66
  }
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import TOML from "@iarna/toml";
5
5
  import { z } from "zod";
6
+ import { mcpName, mcpServerSchema } from "./mcp.js";
6
7
  export const configSchema = z
7
8
  .object({
8
9
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
@@ -17,6 +18,8 @@ export const configSchema = z
17
18
  "danger-full-access",
18
19
  ])
19
20
  .default("read-only"),
21
+ default_mcp: z.array(mcpName).default([]),
22
+ mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
20
23
  allow_dangerous_flags: z.boolean().default(false),
21
24
  })
22
25
  .strict();
package/dist/format.js CHANGED
@@ -35,11 +35,17 @@ 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)));
41
43
  if (record.sandbox)
42
44
  lines.push(field("Sandbox", clean(record.sandbox)));
45
+ if (record.mcp)
46
+ lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
47
+ for (const warning of record.mcp_warnings ?? [])
48
+ lines.push(field("MCP warning", clean(warning)));
43
49
  if (record.host)
44
50
  lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
45
51
  if (record.terminal_opened)
package/dist/guard.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { resolve } from "node:path";
3
+ import { mcpName, claudeMcpConfig } from "./mcp.js";
3
4
  export const runSchema = z
4
5
  .object({
5
- runtime: z.enum(["codex", "claude"]),
6
+ runtime: z.enum(["codex", "claude", "opencode"]),
6
7
  prompt: z
7
8
  .string()
8
9
  .refine((s) => s.trim().length > 0, "prompt must not be blank"),
@@ -17,6 +18,7 @@ export const runSchema = z
17
18
  terminal: z.enum(["auto", "terminal", "iterm2", "ghostty"]).optional(),
18
19
  permissions: z.enum(["auto", "deny", "bypass"]).optional(),
19
20
  sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
21
+ mcp: z.array(mcpName).optional(),
20
22
  args: z.array(z.string()).default([]),
21
23
  })
22
24
  .strict();
@@ -35,12 +37,16 @@ export function resolvePermissions(req, config) {
35
37
  throw new Error("--permissions bypass requires --sandbox full-access");
36
38
  return { permissions, sandbox };
37
39
  }
38
- function extras(req) {
40
+ export function runtimeArgs(req) {
39
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"];
40
47
  for (let i = 0; i < req.args.length; i++) {
41
48
  const arg = req.args[i], flag = arg.split("=")[0].toLowerCase().replaceAll("_", "-");
42
- if (!["--model", "-m", "--effort"].includes(flag) ||
43
- (flag === "--effort" && req.runtime !== "claude"))
49
+ if (!allowed.includes(flag))
44
50
  throw new Error(`Runtime option refused: ${arg}`);
45
51
  if (arg.includes("="))
46
52
  result.push(arg);
@@ -53,8 +59,8 @@ function extras(req) {
53
59
  }
54
60
  return result;
55
61
  }
56
- export function launchArgs(req, config) {
57
- const extra = extras(req);
62
+ export function launchArgs(req, config, mcp = [], bridgePath) {
63
+ const extra = runtimeArgs(req);
58
64
  const policy = resolvePermissions(req, config);
59
65
  if (req.runtime === "codex")
60
66
  return [
@@ -89,7 +95,11 @@ export function launchArgs(req, config) {
89
95
  disableAllHooks: true,
90
96
  permissions: {
91
97
  defaultMode: mode,
92
- deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
98
+ allow: mcp.flatMap((s) => s.config.tools.map((t) => `mcp__${s.name}__${t}`)),
99
+ deny: [
100
+ ...(readonly ? ["Edit", "Write", "NotebookEdit"] : []),
101
+ ...mcp.flatMap((s) => s.excludedTools.map((t) => `mcp__${s.name}__${t}`)),
102
+ ],
93
103
  additionalDirectories: [],
94
104
  },
95
105
  sandbox: {
@@ -112,7 +122,7 @@ export function launchArgs(req, config) {
112
122
  "",
113
123
  "--strict-mcp-config",
114
124
  "--mcp-config",
115
- '{"mcpServers":{}}',
125
+ JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
116
126
  "--settings",
117
127
  JSON.stringify(settings),
118
128
  ...extra,
@@ -56,22 +56,22 @@ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "
56
56
  "Ghostty",
57
57
  "--args",
58
58
  "--quit-after-last-window-closed=true",
59
+ // A separate instance otherwise restores the user's saved tabs/windows.
60
+ "--window-save-state=never",
59
61
  "-e",
60
62
  tmux,
61
63
  "-L",
62
64
  server,
63
65
  "attach-session",
64
66
  "-t",
65
- "muster",
66
- ";",
67
- "select-window",
68
- "-t",
69
67
  hostRef,
70
68
  ], timeout);
71
69
  return;
72
70
  }
73
71
  const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
74
- const shellCommand = `${quoted} -L ${server} attach-session -t muster \\; select-window -t ${hostRef}`;
72
+ const shellCommand = `${quoted} -L ${server} attach-session -t ${hostRef}`;
73
+ // Terminal must create the requested window before activation. Activating
74
+ // a cold app first also creates its default shell window.
75
75
  await command("/usr/bin/osascript", [
76
76
  "-e",
77
77
  terminal === "iterm2"
@@ -83,8 +83,9 @@ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "
83
83
  end run`
84
84
  : `on run argv
85
85
  tell application "Terminal"
86
- activate
86
+ launch
87
87
  do script (item 1 of argv)
88
+ activate
88
89
  end tell
89
90
  end run`,
90
91
  shellCommand,
@@ -1,4 +1,5 @@
1
1
  import { mkdtemp, writeFile, rm } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -52,13 +53,9 @@ export class TmuxHost {
52
53
  file,
53
54
  ];
54
55
  try {
55
- let output;
56
- try {
57
- output = await this.call(["new-session", "-s", "muster", ...common], opts.deadline);
58
- }
59
- catch {
60
- output = await this.call(["new-window", "-t", "muster", ...common], opts.deadline);
61
- }
56
+ // Window selection is session-wide: sharing a session makes every
57
+ // attached terminal follow whichever agent was opened most recently.
58
+ const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
62
59
  const [hostRef, pid] = output.trim().split("\t");
63
60
  if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
64
61
  throw new Error("tmux returned an invalid window identity");
@@ -69,8 +66,7 @@ export class TmuxHost {
69
66
  try {
70
67
  const windows = await this.call([
71
68
  "list-windows",
72
- "-t",
73
- "muster",
69
+ "-a",
74
70
  "-F",
75
71
  "#{window_id}\t#{pane_start_command}",
76
72
  ]);
@@ -87,14 +83,16 @@ export class TmuxHost {
87
83
  try {
88
84
  return (await this.call([
89
85
  "list-windows",
90
- "-t",
91
- "muster",
86
+ "-a",
92
87
  "-F",
93
- "#{window_id}\t#{pane_pid}\t#{window_name}",
88
+ "#{window_id}\t#{pane_pid}\t#{window_name}\t#{session_name}",
94
89
  ]))
95
90
  .trim()
96
91
  .split("\n")
97
- .filter(Boolean)
92
+ .filter((l) => {
93
+ const session = l.split("\t")[3];
94
+ return (session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""));
95
+ })
98
96
  .map((l) => {
99
97
  const [hostRef, pid, label] = l.split("\t");
100
98
  return { hostRef: hostRef, pid: Number(pid), label: label };
@@ -117,6 +115,6 @@ export class TmuxHost {
117
115
  }
118
116
  }
119
117
  attachHint(hostRef) {
120
- return `tmux -L ${this.server} attach-session -t muster \\; select-window -t ${hostRef}`;
118
+ return `tmux -L ${this.server} attach-session -t ${hostRef}`;
121
119
  }
122
120
  }