@brutalsystems/muster 0.7.19 → 0.7.21

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,22 @@ 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. The source is
49
+ // shown because "pinned by the caller" and "fell back to the configured
50
+ // default" are the distinction the field exists to make.
51
+ if (record.model)
52
+ lines.push(field("Model", `${clean(record.model)}${record.model_source === "request"
53
+ ? " (requested)"
54
+ : record.model_source === "config"
55
+ ? " (configured default)"
56
+ : ""}`));
45
57
  if (record.mcp)
46
58
  lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
47
59
  for (const warning of record.mcp_warnings ?? [])
package/dist/guard.js CHANGED
@@ -156,30 +156,125 @@ 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) {
269
+ // Before `runtimeArgs`, so this function owns every model error on its own
270
+ // rather than by its caller's discipline: `Muster.launch` happens to resolve
271
+ // first, but `launchArgs` is exported and an input carrying both a model
272
+ // conflict and a refused option must report the model conflict.
273
+ const resolved = resolveModel(req, config);
182
274
  const extra = runtimeArgs(req);
275
+ // OpenCode never reaches here — `run.ts` branches it to the OpenCode launch
276
+ // builders, which carry the model in their config overlay.
277
+ const model = resolved ? ["--model", resolved.model] : [];
183
278
  const policy = resolvePermissions(req, config);
184
279
  if (req.runtime === "codex")
185
280
  return [
@@ -200,6 +295,7 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
200
295
  ? ["--ask-for-approval", "never"]
201
296
  : ["-c", 'approval_policy="never"']),
202
297
  ]),
298
+ ...model,
203
299
  ...extra,
204
300
  "--",
205
301
  req.prompt,
@@ -244,6 +340,7 @@ export function launchArgs(req, config, mcp = [], bridgePath) {
244
340
  JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
245
341
  "--settings",
246
342
  JSON.stringify(settings),
343
+ ...model,
247
344
  ...extra,
248
345
  "--",
249
346
  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,13 @@ 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 } : {}),
543
+ // Same reason, for the same pair of records.
544
+ model: model?.model ?? null,
545
+ model_source: model?.source ?? null,
522
546
  attach_hint: driver.attachHint(launched.hostRef),
523
547
  };
524
548
  if (req.open) {
@@ -859,6 +883,13 @@ export class Muster {
859
883
  // From the entry, not the peer: a listing must report the same fact a
860
884
  // launch result does, and an entry predating the field has none.
861
885
  ...(e.enforcement ? { enforcement: e.enforcement } : {}),
886
+ ...(e.identity ? { identity: e.identity } : {}),
887
+ // Absent on an entry written before the field, which is a different
888
+ // fact from an explicit null: that one means Muster resolved nothing
889
+ // and the child chose. Both are more useful than a guess.
890
+ ...(e.model !== undefined
891
+ ? { model: e.model, model_source: e.modelSource ?? null }
892
+ : {}),
862
893
  id: e.id,
863
894
  runtime: e.peer?.runtime ?? e.runtime,
864
895
  state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
@@ -879,6 +910,13 @@ export class Muster {
879
910
  ? { permissions: e.permissions, sandbox: e.sandbox }
880
911
  : {}),
881
912
  ...(e.enforcement ? { enforcement: e.enforcement } : {}),
913
+ ...(e.identity ? { identity: e.identity } : {}),
914
+ // Absent on an entry written before the field, which is a different
915
+ // fact from an explicit null: that one means Muster resolved nothing
916
+ // and the child chose. Both are more useful than a guess.
917
+ ...(e.model !== undefined
918
+ ? { model: e.model, model_source: e.modelSource ?? null }
919
+ : {}),
882
920
  id: e.id,
883
921
  runtime: e.runtime,
884
922
  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.21",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {