@brutalsystems/muster 0.7.1 → 0.7.3

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.
@@ -79,6 +79,115 @@ the same codename remediation via a history rewrite (`git filter-repo
79
79
  whose content or ancestry it touched. This is a historical acquisition
80
80
  record, not a claim that the commit is still reachable.
81
81
 
82
+ ## Contract baseline moved to 0.6.0 — 2026-09-20
83
+
84
+ Tin Can 0.6.0 changes the endpoint it delivers to on the opencode leg, from the
85
+ v2 `/api/session/{id}/prompt` to the v1 `/session/{id}/prompt_async`, and the
86
+ change resolves the failure described in the 0.5.9 entry below. It is a minor
87
+ rather than a patch because `urgent` was removed as a capability: the v1 route
88
+ has no equivalent of v2's steer/queue distinction, so rather than accept it and
89
+ ignore it, Tin Can now reports urgent as unsupported on every runtime. Muster
90
+ never referenced that capability, so nothing here changes.
91
+
92
+ Muster's contract suite was run against a real installed
93
+ `@brutalsystems/tincan@0.6.0` and passed all four cases. Naming is untouched:
94
+ 0.6.0's `canonical-id.json` was compared case by case with Muster's frozen copy
95
+ and every pre-existing case is present with an unchanged expectation, with the
96
+ same four additive opencode-specific cases introduced in 0.5.7.
97
+
98
+ Verified end to end rather than by the suite alone: a Muster-launched OpenCode
99
+ session in a terminal received a Tin Can message, ran the turn, and replied to
100
+ the sending Claude Code session. The OpenCode log for that session shows a
101
+ started loop and a resolved model with no `Failed to drain` line, which is the
102
+ signature the 0.5.9 entry documents as the failure.
103
+
104
+ One operational trap worth recording, because it makes a working setup look
105
+ broken: the OpenCode plugin is a **copy on disk** under
106
+ `~/.config/opencode/plugin/`. Updating the npm package does not update it, and
107
+ a stale plugin keeps posting to the old v2 route. The peer registry records
108
+ `plugin_version`; confirm it matches the installed package before trusting a
109
+ delivery result.
110
+
111
+ ## Contract baseline moved to 0.5.9 — 2026-09-20
112
+
113
+ Re-pinned from `0.5.7`, skipping a separate `0.5.8` pass, because the `notice`
114
+ text moved twice in one afternoon while the underlying mechanism was being
115
+ isolated: 0.5.7 described it as never scheduled, 0.5.8 as a warmth problem,
116
+ 0.5.9 states what was actually observed. Both earlier descriptions were wrong.
117
+
118
+ Muster's contract suite was run against a real installed
119
+ `@brutalsystems/tincan@0.5.9` and passed all four cases.
120
+
121
+ 0.5.9 changes the `notice` text on `send_peer` results and widens it from idle
122
+ opencode peers to all opencode peers. It does not touch naming: 0.5.9's
123
+ `canonical-id.json` was compared case by case with Muster's frozen copy, and
124
+ every pre-existing case in `slugify`, `suffix`, `assign` and `resolve` is
125
+ present with an unchanged expectation, with the same four additive
126
+ opencode-specific cases 0.5.7 introduced.
127
+
128
+ The reason for the churn is worth recording, because Muster's own evidence
129
+ caused it. Muster reported that inbound Tin Can delivery to an idle
130
+ TUI-hosted opencode session was admitted but never scheduled, and 0.5.7's
131
+ notice encoded that mechanism. The opencode log disproves it: a session idle
132
+ since 20:00:13 was woken 68ms after admission at 20:01:13 and ran the drain,
133
+ which then failed with `ModelUnavailableError` on a local model. Fourteen of
134
+ the seventeen `Failed to drain` lines in that log name the same local model.
135
+ Admission does schedule execution, exactly as the endpoint documents; the
136
+ turn died afterwards. Muster's `opencode serve` control did not separate host
137
+ type from model resolution, because it used different credentials and failed on
138
+ an unrelated 401.
139
+
140
+ The mechanism was subsequently isolated, and it is neither scheduling nor model
141
+ warmth. In every TUI-hosted opencode session observed, the drain path failed to
142
+ resolve the session's model, while the TUI path resolved the same model in the
143
+ same process seconds either side of it:
144
+
145
+ 20:42:30 stream providerID=fireworks-ai modelID=...qwen3p8-max (TUI turn)
146
+ 20:42:54 ERROR Failed to drain Session
147
+ ModelUnavailableError: fireworks-ai/...qwen3p8-max (delivered)
148
+
149
+ That run was stock opencode 1.18.31 with no Muster involvement, no overlay, and
150
+ a globally authenticated provider, so it is not a Muster defect and not
151
+ specific to Muster-supplied providers.
152
+
153
+ Observed across 14 distinct TUI-hosted sessions and 20 failures, under two
154
+ unrelated providers (`muster-local` and `fireworks-ai`). No TUI-hosted drain
155
+ was observed to succeed. The contrasting case is an `opencode serve` session,
156
+ whose drain resolved its model and reached the provider, failing only on an
157
+ unrelated HTTP 401 — the model resolution itself worked there. That is what
158
+ localises this to TUI-hosted sessions. It is a consistent observation on
159
+ 1.18.31, not a proof about every configuration.
160
+
161
+ What remains true for callers is narrower: a caller cannot confirm that an
162
+ opencode peer acted on a message, because a turn that dies after admission is
163
+ indistinguishable from outside from one that succeeded.
164
+
165
+ ## Contract baseline moved to 0.5.7 — 2026-09-20
166
+
167
+ Re-pinned from `0.5.2` to `@brutalsystems/tincan@0.5.7`, deliberately rather
168
+ than by drift: 0.5.2 was three releases behind and no longer what anyone runs.
169
+
170
+ Muster's contract suite was run against a real installed
171
+ `@brutalsystems/tincan@0.5.7` and passed all four cases.
172
+
173
+ The intervening releases are additive on this contract. 0.5.6 added a `notes`
174
+ array to the `peers` result and changed tool description text; 0.5.7 added a
175
+ `notice` field on `send_peer` results for idle opencode peers, reporting that a
176
+ message is durably admitted but that execution cannot be confirmed on that leg.
177
+ Neither touches naming.
178
+
179
+ Verified against the artifact rather than assumed: 0.5.7's
180
+ `test/fixtures/canonical-id.json` was compared case by case with Muster's
181
+ frozen copy. Every pre-existing case in `slugify`, `suffix`, `assign` and
182
+ `resolve` is present with an unchanged expectation. 0.5.7 adds four cases, all
183
+ opencode-specific — one `suffix` case for opencode session ids, three `assign`
184
+ cases for opencode peers and slug collisions between them. Nothing was modified
185
+ or removed.
186
+
187
+ Muster's own fixture stays frozen at `tincan_version: 0.1.0` with its 31 cases.
188
+ That is the point of the design: frozen expectations are checked against a live
189
+ installed binary, so the suite detects drift rather than absorbing it.
190
+
82
191
  ## Contract baseline moved to 0.5.2 — 2026-09-20
83
192
 
84
193
  `@brutalsystems/tincan@0.2.0`, this document's verified baseline, was
@@ -112,8 +221,8 @@ withdrawn npm artifact actually contained.
112
221
  The upstream release notes report that 0.3.0 expands
113
222
  Codex-hosted listings to both runtimes and 0.3.1 corrects Codex status decoding.
114
223
  These releases have not been verified by Muster's contract suite. The tested
115
- baseline remains exactly 0.2.0; README installation instructions pin it outside
116
- Muster's dependency tree.
224
+ baseline is 0.5.7; README installation instructions pin it outside Muster's
225
+ dependency tree.
117
226
 
118
227
  Inspection confirms the contract test does not assert total peer counts,
119
228
  same-runtime exclusion, or Tin Can state. Its count assertion applies only to
package/README.md CHANGED
@@ -12,6 +12,29 @@ loopback API and local-provider protocol checks. Claude requires a directory
12
12
  already trusted by the operator. The automated suite uses fake runtimes, not
13
13
  real models.
14
14
 
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install -g @brutalsystems/muster
19
+ muster --version
20
+ ```
21
+
22
+ Publishing a release does not touch an installed copy: `muster --version` keeps
23
+ reporting the old version until you update it.
24
+
25
+ ```sh
26
+ npm update -g @brutalsystems/muster
27
+ ```
28
+
29
+ If `which muster` resolves to a version-manager shim (for example
30
+ `~/.asdf/shims/muster`), run that update under the Node the shim resolves to and
31
+ reshim afterwards — `asdf reshim nodejs` — or the shim keeps pointing at the old
32
+ binary.
33
+
34
+ An MCP server starts once at session startup, so an agent session that already
35
+ has Muster loaded keeps running the old binary until that session restarts.
36
+ Updating on disk is not enough; restart the session too.
37
+
15
38
  ## Build and run locally
16
39
 
17
40
  Requires Node 22.12+ (tested with 24.16), TypeScript 5, Codex 0.155.1, Claude
@@ -231,8 +254,14 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
231
254
  Claude permissions and its command sandbox are different mechanisms; neither
232
255
  claim implies that every external tool is OS-sandboxed.
233
256
 
234
- OpenCode keeps normal provider/model configuration but launches with `--pure`,
235
- which disables inherited external plugins. Before launch, Muster inspects the
257
+ OpenCode keeps normal provider/model configuration and launches with `--pure`
258
+ unless plugins are selected, which disables inherited external plugins.
259
+ Providers declared under `[opencode.provider]` and a model named by
260
+ `[opencode] model` or `--model` are supplied in the same verified overlay, so a
261
+ launch does not depend on the operator's own OpenCode configuration; provider
262
+ definitions merge with any the child resolves for itself rather than replacing
263
+ them. Secrets belong in `api_key_env_var`, read from the launching environment,
264
+ not in `config.toml`. Before launch, Muster inspects the
236
265
  effective OpenCode configuration, disables every inherited MCP server and tool
237
266
  family, enables only the selected Muster definitions, and fails closed if a
238
267
  higher-precedence managed policy prevents that isolation from being proven.
@@ -240,8 +269,10 @@ higher-precedence managed policy prevents that isolation from being proven.
240
269
  These child restrictions prevent automatically propagating spawn authority.
241
270
  External tools are enabled only through the operator configuration and launch
242
271
  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.
272
+ from an external Tin Can even when they have no MCP tools of their own. An
273
+ OpenCode session is reachable as a Tin Can peer only when the Tin Can plugin is
274
+ selected with `--plugin`; the plugin, not the MCP server, is what advertises the
275
+ session to other runtimes.
245
276
  No user-level runtime configuration is rewritten, and workspace-trust dialogs
246
277
  are never accepted automatically.
247
278
 
@@ -308,8 +339,18 @@ server has the same name, and its native tool allowlist. Claude receives a priva
308
339
  Muster stdio connection that filters both tool discovery and calls; newly added
309
340
  upstream tools cannot bypass the selection. Only selected tools are preapproved.
310
341
  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.
342
+ per-launch overlay, then enables only selected definitions. A launch that
343
+ selects MCP servers omits the wildcard `*` permission entry and names the
344
+ built-in tools it denies instead: in OpenCode a `*` entry suppresses MCP tools
345
+ outright, and an explicit allow for the tool does not restore them. The
346
+ built-in list is fixed, so a tool added by a future OpenCode release would not
347
+ be denied by it; launches that select no MCP server keep the wildcard. With no plugins
348
+ selected it also uses `--pure` and pins an empty plugin list, so inherited
349
+ plugins do not enter the child process. Selecting a plugin with `--plugin`
350
+ necessarily relaxes that: OpenCode merges project-local `.opencode/plugin`
351
+ discovery into any non-empty plugin list, so the child also loads whatever
352
+ plugins the target repository ships. Select plugins only for repositories
353
+ trusted with that.
313
354
  Claude connection specifications are stored with owner-only permissions under
314
355
  `~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
315
356
  session. MCP server access is separate from filesystem sandbox permissions: a
@@ -372,18 +413,18 @@ The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
372
413
  real file locks in the fake Codex executable. It uses isolated runtime homes and
373
414
  no model APIs. It skips the four explicitly invoked compatibility cases.
374
415
 
375
- The verified contract baseline is **Tin Can 0.2.0**. The suite spawns an installed binary as an MCP
416
+ The verified contract baseline is **Tin Can 0.6.0**. The suite spawns an installed binary as an MCP
376
417
  subprocess, compares durable IDs, and verifies delivery to fake runtimes through
377
418
  both terminal hosts. A missing binary or missing durable field fails the test.
378
419
  For reproducible verification, install that exact release outside Muster:
379
420
 
380
421
  ```sh
381
422
  contract_dir=$(mktemp -d)
382
- npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.5.2
423
+ npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.6.0
383
424
  MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
384
425
  ```
385
426
 
386
- Verification uses the published 0.2.0 release installed in an isolated
427
+ Verification uses the published 0.6.0 release installed in an isolated
387
428
  temporary directory. No Tin Can source edits are made.
388
429
  Fixture version metadata alone does not indicate address-format drift: compare
389
430
  the case arrays. Those arrays cover naming only, not peer-list membership or
@@ -403,6 +444,23 @@ real models.
403
444
  MIT © 2026 Mike Williams. See [LICENSE](./LICENSE). Vendored Tin Can code
404
445
  retains source attribution and its [MIT notice](./TINCAN_LICENSE).
405
446
 
447
+ Publishing is tag-driven and runs in GitHub Actions over OIDC trusted
448
+ publishing, with no stored npm token. A bare `git push` publishes nothing; a
449
+ version tag is what triggers `publish.yml`. That workflow validates a [RELEASE_NOTES.md](./RELEASE_NOTES.md) section for
450
+ the version being published, so write the notes under `## Unreleased` as you do
451
+ the work and commit them normally. Cutting the release is then one command:
452
+
453
+ ```sh
454
+ git commit -am "<the change, including its notes under ## Unreleased>"
455
+ npm version patch -m "%s — <what changed>"
456
+ ```
457
+
458
+ `npm version` runs the `version` hook, which stamps `## Unreleased` into
459
+ `## <version> — <date>` and stages it, then bumps, commits and tags; the
460
+ `postversion` hook pushes the commit and tag together. If there is no
461
+ `## Unreleased` section, or it is empty, the stamp refuses — before a tag
462
+ exists, rather than in CI after one has been pushed.
463
+
406
464
  [RELEASING.md](./RELEASING.md) covers versioning, package inspection, publication,
407
- and release notes. Changes to the shared address format require an explicit
465
+ and release notes in full. Changes to the shared address format require an explicit
408
466
  contract update; Muster never independently fixes the frozen naming behavior.
package/dist/config.js CHANGED
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import TOML from "@iarna/toml";
5
5
  import { z } from "zod";
6
6
  import { mcpName, mcpServerSchema } from "./mcp.js";
7
+ import { pluginName, pluginSchema } from "./plugins.js";
8
+ import { openCodeSectionSchema } from "./providers.js";
7
9
  export const configSchema = z
8
10
  .object({
9
11
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
@@ -20,6 +22,9 @@ export const configSchema = z
20
22
  .default("read-only"),
21
23
  default_mcp: z.array(mcpName).default([]),
22
24
  mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
25
+ default_plugins: z.array(pluginName).default([]),
26
+ plugins: z.record(pluginName, pluginSchema).default({}),
27
+ opencode: openCodeSectionSchema,
23
28
  allow_dangerous_flags: z.boolean().default(false),
24
29
  })
25
30
  .strict();
package/dist/guard.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { resolve } from "node:path";
3
3
  import { mcpName, claudeMcpConfig } from "./mcp.js";
4
+ import { pluginName } from "./plugins.js";
4
5
  export const runSchema = z
5
6
  .object({
6
7
  runtime: z.enum(["codex", "claude", "opencode"]),
@@ -19,6 +20,8 @@ export const runSchema = z
19
20
  permissions: z.enum(["auto", "deny", "bypass"]).optional(),
20
21
  sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
21
22
  mcp: z.array(mcpName).optional(),
23
+ plugin: z.array(pluginName).optional(),
24
+ model: z.string().min(1).optional(),
22
25
  args: z.array(z.string()).default([]),
23
26
  })
24
27
  .strict();
package/dist/muster.js CHANGED
@@ -28,6 +28,12 @@ and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
28
28
  terminal, iterm2, or ghostty, and requires --open.
29
29
  --mcp NAME is repeatable and replaces configured session defaults; --no-mcp
30
30
  clears them. Tasks use no MCP defaults; select servers explicitly.
31
+ --plugin NAME is repeatable and replaces configured session defaults;
32
+ --no-plugin clears them. OpenCode only: selected plugins load in the child,
33
+ which also admits any plugin the target repository ships.
34
+ --model PROVIDER/MODEL selects the OpenCode model, overriding the configured
35
+ [opencode] model. Providers defined under [opencode.provider] are supplied to
36
+ the child, so launches do not depend on the user's own OpenCode config.
31
37
  run, list and stop default to JSON. output prints raw task text.
32
38
 
33
39
  pty launches print their record and keep this process running. Ctrl-C stops the
@@ -199,15 +205,24 @@ async function main() {
199
205
  sandbox: { type: "string" },
200
206
  mcp: { type: "string", multiple: true },
201
207
  "no-mcp": { type: "boolean" },
208
+ plugin: { type: "string", multiple: true },
209
+ model: { type: "string" },
210
+ "no-plugin": { type: "boolean" },
202
211
  },
203
212
  });
204
- const { format = "json", "no-mcp": noMcp, ...values } = parsed;
213
+ const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, ...values } = parsed;
205
214
  if (noMcp !== undefined && command !== "run")
206
215
  throw new Error("--no-mcp applies only to run");
207
216
  if (noMcp && values.mcp)
208
217
  throw new Error("--mcp and --no-mcp cannot be combined");
209
218
  if (noMcp)
210
219
  values.mcp = [];
220
+ if (noPlugin !== undefined && command !== "run")
221
+ throw new Error("--no-plugin applies only to run");
222
+ if (noPlugin && values.plugin)
223
+ throw new Error("--plugin and --no-plugin cannot be combined");
224
+ if (noPlugin)
225
+ values.plugin = [];
211
226
  if (format !== "json" && format !== "human")
212
227
  throw new Error("format must be json or human");
213
228
  if (command === "output" && parsed.format !== undefined)
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { resolvePermissions, runtimeArgs } from "./guard.js";
4
+ import { openCodeModel, openCodeProviders } from "./providers.js";
4
5
  const exec = promisify(execFile);
5
6
  const DEBUG_CONFIG_ERROR = "Cannot verify effective OpenCode MCP configuration";
6
7
  const ISOLATION_ERROR = "OpenCode MCP isolation could not be verified; refusing launch";
@@ -96,15 +97,48 @@ function mcpPolicy(selected, inheritedMcpNames) {
96
97
  }
97
98
  return { effective, mcp, tools };
98
99
  }
100
+ /**
101
+ * OpenCode's built-in tool ids, as reported by its own tool listing. Needed
102
+ * because a wildcard `*` permission entry suppresses MCP tools outright — an
103
+ * explicit allow for the tool does not rescue it — so a launch that selects
104
+ * MCP servers has to name the built-ins it denies instead of leaning on `*`.
105
+ * A built-in added by a future OpenCode release is not in this list and would
106
+ * not be denied by it; see the isolation note in the README.
107
+ */
108
+ const OPENCODE_BUILTIN_TOOLS = [
109
+ "invalid",
110
+ "question",
111
+ "bash",
112
+ "read",
113
+ "glob",
114
+ "grep",
115
+ "edit",
116
+ "write",
117
+ "task",
118
+ "webfetch",
119
+ "todowrite",
120
+ "todoread",
121
+ "websearch",
122
+ "skill",
123
+ "apply_patch",
124
+ "patch",
125
+ "list",
126
+ "lsp",
127
+ "external_directory",
128
+ ];
99
129
  function permissions(req, config, tools) {
100
130
  const policy = resolvePermissions(req, config);
101
- const permission = {
102
- "*": policy.permissions === "bypass"
103
- ? "allow"
104
- : policy.permissions === "auto"
105
- ? "ask"
106
- : "deny",
107
- };
131
+ // A selected MCP server cannot coexist with a wildcard entry, so the
132
+ // built-ins are enumerated instead. Without one, `*` still backstops.
133
+ const selectsMcp = Object.values(tools).some((enabled) => enabled);
134
+ const fallback = policy.permissions === "bypass"
135
+ ? "allow"
136
+ : policy.permissions === "auto"
137
+ ? "ask"
138
+ : "deny";
139
+ const permission = selectsMcp && policy.permissions !== "bypass"
140
+ ? Object.fromEntries(OPENCODE_BUILTIN_TOOLS.map((name) => [name, fallback]))
141
+ : { "*": fallback };
108
142
  if (policy.permissions !== "bypass") {
109
143
  for (const name of [
110
144
  "read",
@@ -137,14 +171,27 @@ function permissions(req, config, tools) {
137
171
  permission[pattern] = enabled ? "allow" : "deny";
138
172
  return permission;
139
173
  }
140
- export function openCodeConfig(req, config, selected, inheritedMcpNames) {
174
+ export function openCodeConfig(req, config, selected, inheritedMcpNames, plugins = [], source = {}) {
141
175
  const { mcp, tools } = mcpPolicy(selected, inheritedMcpNames);
176
+ const model = openCodeModel(req.model, config);
177
+ const provider = openCodeProviders(config, source);
142
178
  return {
143
179
  share: "disabled",
180
+ // Providers merge with whatever the child resolves for itself, so naming
181
+ // one here adds a definition rather than displacing the operator's.
182
+ ...(Object.keys(provider).length > 0 && { provider }),
183
+ ...(model !== undefined && { model }),
144
184
  server: { hostname: "127.0.0.1" },
145
185
  permission: permissions(req, config, tools),
146
186
  tools,
147
187
  mcp,
188
+ // Absent, OpenCode discovers plugins from the user and project plugin
189
+ // directories; `--pure` is what suppresses them. A non-empty array names
190
+ // the operator's selection but does NOT displace project-local discovery,
191
+ // so selecting any plugin also admits whatever the target repository
192
+ // ships under .opencode/plugin. An empty array is the isolated case and
193
+ // is paired with `--pure` at launch.
194
+ plugin: plugins.map((plugin) => plugin.url),
148
195
  };
149
196
  }
150
197
  function mergeRecords(base, override) {
@@ -290,18 +337,18 @@ export async function openCodeMcpNames(env, cwd, deadline) {
290
337
  throw new Error(DEBUG_CONFIG_ERROR);
291
338
  return Object.keys(config.mcp);
292
339
  }
293
- export async function openCodeSessionLaunch(req, config, selected, inheritedMcpNames, port, env, cwd, deadline) {
340
+ export async function openCodeSessionLaunch(req, config, selected, inheritedMcpNames, port, env, cwd, deadline, plugins = [], source = {}) {
294
341
  const extra = runtimeArgs(req);
295
342
  const policy = resolvePermissions(req, config);
296
343
  const overlay = {
297
- ...openCodeConfig(req, config, selected, inheritedMcpNames),
344
+ ...openCodeConfig(req, config, selected, inheritedMcpNames, plugins, source),
298
345
  server: { hostname: "127.0.0.1", port },
299
346
  };
300
347
  const patch = await verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline);
301
348
  return {
302
349
  argv: [
303
350
  "opencode",
304
- "--pure",
351
+ ...(plugins.length === 0 ? ["--pure"] : []),
305
352
  "--hostname",
306
353
  "127.0.0.1",
307
354
  "--port",
@@ -316,10 +363,10 @@ export async function openCodeSessionLaunch(req, config, selected, inheritedMcpN
316
363
  serverUrl: `http://127.0.0.1:${port}`,
317
364
  };
318
365
  }
319
- export async function openCodeTaskLaunch(req, config, selected, inheritedMcpNames, env, cwd, deadline) {
366
+ export async function openCodeTaskLaunch(req, config, selected, inheritedMcpNames, env, cwd, deadline, plugins = [], source = {}) {
320
367
  const extra = runtimeArgs(req);
321
368
  const policy = resolvePermissions(req, config);
322
- const overlay = openCodeConfig(req, config, selected, inheritedMcpNames);
369
+ const overlay = openCodeConfig(req, config, selected, inheritedMcpNames, plugins, source);
323
370
  const patch = await verifyOverlay(overlay, selected, inheritedMcpNames, env, cwd, deadline);
324
371
  return {
325
372
  argv: [
@@ -327,7 +374,7 @@ export async function openCodeTaskLaunch(req, config, selected, inheritedMcpName
327
374
  "run",
328
375
  "--format",
329
376
  "json",
330
- "--pure",
377
+ ...(plugins.length === 0 ? ["--pure"] : []),
331
378
  ...(policy.permissions === "auto" ? ["--auto"] : []),
332
379
  ...extra,
333
380
  "--",
@@ -0,0 +1,34 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { z } from "zod";
5
+ export const pluginName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
6
+ export const pluginSchema = z
7
+ .object({
8
+ path: z.string().min(1),
9
+ })
10
+ .strict();
11
+ /**
12
+ * OpenCode wants plugins as file URLs. Operator definitions are written as
13
+ * ordinary paths, so `~` and relative paths resolve here rather than in the
14
+ * launch path, where a wrong answer would be harder to see.
15
+ */
16
+ function pluginUrl(path) {
17
+ const expanded = path.startsWith("~/")
18
+ ? join(homedir(), path.slice(2))
19
+ : path;
20
+ return pathToFileURL(resolve(expanded)).href;
21
+ }
22
+ export function selectPlugins(req, config) {
23
+ // Personal defaults apply to sessions, not unattended one-shot tasks —
24
+ // the same rule selectMcp uses.
25
+ const names = req.plugin ?? (req.kind === "session" ? config.default_plugins : []);
26
+ return [...new Set(names)].map((name) => {
27
+ const plugin = Object.hasOwn(config.plugins, name)
28
+ ? config.plugins[name]
29
+ : undefined;
30
+ if (!plugin)
31
+ throw new Error(`Unknown plugin: ${name}`);
32
+ return { name, url: pluginUrl(plugin.path) };
33
+ });
34
+ }
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ export const providerName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
3
+ const environmentName = z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
4
+ const modelId = z.string().min(1);
5
+ /**
6
+ * Deliberately thin. OpenCode's provider schema is far larger; Muster carries
7
+ * only what a launch needs to stand on its own, so the operator does not have
8
+ * to keep a matching definition in ~/.config/opencode. Per-model options are
9
+ * passed through verbatim because model capability flags (tool_call, limit)
10
+ * decide whether an agent can use tools at all.
11
+ */
12
+ export const providerSchema = z
13
+ .object({
14
+ npm: z.string().min(1).optional(),
15
+ name: z.string().min(1).optional(),
16
+ base_url: z.string().url().optional(),
17
+ api_key: z.string().min(1).optional(),
18
+ api_key_env_var: environmentName.optional(),
19
+ models: z
20
+ .union([
21
+ z.array(modelId),
22
+ z.record(modelId, z.record(z.string(), z.unknown())),
23
+ ])
24
+ .default({}),
25
+ })
26
+ .strict()
27
+ .refine((p) => !(p.api_key !== undefined && p.api_key_env_var !== undefined), "api_key and api_key_env_var cannot be combined");
28
+ export const openCodeSectionSchema = z
29
+ .object({
30
+ model: z.string().min(1).optional(),
31
+ provider: z.record(providerName, providerSchema).default({}),
32
+ })
33
+ .strict()
34
+ .default({});
35
+ function models(definition) {
36
+ return Array.isArray(definition.models)
37
+ ? Object.fromEntries(definition.models.map((id) => [id, {}]))
38
+ : definition.models;
39
+ }
40
+ function apiKey(name, definition, source) {
41
+ if (definition.api_key !== undefined)
42
+ return definition.api_key;
43
+ if (definition.api_key_env_var === undefined)
44
+ return undefined;
45
+ const value = source[definition.api_key_env_var];
46
+ // Failing here beats launching a session whose every turn dies on a 401.
47
+ if (value === undefined || value === "")
48
+ throw new Error(`Provider ${name} needs ${definition.api_key_env_var} in the environment`);
49
+ return value;
50
+ }
51
+ /**
52
+ * The provider/model half of the OpenCode overlay. Providers merge with any
53
+ * the child resolves for itself, so these definitions add to the operator's
54
+ * own configuration rather than replacing it.
55
+ */
56
+ export function openCodeProviders(config, source) {
57
+ const entries = Object.entries(config.opencode.provider);
58
+ if (entries.length === 0)
59
+ return {};
60
+ return Object.fromEntries(entries.map(([name, definition]) => {
61
+ const key = apiKey(name, definition, source);
62
+ const options = {};
63
+ if (definition.base_url !== undefined)
64
+ options.baseURL = definition.base_url;
65
+ if (key !== undefined)
66
+ options.apiKey = key;
67
+ return [
68
+ name,
69
+ {
70
+ ...(definition.npm !== undefined && { npm: definition.npm }),
71
+ ...(definition.name !== undefined && { name: definition.name }),
72
+ ...(Object.keys(options).length > 0 && { options }),
73
+ models: models(definition),
74
+ },
75
+ ];
76
+ }));
77
+ }
78
+ /** `--model` beats the configured default; OpenCode decides when neither is set. */
79
+ export function openCodeModel(requested, config) {
80
+ return requested ?? config.opencode.model;
81
+ }
package/dist/run.js CHANGED
@@ -22,6 +22,7 @@ import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
22
22
  import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
23
23
  import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
24
24
  import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
25
+ import { selectPlugins } from "./plugins.js";
25
26
  export class Muster {
26
27
  config;
27
28
  home;
@@ -68,6 +69,7 @@ export class Muster {
68
69
  throw new Error("--open requires a tmux session on macOS");
69
70
  const permissions = resolvePermissions(req, this.config);
70
71
  const selectedMcp = selectMcp(req, this.config);
72
+ const selectedPlugins = req.runtime === "opencode" ? selectPlugins(req, this.config) : [];
71
73
  let argv = [];
72
74
  if (req.runtime === "opencode")
73
75
  runtimeArgs(req);
@@ -103,6 +105,9 @@ export class Muster {
103
105
  ...(req.open ? { open: true, terminal } : {}),
104
106
  args: req.args,
105
107
  mcp: selectedMcp.map((s) => s.name),
108
+ ...(selectedPlugins.length
109
+ ? { plugins: selectedPlugins.map((p) => p.name) }
110
+ : {}),
106
111
  ...permissions,
107
112
  });
108
113
  const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
@@ -127,7 +132,7 @@ export class Muster {
127
132
  else if (req.runtime === "opencode" && req.kind === "session") {
128
133
  const port = await allocateLoopbackPort();
129
134
  const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
130
- const launch = await openCodeSessionLaunch(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline);
135
+ const launch = await openCodeSessionLaunch(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
131
136
  argv = launch.argv;
132
137
  runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
133
138
  entry = await this.registry.update(entry.launchId, {
@@ -137,7 +142,7 @@ export class Muster {
137
142
  }
138
143
  else if (req.runtime === "opencode" && req.kind === "task") {
139
144
  const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
140
- const launch = await openCodeTaskLaunch(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline);
145
+ const launch = await openCodeTaskLaunch(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
141
146
  argv = launch.argv;
142
147
  runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
143
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,7 +18,9 @@
18
18
  "pretest:contract": "npm run build",
19
19
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
20
20
  "prepack": "npm run build",
21
- "prepublishOnly": "npm test"
21
+ "prepublishOnly": "npm test",
22
+ "version": "node scripts/stamp-release-notes.mjs && git add RELEASE_NOTES.md",
23
+ "postversion": "git push origin HEAD --follow-tags"
22
24
  },
23
25
  "dependencies": {
24
26
  "@iarna/toml": "^2.2.5",