@brutalsystems/muster 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -343,6 +343,8 @@ Missing configuration uses these defaults:
343
343
 
344
344
  ```toml
345
345
  host = "auto"
346
+ tmux_status = "off"
347
+ terminal = "terminal"
346
348
  launch_timeout_sec = 30
347
349
  max_concurrent = 4
348
350
  permissions = "deny"
@@ -472,6 +474,20 @@ without adding a retention policy nobody asked for.
472
474
 
473
475
  ### Containment levels
474
476
 
477
+ Two axes, answering different questions:
478
+
479
+ - **`--permissions` decides whether the agent stops to ask a human.** It
480
+ configures the runtime's own approval prompting — a setting the agent's
481
+ process honours.
482
+ - **`--sandbox` decides what the agent may touch, and the kernel enforces it.**
483
+ On macOS that is seatbelt. Nothing the agent does talks its way past it.
484
+
485
+ The difference is not academic: a permissions refusal arrives as a prompt or a
486
+ tool error the agent can read and react to, while a sandbox refusal arrives as
487
+ `EPERM` from the kernel, with no negotiation and often no explanation the agent
488
+ can interpret. `enforcement` below records which of the two a launch actually
489
+ got.
490
+
475
491
  `--level` names a legal `--permissions` / `--sandbox` pair. It is a spelling,
476
492
  not a second policy path, and naming both a level and either flag is an error.
477
493
 
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([]),
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) {
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({
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, resolveLifecycle, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
7
+ import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, resolveTerminal, 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";
@@ -98,7 +98,8 @@ export class Muster {
98
98
  }
99
99
  static async create(opts = {}) {
100
100
  const env = launchEnv(opts.env ?? process.env), home = opts.home ?? join(env.HOME ?? homedir(), ".muster");
101
- return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(opts.env ?? process.env), { ...(opts.env ?? process.env) });
101
+ const config = await loadConfig(home);
102
+ return new Muster(config, home, env, opts.drivers ?? hosts(opts.env ?? process.env, config.tmux_status), { ...(opts.env ?? process.env) });
102
103
  }
103
104
  run(input, requester = LOCAL) {
104
105
  if (this.closing)
@@ -217,7 +218,7 @@ export class Muster {
217
218
  throw new Error("host applies only to session launches");
218
219
  if (req.terminal && !req.open)
219
220
  throw new Error("--terminal requires --open");
220
- const terminal = !req.terminal || req.terminal === "auto" ? "terminal" : req.terminal;
221
+ const terminal = resolveTerminal(req, this.config);
221
222
  const requestedHost = req.host ?? this.config.host;
222
223
  if (req.open &&
223
224
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {