@brutalsystems/muster 0.7.19 → 0.7.20

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.
package/README.md CHANGED
@@ -105,7 +105,10 @@ piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
105
105
 
106
106
  `run` accepts an optional `--` followed by runtime arguments. The normal
107
107
  allowlist is `--model` / `-m`, plus Claude's `--effort` and OpenCode's `--agent`
108
- and `--variant`. Unknown options, bundled short options, config injection and
108
+ and `--variant`. For Claude and Codex a model named there is *consumed* rather
109
+ than forwarded — it reaches the child through the same path as `--model`, so it
110
+ appears once — and naming a model both ways is refused rather than resolved by
111
+ precedence. Unknown options, bundled short options, config injection and
109
112
  raw permission overrides are refused, even with `allow_dangerous_flags`
110
113
  enabled. Use Muster's normalized permission flags. Prompt strings are always
111
114
  passed as one literal argument. Codex and Claude launches and OpenCode tasks put
@@ -396,7 +399,9 @@ claim implies that every external tool is OS-sandboxed.
396
399
  OpenCode keeps normal provider/model configuration and launches with `--pure`
397
400
  unless plugins are selected, which disables inherited external plugins.
398
401
  Providers declared under `[opencode.provider]` and a model named by
399
- `[opencode] model` or `--model` are supplied in the same verified overlay, so a
402
+ `[opencode] model` or `--model` are supplied in the same verified overlay (a
403
+ model named after `--` instead rides the child's argv and is left out of the
404
+ overlay, so it is delivered once rather than twice), so a
400
405
  launch does not depend on the operator's own OpenCode configuration; provider
401
406
  definitions merge with any the child resolves for itself rather than replacing
402
407
  them. Secrets belong in `api_key_env_var`, read from the launching environment,
package/dist/format.js CHANGED
@@ -38,10 +38,16 @@ function recordHuman(record, color) {
38
38
  if (record.runtime === "opencode" && record.server_url)
39
39
  lines.push(field("Control URL", clean(record.server_url)));
40
40
  lines.push(field("Directory", clean(record.cwd)));
41
+ if (record.identity)
42
+ lines.push(field("Identity", clean(record.identity)));
41
43
  if (record.permissions)
42
44
  lines.push(field("Permissions", clean(record.permissions)));
43
45
  if (record.sandbox)
44
46
  lines.push(field("Sandbox", clean(record.sandbox)));
47
+ // Only when known: a null means the child chose its own model, and "-" in a
48
+ // human listing would read as a value rather than an absence.
49
+ if (record.model)
50
+ lines.push(field("Model", clean(record.model)));
45
51
  if (record.mcp)
46
52
  lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
47
53
  for (const warning of record.mcp_warnings ?? [])
package/dist/guard.js CHANGED
@@ -156,30 +156,121 @@ export function paramsFingerprint(req, permissions, mcp, plugins) {
156
156
  ]))
157
157
  .digest("hex");
158
158
  }
159
+ const MODEL_FLAGS = ["--model", "-m"];
160
+ /**
161
+ * The flag half of any pass-through arg, lowercased with `_` mapped to `-`.
162
+ * Extracted from `runtimeArgs`, which already normalised this way inline, and
163
+ * now shared with the resolver so the two cannot disagree about whether a
164
+ * given spelling names a model: one deciding a model was named while the other
165
+ * forwards it is exactly how a model reaches the child's argv twice.
166
+ */
167
+ export function normaliseFlag(arg) {
168
+ return arg.split("=")[0].toLowerCase().replaceAll("_", "-");
169
+ }
170
+ /** A model named inside the runtime pass-through, if one is there. */
171
+ function modelInArgs(args) {
172
+ const found = [];
173
+ for (let i = 0; i < args.length; i++) {
174
+ const arg = args[i];
175
+ if (!MODEL_FLAGS.includes(normaliseFlag(arg)))
176
+ continue;
177
+ if (arg.includes("=")) {
178
+ const value = arg.slice(arg.indexOf("=") + 1);
179
+ // `--model=` names a model and supplies none. Accepting it would deliver
180
+ // nothing and record `model: null` — "the child chose" — which is the
181
+ // accepted-and-silently-ignored behaviour this feature exists to remove.
182
+ if (!value)
183
+ throw new Error(`Missing value for ${arg}`);
184
+ found.push(value);
185
+ continue;
186
+ }
187
+ const value = args[i + 1];
188
+ // A bare flag with no usable value is left alone: `runtimeArgs` raises
189
+ // "Missing value for --model" on the same input and words it well.
190
+ if (value && !value.startsWith("-"))
191
+ found.push(value);
192
+ }
193
+ // Taking the first would let OpenCode forward both to a child whose last flag
194
+ // wins, leaving the run record naming a model the child did not run.
195
+ if (found.length > 1)
196
+ throw new Error(`Model named twice in runtime options: ${found.join(" and ")}. Name it once.`);
197
+ return found[0];
198
+ }
199
+ /**
200
+ * The one place a launch's model is decided. `launchArgs`, the registry write
201
+ * and the OpenCode overlay all call this, so what is recorded on the run and
202
+ * what is handed to the child are one computation rather than two that agree
203
+ * by luck — which is the whole point of recording it at all.
204
+ */
205
+ export function resolveModel(req, config) {
206
+ const inArgs = modelInArgs(req.args);
207
+ if (req.model !== undefined && inArgs !== undefined)
208
+ throw new Error(`Model named twice: --model ${req.model} and ${inArgs} in runtime options. Name it once.`);
209
+ const named = req.model ?? inArgs;
210
+ if (named !== undefined) {
211
+ // OpenCode's native form IS provider/model; claude and codex take a bare
212
+ // id, and a slash there is a mistake worth catching at this boundary
213
+ // rather than in the child's argument parser.
214
+ if (req.runtime !== "opencode" && named.includes("/"))
215
+ throw new Error(`--model for ${req.runtime} takes a bare model id, not PROVIDER/MODEL (got ${named})`);
216
+ return {
217
+ model: named,
218
+ source: "request",
219
+ origin: req.model !== undefined ? "flag" : "args",
220
+ };
221
+ }
222
+ const configured = req.runtime === "opencode" ? config.opencode.model : undefined;
223
+ return configured === undefined
224
+ ? null
225
+ : { model: configured, source: "config", origin: "config" };
226
+ }
227
+ /**
228
+ * Per runtime: which pass-through options ride to the child, and which are
229
+ * recognised but delivered by `resolveModel` instead. A consumed flag must not
230
+ * be refused — naming a model in `args` is still a supported way to ask, and
231
+ * it is the only way that worked before `--model` reached these runtimes.
232
+ * OpenCode is deliberately absent from `consumed`: it already forwards a model
233
+ * from `args` onto its argv and `test/opencode-policy.test.ts` pins that.
234
+ */
235
+ const RUNTIME_OPTIONS = {
236
+ claude: { forwarded: ["--effort"], consumed: MODEL_FLAGS },
237
+ codex: { forwarded: [], consumed: MODEL_FLAGS },
238
+ opencode: {
239
+ forwarded: ["--model", "-m", "--agent", "--variant"],
240
+ consumed: [],
241
+ },
242
+ };
159
243
  export function runtimeArgs(req) {
160
244
  const result = [];
161
- const allowed = req.runtime === "claude"
162
- ? ["--model", "-m", "--effort"]
163
- : req.runtime === "opencode"
164
- ? ["--model", "-m", "--agent", "--variant"]
165
- : ["--model", "-m"];
245
+ const { forwarded, consumed } = RUNTIME_OPTIONS[req.runtime];
166
246
  for (let i = 0; i < req.args.length; i++) {
167
- const arg = req.args[i], flag = arg.split("=")[0].toLowerCase().replaceAll("_", "-");
168
- if (!allowed.includes(flag))
247
+ const arg = req.args[i], flag = normaliseFlag(arg);
248
+ const keep = forwarded.includes(flag);
249
+ if (!keep && !consumed.includes(flag))
169
250
  throw new Error(`Runtime option refused: ${arg}`);
170
- if (arg.includes("="))
171
- result.push(arg);
251
+ if (arg.includes("=")) {
252
+ if (keep)
253
+ result.push(arg);
254
+ }
172
255
  else {
256
+ // The value is stepped over for a consumed flag exactly as for a
257
+ // forwarded one, so a missing value is still an error and the next
258
+ // option is not mistaken for this one's value.
173
259
  const value = req.args[++i];
174
260
  if (!value || value.startsWith("-"))
175
261
  throw new Error(`Missing value for ${arg}`);
176
- result.push(arg, value);
262
+ if (keep)
263
+ result.push(arg, value);
177
264
  }
178
265
  }
179
266
  return result;
180
267
  }
181
268
  export function launchArgs(req, config, mcp = [], bridgePath) {
182
269
  const extra = runtimeArgs(req);
270
+ const resolved = resolveModel(req, config);
271
+ // OpenCode never reaches here — `run.ts` branches it to the OpenCode launch
272
+ // builders, which carry the model in their config overlay.
273
+ const model = resolved ? ["--model", resolved.model] : [];
183
274
  const policy = resolvePermissions(req, config);
184
275
  if (req.runtime === "codex")
185
276
  return [
@@ -200,6 +291,7 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
200
291
  ? ["--ask-for-approval", "never"]
201
292
  : ["-c", 'approval_policy="never"']),
202
293
  ]),
294
+ ...model,
203
295
  ...extra,
204
296
  "--",
205
297
  req.prompt,
@@ -244,6 +336,7 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
244
336
  JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
245
337
  "--settings",
246
338
  JSON.stringify(settings),
339
+ ...model,
247
340
  ...extra,
248
341
  "--",
249
342
  req.prompt,
package/dist/help.js CHANGED
@@ -55,7 +55,10 @@ export const COMMANDS = [
55
55
  ["--no-mcp", "launch with no MCP servers"],
56
56
  ["--plugin NAME", "repeatable; replaces configured defaults (OpenCode)"],
57
57
  ["--no-plugin", "launch with no plugins"],
58
- ["--model PROVIDER/MODEL", "select the OpenCode model"],
58
+ [
59
+ "--model MODEL",
60
+ "model for the launch: a bare id for claude and codex, PROVIDER/MODEL for opencode",
61
+ ],
59
62
  [
60
63
  "--request-key KEY",
61
64
  "name the request; sending it again returns the same launch",
package/dist/mcp-tools.js CHANGED
@@ -80,7 +80,7 @@ export const MCP_TOOLS = [
80
80
  model: {
81
81
  type: "string",
82
82
  minLength: 1,
83
- description: "PROVIDER/MODEL, overriding the configured [opencode] model. OpenCode only.",
83
+ description: "Model for the launch. A bare model id for claude and codex; PROVIDER/MODEL for opencode, where it overrides the configured [opencode] model. Recorded on the run and reported by `list`.",
84
84
  },
85
85
  identity: {
86
86
  type: "string",
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { resolvePermissions, runtimeArgs } from "./guard.js";
4
- import { openCodeModel, openCodeProviders } from "./providers.js";
3
+ import { resolveModel, resolvePermissions, runtimeArgs, } from "./guard.js";
4
+ import { openCodeProviders } from "./providers.js";
5
5
  const exec = promisify(execFile);
6
6
  const DEBUG_CONFIG_ERROR = "Cannot verify effective OpenCode MCP configuration";
7
7
  const ISOLATION_ERROR = "OpenCode MCP isolation could not be verified; refusing launch";
@@ -173,7 +173,15 @@ function permissions(req, config, tools) {
173
173
  }
174
174
  export function openCodeConfig(req, config, selected, inheritedMcpNames, plugins = [], source = {}) {
175
175
  const { mcp, tools } = mcpPolicy(selected, inheritedMcpNames);
176
- const model = openCodeModel(req.model, config);
176
+ // An `args`-named model is already forwarded onto the child's argv by
177
+ // `runtimeArgs`, so it must not also enter the verified overlay: that would
178
+ // deliver one model by two routes and rewrite a config the caller never
179
+ // changed. The overlay carries only what `--model` or `[opencode] model`
180
+ // named, exactly as before this resolver existed.
181
+ const resolved = resolveModel(req, config);
182
+ const model = resolved && resolved.origin !== "args"
183
+ ? resolved.model
184
+ : config.opencode.model;
177
185
  const provider = openCodeProviders(config, source);
178
186
  return {
179
187
  share: "disabled",
package/dist/providers.js CHANGED
@@ -75,7 +75,3 @@ export function openCodeProviders(config, source) {
75
75
  ];
76
76
  }));
77
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
@@ -4,7 +4,7 @@ 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, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
7
+ import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
8
8
  import { enforcementGrade } from "./enforcement.js";
9
9
  import { LOCAL } from "./requester.js";
10
10
  import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
@@ -37,6 +37,12 @@ function taskHandle(entry, pid) {
37
37
  mcp: entry.mcp ?? [],
38
38
  ...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
39
39
  ...(entry.enforcement ? { enforcement: entry.enforcement } : {}),
40
+ ...(entry.identity ? { identity: entry.identity } : {}),
41
+ // `run` returns this, while `list` builds its own record: a field added to
42
+ // one and not the other makes the two disagree about the same launch.
43
+ ...(entry.model !== undefined
44
+ ? { model: entry.model, model_source: entry.modelSource ?? null }
45
+ : {}),
40
46
  id: entry.id,
41
47
  runtime: entry.runtime,
42
48
  state: "running",
@@ -226,6 +232,10 @@ export class Muster {
226
232
  const selectedMcp = selectMcp(effective, this.config);
227
233
  const selectedPlugins = req.runtime === "opencode" ? selectPlugins(effective, this.config) : [];
228
234
  let argv = [];
235
+ // Resolved here, at the seam that already exists to refuse a bad launch
236
+ // before anything is reserved: a malformed model must not leave a
237
+ // registry entry behind.
238
+ const model = resolveModel(effective, this.config);
229
239
  if (req.runtime === "opencode")
230
240
  runtimeArgs(effective);
231
241
  else
@@ -247,6 +257,13 @@ export class Muster {
247
257
  cwd: req.cwd,
248
258
  ...permissions,
249
259
  enforcement,
260
+ model: model?.model ?? null,
261
+ modelSource: model?.source ?? null,
262
+ // Recorded here rather than only at the patch below, because `list`
263
+ // now publishes it: an entry that dies between this line and that one
264
+ // would otherwise report no identity, which reads as "ran on the
265
+ // ambient environment" for a launch that named an account.
266
+ ...(identityName ? { identity: identityName } : {}),
250
267
  }, this.config.max_concurrent, req.requestKey
251
268
  ? {
252
269
  requestKey: req.requestKey,
@@ -519,6 +536,10 @@ export class Muster {
519
536
  pid: identity.pid,
520
537
  host: driver.id,
521
538
  capabilities: driver.capabilities(),
539
+ // The RESOLVED name, matching what the entry records and what
540
+ // `list` projects: a launch result and a listing must not
541
+ // disagree about which account this ran as.
542
+ ...(identityName ? { identity: identityName } : {}),
522
543
  attach_hint: driver.attachHint(launched.hostRef),
523
544
  };
524
545
  if (req.open) {
@@ -859,6 +880,13 @@ export class Muster {
859
880
  // From the entry, not the peer: a listing must report the same fact a
860
881
  // launch result does, and an entry predating the field has none.
861
882
  ...(e.enforcement ? { enforcement: e.enforcement } : {}),
883
+ ...(e.identity ? { identity: e.identity } : {}),
884
+ // Absent on an entry written before the field, which is a different
885
+ // fact from an explicit null: that one means Muster resolved nothing
886
+ // and the child chose. Both are more useful than a guess.
887
+ ...(e.model !== undefined
888
+ ? { model: e.model, model_source: e.modelSource ?? null }
889
+ : {}),
862
890
  id: e.id,
863
891
  runtime: e.peer?.runtime ?? e.runtime,
864
892
  state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
@@ -879,6 +907,13 @@ export class Muster {
879
907
  ? { permissions: e.permissions, sandbox: e.sandbox }
880
908
  : {}),
881
909
  ...(e.enforcement ? { enforcement: e.enforcement } : {}),
910
+ ...(e.identity ? { identity: e.identity } : {}),
911
+ // Absent on an entry written before the field, which is a different
912
+ // fact from an explicit null: that one means Muster resolved nothing
913
+ // and the child chose. Both are more useful than a guess.
914
+ ...(e.model !== undefined
915
+ ? { model: e.model, model_source: e.modelSource ?? null }
916
+ : {}),
882
917
  id: e.id,
883
918
  runtime: e.runtime,
884
919
  cwd: e.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {