@brutalsystems/muster 0.10.0 → 0.11.1

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
@@ -120,7 +120,7 @@ piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
120
120
 
121
121
  `run` accepts an optional `--` followed by runtime arguments. The normal
122
122
  allowlist is `--model` / `-m`, plus Claude's `--effort` and OpenCode's `--agent`
123
- and `--variant`. For Claude and Codex a model named there is *consumed* rather
123
+ and `--variant`. For Claude and Codex a model named there is _consumed_ rather
124
124
  than forwarded — it reaches the child through the same path as `--model`, so it
125
125
  appears once — and naming a model both ways is refused rather than resolved by
126
126
  precedence. Unknown options, bundled short options, config injection and
@@ -212,7 +212,7 @@ and exit-status lifecycle as other tasks.
212
212
 
213
213
  ## Repeating a launch request
214
214
 
215
- `--request-key KEY` (MCP: `requestKey`) names the launch *request*, not the
215
+ `--request-key KEY` (MCP: `requestKey`) names the launch _request_, not the
216
216
  session it produces. Sending the same key again returns the launch it already
217
217
  produced instead of starting a second agent:
218
218
 
@@ -233,12 +233,12 @@ computed before validation would disagree with itself.
233
233
 
234
234
  What a repeat returns depends on what the first attempt did:
235
235
 
236
- | First attempt | A repeat gets |
237
- | --- | --- |
238
- | Reachable session or running task | That session or task, unchanged |
239
- | Still launching | Refused — ask for its status instead |
240
- | Failed, exited or stopped | Refused, naming the recorded outcome |
241
- | Outcome unknown | Refused, and told to inspect first (below) |
236
+ | First attempt | A repeat gets |
237
+ | --------------------------------- | ------------------------------------------ |
238
+ | Reachable session or running task | That session or task, unchanged |
239
+ | Still launching | Refused — ask for its status instead |
240
+ | Failed, exited or stopped | Refused, naming the recorded outcome |
241
+ | Outcome unknown | Refused, and told to inspect first (below) |
242
242
 
243
243
  A repeat never consumes launch capacity, including when the concurrency cap is
244
244
  full of the very launch being asked about. Omitting the key leaves behaviour
@@ -329,7 +329,7 @@ Muster records the group the launched root leads and takes the union. A stop
329
329
  reaches reparented workers, and a session whose root has died but whose group is
330
330
  still busy is reported as running rather than ended.
331
331
 
332
- Only a group the root *leads* is used. A process that merely belongs to someone
332
+ Only a group the root _leads_ is used. A process that merely belongs to someone
333
333
  else's group shares it with whatever else is there — a shell, a tmux pane, the
334
334
  terminal — and treating that as membership would reach far outside the session.
335
335
  Both terminal hosts start their root in a new session, so in practice it does
@@ -339,10 +339,21 @@ nothing on macOS prevents that.
339
339
  ## Configuration and permissions
340
340
 
341
341
  Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
342
+
343
+ A key this build does not know is reported and ignored, so a config written for
344
+ a newer Muster does not stop an older one from running — a strict top level is
345
+ what made every 0.10.0 command fail with `unrecognized_keys` the moment
346
+ `terminal` was added in 0.11.0. Values are still validated, and the nested
347
+ policy tables stay strict: a dropped `sandbox` value would launch under the
348
+ default rather than the pair that was written, and an unknown key inside a
349
+ requester profile is a policy statement that would not apply.
350
+
342
351
  Missing configuration uses these defaults:
343
352
 
344
353
  ```toml
345
354
  host = "auto"
355
+ tmux_status = "off"
356
+ terminal = "terminal"
346
357
  launch_timeout_sec = 30
347
358
  max_concurrent = 4
348
359
  permissions = "deny"
@@ -472,10 +483,24 @@ without adding a retention policy nobody asked for.
472
483
 
473
484
  ### Containment levels
474
485
 
486
+ Two axes, answering different questions:
487
+
488
+ - **`--permissions` decides whether the agent stops to ask a human.** It
489
+ configures the runtime's own approval prompting — a setting the agent's
490
+ process honours.
491
+ - **`--sandbox` decides what the agent may touch, and the kernel enforces it.**
492
+ On macOS that is seatbelt. Nothing the agent does talks its way past it.
493
+
494
+ The difference is not academic: a permissions refusal arrives as a prompt or a
495
+ tool error the agent can read and react to, while a sandbox refusal arrives as
496
+ `EPERM` from the kernel, with no negotiation and often no explanation the agent
497
+ can interpret. `enforcement` below records which of the two a launch actually
498
+ got.
499
+
475
500
  `--level` names a legal `--permissions` / `--sandbox` pair. It is a spelling,
476
501
  not a second policy path, and naming both a level and either flag is an error.
477
502
 
478
- | Level | Equivalent to |
503
+ | Level | Equivalent to |
479
504
  | ------ | ------------------------------------------------------------------------------------- |
480
505
  | `read` | `--permissions deny --sandbox read-only` |
481
506
  | `work` | `--permissions auto --sandbox workspace-write` |
@@ -487,7 +512,7 @@ through the flags.
487
512
  Every launch also records how strongly its containment was imposed:
488
513
 
489
514
  | `enforcement` | Meaning |
490
- | ------------- | ---------------------------------------------------------------------------- |
515
+ | ------------- | --------------------------------------------------------------------------- |
491
516
  | `kernel` | Codex or Claude Code with an OS sandbox configured and active. |
492
517
  | `tool-policy` | OpenCode — rules the agent is asked to respect, with no kernel enforcement. |
493
518
  | `none` | `full-access` — the sandbox is deliberately disabled. |
@@ -739,7 +764,7 @@ configuration.
739
764
 
740
765
  Five things worth stating plainly, because each is a sharp edge:
741
766
 
742
- 1. **`muster identities` reports *configured*, never *valid*.** A keychain
767
+ 1. **`muster identities` reports _configured_, never _valid_.** A keychain
743
768
  item, an auth file, a stored token or a set variable proves a credential
744
769
  exists — not that it still works. Tokens expire and are revoked; there is
745
770
  no way to check that without exercising the credential, and `identities`
@@ -777,7 +802,7 @@ Five things worth stating plainly, because each is a sharp edge:
777
802
  about bounding.
778
803
 
779
804
  A Claude launch under an identity also marks the launch's working directory
780
- trusted *inside that launch's own copy* of the configuration, so a brand-new
805
+ trusted _inside that launch's own copy_ of the configuration, so a brand-new
781
806
  directory starts as a registered session without anyone accepting a trust
782
807
  dialog. This is distinct from the ordinary Claude prerequisite described
783
808
  above: it only ever writes to the per-launch copy, never to the operator's own
package/dist/config.js CHANGED
@@ -64,6 +64,20 @@ const sessionSectionSchema = z
64
64
  export const configSchema = z
65
65
  .object({
66
66
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
67
+ /**
68
+ * Off by default. Muster's tmux runs on its own server with `-f /dev/null`,
69
+ * so this bar is never the developer's configured one, and a Muster session
70
+ * is one window running one agent.
71
+ */
72
+ tmux_status: z.enum(["on", "off"]).default("off"),
73
+ /**
74
+ * Which terminal `--open` uses when the launch does not name one. Was
75
+ * hardcoded to Terminal.app, so a developer on Ghostty or iTerm2 had to pass
76
+ * `--terminal` on every launch with no way to state it once.
77
+ */
78
+ terminal: z
79
+ .enum(["auto", "terminal", "iterm2", "ghostty"])
80
+ .default("terminal"),
67
81
  launch_timeout_sec: z.number().finite().positive().max(600).default(30),
68
82
  max_concurrent: z.number().int().positive().max(1000).default(4),
69
83
  allowed_roots: z.array(z.string().min(1)).default([]),
@@ -91,7 +105,32 @@ export const configSchema = z
91
105
  .default({}),
92
106
  requesters: z.array(requesterEnrolmentSchema).default([]),
93
107
  })
94
- .strict();
108
+ // Deliberately NOT strict, unlike the nested schemas above. A config written
109
+ // for a newer Muster must not brick an older one: when `terminal` arrived in
110
+ // 0.11.0 a strict top level made EVERY command on 0.10.0 fail with
111
+ // `unrecognized_keys` — not a warning, a dead CLI, `list` and `stop`
112
+ // included. Unknown keys here are reported by `unknownConfigKeys` and
113
+ // dropped.
114
+ //
115
+ // Values are still validated, which is the half that matters for safety: a
116
+ // silently ignored `sandbox = "workspce-write"` would launch under the
117
+ // default rather than the pair the operator wrote. The nested policy tables
118
+ // stay strict for the same reason — an unknown key inside a requester
119
+ // profile is a policy statement that would not apply.
120
+ .strip();
121
+ const knownConfigKeys = new Set(Object.keys(configSchema.shape));
122
+ /**
123
+ * Top-level keys this build does not know, sorted. Reported rather than
124
+ * refused, so a newer config degrades to "this Muster ignores that" instead of
125
+ * refusing to run at all.
126
+ */
127
+ export function unknownConfigKeys(raw) {
128
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
129
+ return [];
130
+ return Object.keys(raw)
131
+ .filter((k) => !knownConfigKeys.has(k))
132
+ .sort();
133
+ }
95
134
  export const musterHome = () => join(homedir(), ".muster");
96
135
  export async function loadConfig(home = musterHome()) {
97
136
  let value = {};
@@ -102,6 +141,10 @@ export async function loadConfig(home = musterHome()) {
102
141
  if (e.code !== "ENOENT")
103
142
  throw e;
104
143
  }
144
+ const ignored = unknownConfigKeys(value);
145
+ if (ignored.length)
146
+ process.stderr.write(`[muster] ignoring unknown config key${ignored.length > 1 ? "s" : ""}: ` +
147
+ `${ignored.join(", ")} — written for a different version of muster?\n`);
105
148
  const result = configSchema.parse(value);
106
149
  const seen = new Set();
107
150
  for (const e of result.requesters) {
package/dist/guard.js CHANGED
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { realpath } from "node:fs/promises";
5
- import { homedir } from "node:os";
6
5
  import { isAbsolute, join, relative, resolve } from "node:path";
6
+ import { claudeConfigDir } from "./identity/claude.js";
7
7
  import { mcpName, claudeMcpConfig } from "./mcp.js";
8
8
  import { pluginName } from "./plugins.js";
9
9
  import { RequesterConfigError } from "./requester-policy.js";
@@ -318,18 +318,6 @@ export function runtimeArgs(req) {
318
318
  }
319
319
  return result;
320
320
  }
321
- /**
322
- * The config dir the launched agent will actually use. `launchEnv`'s blocklist
323
- * is /^(CLAUDECODE|CLAUDE_CODE_|...)/, which does not match CLAUDE_CONFIG_DIR,
324
- * so the child inherits whatever the caller set and this resolves to the same
325
- * dir the child will open.
326
- */
327
- export function claudeConfigDir(env) {
328
- const configured = env.CLAUDE_CONFIG_DIR;
329
- if (configured && configured.trim().length > 0)
330
- return resolve(configured);
331
- return join(env.HOME ?? homedir(), ".claude");
332
- }
333
321
  /**
334
322
  * The keys of a profile's own settings.json that name its skill set rather than
335
323
  * its policy. `--setting-sources ""` is what keeps `permissions`, `sandbox` and
@@ -358,6 +346,61 @@ function profilePluginSettings(configDir) {
358
346
  out[key] = source[key];
359
347
  return out;
360
348
  }
349
+ /**
350
+ * Whether the caller typed `--terminal` themselves, read from the raw argv
351
+ * BEFORE `normalizeOpenFlag` runs. Every `--open <app>` becomes `--terminal
352
+ * <app>`, so after normalisation the two are indistinguishable and a deprecation
353
+ * notice would fire on the spelling Muster is steering callers towards.
354
+ */
355
+ export function usesTerminalFlag(argv) {
356
+ const end = argv.indexOf("--");
357
+ return (end === -1 ? argv : argv.slice(0, end)).some((a) => a === "--terminal" || a.startsWith("--terminal="));
358
+ }
359
+ /**
360
+ * Lifts `--open <app>` and `--open=<app>` onto `--terminal <app>` before
361
+ * `parseArgs` sees them, so `--open` can carry an optional value at all: in
362
+ * strict mode a boolean option refuses `=value` and a string option refuses a
363
+ * bare flag, and there is no third kind. Everything after `--` belongs to the
364
+ * runtime and is copied through untouched.
365
+ *
366
+ * An unrecognised app is lifted too rather than left behind, so the schema names
367
+ * it in the error instead of it becoming a silent runtime argument.
368
+ */
369
+ export function normalizeOpenFlag(argv) {
370
+ const out = [];
371
+ for (let i = 0; i < argv.length; i++) {
372
+ const arg = argv[i];
373
+ if (arg === "--") {
374
+ out.push(...argv.slice(i));
375
+ return out;
376
+ }
377
+ if (arg.startsWith("--open=")) {
378
+ out.push("--open", "--terminal", arg.slice("--open=".length));
379
+ continue;
380
+ }
381
+ if (arg === "--open") {
382
+ const next = argv[i + 1];
383
+ if (next !== undefined && next !== "--" && !next.startsWith("-")) {
384
+ out.push("--open", "--terminal", next);
385
+ i++;
386
+ continue;
387
+ }
388
+ }
389
+ out.push(arg);
390
+ }
391
+ return out;
392
+ }
393
+ /**
394
+ * Which terminal `--open` uses: the launch's own choice, else config's, and
395
+ * Terminal.app only when neither names one — it is the one terminal every macOS
396
+ * is guaranteed to have. `auto` is a request to decide rather than a literal
397
+ * app, so it defers to config exactly as an absent value does.
398
+ */
399
+ export function resolveTerminal(req, config) {
400
+ if (req.terminal && req.terminal !== "auto")
401
+ return req.terminal;
402
+ return config.terminal === "auto" ? "terminal" : config.terminal;
403
+ }
361
404
  export function launchArgs(req, config, mcp = [], bridgePath, env = process.env) {
362
405
  // Before `runtimeArgs`, so this function owns every model error on its own
363
406
  // rather than by its caller's discipline: `Muster.launch` happens to resolve
@@ -399,6 +442,12 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
399
442
  : policy.permissions === "bypass"
400
443
  ? "bypassPermissions"
401
444
  : "dontAsk";
445
+ // The dir the launched agent will actually open: `launchEnv`'s blocklist is
446
+ // /^(CLAUDECODE|CLAUDE_CODE_|...)/, which does not match CLAUDE_CONFIG_DIR, so
447
+ // the child inherits whatever the caller set. Shared with the trust read and
448
+ // the sessions dir rather than restated — a second copy here drifted from that
449
+ // one already (it resolve()d, and read a blank value as unset), which is how a
450
+ // write root ends up naming a directory nobody is launching in.
402
451
  const configDir = claudeConfigDir(env);
403
452
  const settings = {
404
453
  ...profilePluginSettings(configDir),
@@ -425,8 +474,13 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
425
474
  // failed with EPERM while the in-process file tools succeeded (#80).
426
475
  // Granted only under workspace-write: read-only must stay read-only,
427
476
  // and full-access has no sandbox to grant into.
477
+ // Absolute only here: the sandbox is an OS boundary evaluated against a
478
+ // path, and a relative CLAUDE_CONFIG_DIR would grant whatever the
479
+ // launching process's cwd happened to be. The shared resolver stays
480
+ // as-written so the trust read and the sessions dir keep their
481
+ // behaviour.
428
482
  ...(policy.sandbox === "workspace-write"
429
- ? { allowWrite: [configDir] }
483
+ ? { allowWrite: [resolve(configDir)] }
430
484
  : {}),
431
485
  },
432
486
  },
package/dist/help.js CHANGED
@@ -43,8 +43,14 @@ export const COMMANDS = [
43
43
  ["--kind KIND", "session (default) or task"],
44
44
  ["--host ID", "tmux, pty, or auto (default: tmux, then pty)"],
45
45
  ["--identity NAME", "run as a configured identity"],
46
- ["--open", "display the tmux session in a terminal (macOS)"],
47
- ["--terminal APP", "auto, terminal, iterm2, or ghostty; needs --open"],
46
+ [
47
+ "--open [APP]",
48
+ "display the tmux session in a terminal (macOS); APP is terminal, iterm2 or ghostty, defaulting to the configured terminal",
49
+ ],
50
+ [
51
+ "--terminal APP",
52
+ "deprecated, removed in v2: pass the app to --open instead",
53
+ ],
48
54
  [
49
55
  "--level LEVEL",
50
56
  "read, work, or open: a legal permissions and sandbox pair",
@@ -9,9 +9,9 @@ import { MacosTerminalHost } from "./macos-terminal.js";
9
9
  * muster` and stay there, since a tmux launch is meant to survive its parent
10
10
  * exiting (#57).
11
11
  */
12
- export function hosts(env = process.env) {
12
+ export function hosts(env = process.env, tmuxStatus = "off") {
13
13
  return [
14
- new TmuxHost(env.MUSTER_TMUX_SERVER || undefined),
14
+ new TmuxHost(env.MUSTER_TMUX_SERVER || undefined, tmuxStatus),
15
15
  new PtyHost(),
16
16
  new MacosTerminalHost(),
17
17
  ];
@@ -72,9 +72,19 @@ export function tmuxCommand(argv) {
72
72
  }
73
73
  export class TmuxHost {
74
74
  server;
75
+ status;
75
76
  id = "tmux";
76
- constructor(server = "muster") {
77
+ /**
78
+ * `status` is set explicitly on every session rather than left to tmux's
79
+ * default, so the bar's state is what Muster asked for whatever version of
80
+ * tmux is installed. Off by default: Muster's server is its own (`-L`, with
81
+ * `-f /dev/null`), so the bar here is never the developer's configured one,
82
+ * and a session is one window running one agent — the bar reports only what
83
+ * the caller already knows. `tmux_status = "on"` in config.toml restores it.
84
+ */
85
+ constructor(server = "muster", status = "off") {
77
86
  this.server = server;
87
+ this.status = status;
78
88
  if (!/^[a-zA-Z0-9_-]+$/.test(server))
79
89
  throw new Error("Invalid tmux server");
80
90
  }
@@ -155,7 +165,23 @@ export class TmuxHost {
155
165
  try {
156
166
  // Window selection is session-wide: sharing a session makes every
157
167
  // attached terminal follow whichever agent was opened most recently.
158
- const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
168
+ const session = `muster-${randomUUID()}`;
169
+ // One tmux invocation, not two: a second round trip would leave the bar
170
+ // visible for as long as it took, and `;` keeps set-option in the same
171
+ // command list. Targeted by session name rather than relying on "current",
172
+ // which is not meaningful for a detached session.
173
+ const output = await this.call([
174
+ "new-session",
175
+ "-s",
176
+ session,
177
+ ...common,
178
+ ";",
179
+ "set-option",
180
+ "-t",
181
+ session,
182
+ "status",
183
+ this.status,
184
+ ], opts.deadline);
159
185
  return parseWindowRow(output);
160
186
  }
161
187
  catch (e) {
@@ -126,6 +126,16 @@ function signal(ref, name) {
126
126
  * Reparented descendants are reachable only through the process group, so pass
127
127
  * one when the caller has recorded it; the parent chain alone cannot see them.
128
128
  */
129
+ /**
130
+ * Signals a process tree dead: SIGTERM deepest-first, a grace period, a second
131
+ * pass for anything forked during it, then SIGKILL.
132
+ *
133
+ * Resolves once the signals are SENT, not once the processes are gone. SIGKILL
134
+ * is asynchronous, so a pid may still be in the process table when this returns
135
+ * — callers that need the stronger guarantee have to poll. Nothing does today:
136
+ * `stop` marks the entry stopped, and `refreshed` re-probes only entries still
137
+ * marked running, so the window cannot flip a stopped session back to alive.
138
+ */
129
139
  export async function stopTree(ref, group) {
130
140
  if (!(await isSame(ref)))
131
141
  return;
package/dist/muster.js CHANGED
@@ -13,6 +13,7 @@ import { loadConfig, musterHome } from "./config.js";
13
13
  import { describeIdentities, interactiveSetup, setupIdentity, } from "./identity-cli.js";
14
14
  import { IdentityError } from "./identity-store.js";
15
15
  import { MCP_TOOLS } from "./mcp-tools.js";
16
+ import { normalizeOpenFlag, usesTerminalFlag } from "./guard.js";
16
17
  import { commandHelp, topLevelHelp } from "./help.js";
17
18
  import { promptForMissing, terminalReader } from "./prompt.js";
18
19
  const listSchema = z
@@ -147,8 +148,13 @@ async function main() {
147
148
  }
148
149
  try {
149
150
  const command = argv.shift();
151
+ // Read before normalisation, which turns `--open <app>` into `--terminal
152
+ // <app>` and would otherwise make the notice fire on the replacement.
153
+ const typedTerminalFlag = usesTerminalFlag(argv);
150
154
  const { values: parsed, positionals } = parseArgs({
151
- args: argv,
155
+ // `--open` carries an optional terminal name, which parseArgs has no kind
156
+ // for; the value is lifted onto `--terminal` first. See normalizeOpenFlag.
157
+ args: normalizeOpenFlag(argv),
152
158
  allowPositionals: true,
153
159
  strict: true,
154
160
  options: {
@@ -221,6 +227,8 @@ async function main() {
221
227
  ? formatHuman(value, humanColorEnabled(process.stdout.isTTY, process.env))
222
228
  : JSON.stringify(value) + "\n");
223
229
  if (command === "run") {
230
+ if (typedTerminalFlag)
231
+ process.stderr.write("[muster] --terminal is deprecated and will be removed in v2; pass the app to --open instead, or set terminal in config.toml\n");
224
232
  const runtime = positionals[0];
225
233
  const args = positionals.slice(1);
226
234
  const record = await muster.run({