@brutalsystems/muster 0.2.0 → 0.4.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
@@ -58,13 +58,36 @@ still returns JSON. Put Muster options before any `-- RUNTIME_OPTIONS`.
58
58
  `output` always prints captured task text and does not accept `--format`.
59
59
  MCP tools retain their existing JSON responses.
60
60
 
61
+ Human output uses subtle colors in interactive terminals: green for idle or
62
+ running, yellow for busy or starting, gray for ended states and field labels,
63
+ red for failures or nonzero exit codes, and cyan for follow-up commands. IDs
64
+ and paths retain the normal text color. Set `NO_COLOR=1` to disable colors;
65
+ piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
66
+
61
67
  `run` accepts an optional `--` followed by runtime arguments. The normal allowlist
62
68
  is `--model` / `-m`, plus Claude's `--effort`. Unknown options, bundled short
63
- options, config injection and permission overrides are refused. Prompt strings
69
+ options, config injection and raw permission overrides are refused, even with
70
+ `allow_dangerous_flags` enabled. Use Muster's normalized permission flags. Prompt strings
64
71
  are passed as a single literal argument after the runtime's option terminator.
65
72
 
66
73
  ## Terminal lifetime
67
74
 
75
+ Add `--open` to display the launched session in a new Terminal.app window on macOS:
76
+
77
+ ```sh
78
+ muster run codex --open --prompt "Review this project" --format human
79
+ muster run claude --open --prompt "Review this project" --format human
80
+ ```
81
+
82
+ MCP `run` accepts `open: true`. This requires tmux and selects it when the host
83
+ is auto; explicit pty hosts and tasks are rejected. Detached launch remains the
84
+ default. Terminal.app is currently the only supported viewer; there is no
85
+ `--terminal` selection yet. The host remains tmux, and successful results include
86
+ `terminal_opened: true` (a launch event, not a live window-status check).
87
+ Closing the Terminal window leaves the session running; use `muster stop <id>`
88
+ to stop it. macOS may require Terminal automation permission. If opening fails,
89
+ Muster stops the launched session and returns an error.
90
+
68
91
  Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
69
92
  and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
70
93
 
@@ -90,24 +113,67 @@ Missing configuration uses these defaults:
90
113
  host = "auto"
91
114
  launch_timeout_sec = 30
92
115
  max_concurrent = 4
116
+ permissions = "deny"
93
117
  sandbox = "read-only"
94
118
  allow_dangerous_flags = false
95
119
  ```
96
120
 
97
121
  Choose `sandbox = "workspace-write"` yourself when agents should edit files.
98
- `danger-full-access` additionally requires `allow_dangerous_flags = true`.
122
+ `full-access` and `permissions = "bypass"` require `allow_dangerous_flags = true`.
123
+ The old config spelling `danger-full-access` remains accepted as an alias.
124
+
125
+ Per-launch `--permissions` and `--sandbox` override these config defaults. The
126
+ same named fields are accepted by MCP `run`. Both session and task launches
127
+ support them:
128
+
129
+ ```sh
130
+ muster run codex --permissions auto --sandbox workspace-write \
131
+ --prompt 'Implement the change' --format human
132
+ muster run claude --permissions auto --sandbox workspace-write \
133
+ --prompt 'Implement the change' --format human
134
+ ```
135
+
136
+ | Muster permissions | Codex translation | Claude Code translation |
137
+ |---|---|---|
138
+ | `deny` (default) | Approval policy `never` | Permission mode `dontAsk` |
139
+ | `auto` | `--approve-for-me` | Permission mode `auto` |
140
+ | `bypass` | Bypass approvals and sandbox | Permission mode `bypassPermissions` |
141
+
142
+ `deny` refuses actions that would require approval; it does not prohibit tools
143
+ already allowed by the sandbox or permission rules. `auto` delegates permission
144
+ review to the runtime and may still reject an action; it is not blanket approval.
145
+ Auto mode requires `workspace-write` in Muster because the Codex preset selects
146
+ that sandbox. Explicitly select it; Muster never widens a read-only request.
147
+ Bypass requires `full-access`; combinations claiming a sandbox while bypassing
148
+ it are refused. `deny` can be combined with any authorized sandbox setting.
149
+
150
+ `full-access` disables the runtime command sandbox and requires operator-owned
151
+ config authorization even without bypass. Muster never writes that config.
152
+ Config defaults apply to subsequent launches; overrides apply to one launch.
153
+ Resolved `permissions` and `sandbox` are returned in records, shown in human
154
+ output, persisted for listing, and written in the pre-launch log. Old records
155
+ without those fields remain readable; their settings are not guessed.
156
+
157
+ The runtimes' enforcement differs: Codex reviews sandbox escalation requests;
158
+ Claude's classifier reviews tool permission requests while its Bash sandbox is
159
+ separate. Auto-mode availability and decisions remain subject to runtime,
160
+ model, account, and managed policy. These fields describe Muster's resolved
161
+ launch settings, not a continuous attestation of remote policy or user changes.
162
+ See [Codex auto-review](https://learn.chatgpt.com/docs/sandboxing/auto-review) and
163
+ [Claude permission modes](https://code.claude.com/docs/en/permission-modes).
164
+
99
165
  The concurrency cap is shared by separate CLI/MCP processes, including pending
100
166
  launches. Settings apply to both kinds; tasks are read-only by default.
101
167
 
102
- Codex gets an explicit sandbox and never-ask approval policy. Muster enumerates
168
+ Codex defaults to an explicit sandbox and never-ask approval policy. Muster enumerates
103
169
  and explicitly disables inherited MCP servers, then verifies the effective
104
170
  configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
105
171
  and external notifications are disabled for the child. Unknown MCP names that
106
172
  cannot be addressed safely are refused.
107
173
 
108
- Claude's built-in tools default to enabled. Its command sandbox is enabled,
174
+ Claude's built-in tools default to enabled. Unless full access is selected, its command sandbox is enabled,
109
175
  requires availability, and forbids unsandboxed retries. File-writing tools and
110
- sandbox writes are denied in read-only mode; permissions use `dontAsk` so an
176
+ sandbox writes are denied in read-only mode; default permissions use `dontAsk` so an
111
177
  unattended child does not auto-grant escalations. User/project settings and MCP
112
178
  servers are not inherited. Detected managed policy is refused because the CLI
113
179
  cannot prove that inline settings override it. Enterprise remote policy can
package/dist/config.js CHANGED
@@ -8,8 +8,14 @@ export const configSchema = z
8
8
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
9
9
  launch_timeout_sec: z.number().finite().positive().max(600).default(30),
10
10
  max_concurrent: z.number().int().positive().max(1000).default(4),
11
+ permissions: z.enum(["auto", "deny", "bypass"]).default("deny"),
11
12
  sandbox: z
12
- .enum(["read-only", "workspace-write", "danger-full-access"])
13
+ .enum([
14
+ "read-only",
15
+ "workspace-write",
16
+ "full-access",
17
+ "danger-full-access",
18
+ ])
13
19
  .default("read-only"),
14
20
  allow_dangerous_flags: z.boolean().default(false),
15
21
  })
@@ -25,7 +31,9 @@ export async function loadConfig(home = musterHome()) {
25
31
  throw e;
26
32
  }
27
33
  const result = configSchema.parse(value);
28
- if (result.sandbox === "danger-full-access" && !result.allow_dangerous_flags)
29
- throw new Error("danger-full-access requires allow_dangerous_flags in config.toml");
34
+ if ((["danger-full-access", "full-access"].includes(result.sandbox) ||
35
+ result.permissions === "bypass") &&
36
+ !result.allow_dangerous_flags)
37
+ throw new Error("full-access or bypass requires allow_dangerous_flags in config.toml");
30
38
  return result;
31
39
  }
package/dist/format.js CHANGED
@@ -12,38 +12,57 @@ function argument(value) {
12
12
  ? value
13
13
  : "'" + value.replaceAll("'", "'\\''") + "'";
14
14
  }
15
- function recordHuman(record) {
15
+ export function humanColorEnabled(isTTY, env) {
16
+ return isTTY === true && env.NO_COLOR === undefined && env.TERM !== "dumb";
17
+ }
18
+ function recordHuman(record, color) {
19
+ const paint = (value, code) => color ? `\u001b[${code}m${value}\u001b[0m` : value;
20
+ const field = (label, value) => ` ${paint(label + ":", 90)} ${value}`;
21
+ const badExit = typeof record.exit_code === "number" && record.exit_code !== 0;
22
+ const stateColor = record.state === "failed" || badExit
23
+ ? 31
24
+ : ["idle", "running"].includes(record.state ?? "")
25
+ ? 32
26
+ : ["busy", "starting"].includes(record.state ?? "")
27
+ ? 33
28
+ : 90;
16
29
  const id = record.id ?? record.thread_id ?? record.session_id;
17
30
  if (record.stopped === true)
18
- return `Stopped ${clean(id)}.`;
31
+ return `${paint("Stopped", 90)} ${clean(id)}.`;
19
32
  const lines = [
20
- `${clean(record.runtime)} · ${clean(record.kind)} · ${clean(record.state)}`,
21
- ` ID: ${clean(id)}`,
33
+ `${clean(record.runtime)} · ${clean(record.kind)} · ${paint(clean(record.state), stateColor)}`,
34
+ field("ID", clean(id)),
22
35
  ];
23
36
  if (record.canonical_id)
24
- lines.push(` Address: ${clean(record.canonical_id)}`);
25
- lines.push(` Directory: ${clean(record.cwd)}`);
37
+ lines.push(field("Address", clean(record.canonical_id)));
38
+ lines.push(field("Directory", clean(record.cwd)));
39
+ if (record.permissions)
40
+ lines.push(field("Permissions", clean(record.permissions)));
41
+ if (record.sandbox)
42
+ lines.push(field("Sandbox", clean(record.sandbox)));
26
43
  if (record.host)
27
- lines.push(` Host: ${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`);
44
+ lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
45
+ if (record.terminal_opened)
46
+ lines.push(field("Terminal", "Terminal.app (opened at launch)"));
28
47
  if (record.exit_code !== undefined)
29
- lines.push(` Exit code: ${clean(record.exit_code)}`);
48
+ lines.push(field("Exit code", badExit ? paint(clean(record.exit_code), 31) : clean(record.exit_code)));
30
49
  if (record.signal)
31
- lines.push(` Signal: ${clean(record.signal)}`);
50
+ lines.push(field("Signal", clean(record.signal)));
32
51
  if (record.error)
33
- lines.push(` Error: ${clean(record.error)}`);
52
+ lines.push(field("Error", paint(clean(record.error), 31)));
34
53
  const live = ["starting", "running", "idle", "busy"].includes(record.state ?? "");
35
54
  if (live && record.attach_hint)
36
- lines.push(` Attach: ${clean(record.attach_hint)}`);
55
+ lines.push(field("Attach", paint(clean(record.attach_hint), 36)));
37
56
  if (record.kind === "task" && id)
38
- lines.push(` Output: muster output ${argument(clean(id))}`);
57
+ lines.push(field("Output", paint(`muster output ${argument(clean(id))}`, 36)));
39
58
  if (live && id)
40
- lines.push(` Stop: muster stop ${argument(clean(id))}`);
59
+ lines.push(field("Stop", paint(`muster stop ${argument(clean(id))}`, 36)));
41
60
  return lines.join("\n");
42
61
  }
43
- export function formatHuman(value) {
62
+ export function formatHuman(value, color = false) {
44
63
  if (Array.isArray(value))
45
64
  return value.length
46
- ? value.map(recordHuman).join("\n\n") + "\n"
65
+ ? value.map((record) => recordHuman(record, color)).join("\n\n") + "\n"
47
66
  : "No Muster runs.\n";
48
- return recordHuman(value) + "\n";
67
+ return recordHuman(value, color) + "\n";
49
68
  }
package/dist/guard.js CHANGED
@@ -13,28 +13,31 @@ export const runSchema = z
13
13
  .transform((s) => resolve(s)),
14
14
  kind: z.enum(["session", "task"]).default("session"),
15
15
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).optional(),
16
+ open: z.boolean().optional(),
17
+ permissions: z.enum(["auto", "deny", "bypass"]).optional(),
18
+ sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
16
19
  args: z.array(z.string()).default([]),
17
20
  })
18
21
  .strict();
19
- const danger = new Set([
20
- "--dangerously-skip-permissions",
21
- "--dangerously-bypass-approvals-and-sandbox",
22
- "--approve-for-me",
23
- "--allow-dangerously-skip-permissions",
24
- "--dangerously-bypass-hook-trust",
25
- "--yolo",
26
- "-y",
27
- ]);
28
- function extras(req, config) {
22
+ export function resolvePermissions(req, config) {
23
+ const permissions = req.permissions ?? config.permissions;
24
+ const configuredSandbox = req.sandbox ?? config.sandbox;
25
+ const sandbox = configuredSandbox === "danger-full-access"
26
+ ? "full-access"
27
+ : configuredSandbox;
28
+ if ((permissions === "bypass" || sandbox === "full-access") &&
29
+ !config.allow_dangerous_flags)
30
+ throw new Error("bypass or full-access requires allow_dangerous_flags=true in config.toml");
31
+ if (permissions === "auto" && sandbox !== "workspace-write")
32
+ throw new Error("--permissions auto requires --sandbox workspace-write; no sandbox is widened automatically");
33
+ if (permissions === "bypass" && sandbox !== "full-access")
34
+ throw new Error("--permissions bypass requires --sandbox full-access");
35
+ return { permissions, sandbox };
36
+ }
37
+ function extras(req) {
29
38
  const result = [];
30
39
  for (let i = 0; i < req.args.length; i++) {
31
40
  const arg = req.args[i], flag = arg.split("=")[0].toLowerCase().replaceAll("_", "-");
32
- if (danger.has(flag)) {
33
- if (!config.allow_dangerous_flags)
34
- throw new Error(`Dangerous flag refused: ${arg}; only config.toml can enable it`);
35
- result.push(arg);
36
- continue;
37
- }
38
41
  if (!["--model", "-m", "--effort"].includes(flag) ||
39
42
  (flag === "--effort" && req.runtime !== "claude"))
40
43
  throw new Error(`Runtime option refused: ${arg}`);
@@ -50,30 +53,46 @@ function extras(req, config) {
50
53
  return result;
51
54
  }
52
55
  export function launchArgs(req, config) {
53
- const extra = extras(req, config);
56
+ const extra = extras(req);
57
+ const policy = resolvePermissions(req, config);
54
58
  if (req.runtime === "codex")
55
59
  return [
56
60
  "codex",
57
61
  ...(req.kind === "task"
58
62
  ? ["exec", "--ignore-user-config", "--skip-git-repo-check"]
59
63
  : []),
60
- "--sandbox",
61
- config.sandbox,
62
- ...(req.kind === "session" ? ["--ask-for-approval", "never"] : []),
64
+ ...(policy.permissions === "auto"
65
+ ? ["--approve-for-me"]
66
+ : policy.permissions === "bypass"
67
+ ? ["--dangerously-bypass-approvals-and-sandbox"]
68
+ : [
69
+ "--sandbox",
70
+ policy.sandbox === "full-access"
71
+ ? "danger-full-access"
72
+ : policy.sandbox,
73
+ ...(req.kind === "session"
74
+ ? ["--ask-for-approval", "never"]
75
+ : ["-c", 'approval_policy="never"']),
76
+ ]),
63
77
  ...extra,
64
78
  "--",
65
79
  req.prompt,
66
80
  ];
67
- const readonly = config.sandbox === "read-only";
81
+ const readonly = policy.sandbox === "read-only";
82
+ const mode = policy.permissions === "auto"
83
+ ? "auto"
84
+ : policy.permissions === "bypass"
85
+ ? "bypassPermissions"
86
+ : "dontAsk";
68
87
  const settings = {
69
88
  disableAllHooks: true,
70
89
  permissions: {
71
- defaultMode: "dontAsk",
90
+ defaultMode: mode,
72
91
  deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
73
92
  additionalDirectories: [],
74
93
  },
75
94
  sandbox: {
76
- enabled: config.sandbox !== "danger-full-access",
95
+ enabled: policy.sandbox !== "full-access",
77
96
  failIfUnavailable: true,
78
97
  autoAllowBashIfSandboxed: true,
79
98
  allowUnsandboxedCommands: false,
@@ -87,7 +106,7 @@ export function launchArgs(req, config) {
87
106
  "--tools",
88
107
  "default",
89
108
  "--permission-mode",
90
- "dontAsk",
109
+ mode,
91
110
  "--setting-sources",
92
111
  "",
93
112
  "--strict-mcp-config",
@@ -0,0 +1,47 @@
1
+ import { access, realpath } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { command } from "../identity/processes.js";
5
+ export async function terminalAvailable(platform = process.platform) {
6
+ if (platform !== "darwin")
7
+ return false;
8
+ try {
9
+ await access("/System/Applications/Utilities/Terminal.app");
10
+ await access("/usr/bin/osascript", constants.X_OK);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export async function tmuxExecutable() {
18
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
19
+ if (!dir)
20
+ continue;
21
+ const path = join(dir, "tmux");
22
+ try {
23
+ await access(path, constants.X_OK);
24
+ return await realpath(path);
25
+ }
26
+ catch { }
27
+ }
28
+ throw new Error("Cannot locate tmux executable for Terminal.app");
29
+ }
30
+ export async function openTerminal(tmux, server, hostRef, deadline) {
31
+ if (!/^[a-zA-Z0-9_-]+$/.test(server) || !/^@\d+$/.test(hostRef))
32
+ throw new Error("Invalid tmux terminal target");
33
+ if (Date.now() >= deadline)
34
+ throw new Error("Launch deadline expired before opening Terminal.app");
35
+ const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
36
+ const shellCommand = `${quoted} -L ${server} attach-session -t muster \\; select-window -t ${hostRef}`;
37
+ await command("/usr/bin/osascript", [
38
+ "-e",
39
+ `on run argv
40
+ tell application "Terminal"
41
+ activate
42
+ do script (item 1 of argv)
43
+ end tell
44
+ end run`,
45
+ shellCommand,
46
+ ], Math.max(1, Math.min(10000, deadline - Date.now())));
47
+ }
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { command, processRef, stopTree } from "../identity/processes.js";
6
+ import { terminalAvailable, tmuxExecutable, openTerminal } from "./terminal.js";
6
7
  export class TmuxHost {
7
8
  server;
8
9
  id = "tmux";
@@ -23,6 +24,12 @@ export class TmuxHost {
23
24
  return false;
24
25
  }
25
26
  }
27
+ openAvailable() {
28
+ return terminalAvailable();
29
+ }
30
+ async open(hostRef, deadline) {
31
+ await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline);
32
+ }
26
33
  capabilities() {
27
34
  return { watchable: true, attachable: true, survivesParentExit: true };
28
35
  }
@@ -5,7 +5,7 @@ import { readdir, realpath } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { command, descendants } from "./processes.js";
8
- export async function resolveCodex(root, env, deadline) {
8
+ export async function resolveCodex(root, env, deadline, isDirectCli) {
9
9
  const family = new Set(await descendants(root, deadline));
10
10
  let dir = join(env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex"), "thread-writer-locks");
11
11
  try {
@@ -46,8 +46,18 @@ export async function resolveCodex(root, env, deadline) {
46
46
  matches.set(path.slice(dir.length + 1, -5), pid);
47
47
  }
48
48
  }
49
- if (matches.size > 1)
50
- throw new Error("Multiple Codex writer locks held by launched descendants");
51
- const first = [...matches][0];
49
+ let candidates = [...matches];
50
+ if (candidates.length > 1 && isDirectCli) {
51
+ // Auto-review owns internal threads in the same process. Inspect only
52
+ // identities already tied to our descendants; never diff global sessions.
53
+ const selected = [];
54
+ for (const candidate of candidates)
55
+ if (await isDirectCli(candidate[0]))
56
+ selected.push(candidate);
57
+ candidates = selected;
58
+ }
59
+ if (candidates.length > 1)
60
+ throw new Error("Multiple Codex CLI writer locks held by launched descendants");
61
+ const first = candidates[0];
52
62
  return first ? { id: first[0], pid: first[1] } : undefined;
53
63
  }
package/dist/muster.js CHANGED
@@ -7,19 +7,21 @@ import { z } from "zod";
7
7
  import { Muster } from "./run.js";
8
8
  import { runSchema } from "./guard.js";
9
9
  import { delay } from "./identity/processes.js";
10
- import { formatHuman } from "./format.js";
10
+ import { formatHuman, humanColorEnabled } from "./format.js";
11
11
  const listSchema = z
12
12
  .object({ kind: z.enum(["session", "task"]).optional() })
13
13
  .strict();
14
14
  const idSchema = z.object({ id: z.string().min(1) }).strict();
15
15
  const help = `Usage:
16
- muster run <codex|claude> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [-- RUNTIME_OPTIONS]
16
+ muster run <codex|claude> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [-- RUNTIME_OPTIONS]
17
17
  muster list [--kind session|task] [--format json|human]
18
18
  muster stop <id> [--format json|human]
19
19
  muster output <id>
20
20
  muster mcp
21
21
 
22
- run also accepts --format json|human before -- RUNTIME_OPTIONS.
22
+ run also accepts --format json|human, --permissions auto|deny|bypass,
23
+ and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
24
+ --open displays a tmux session in Terminal.app on macOS.
23
25
  run, list and stop default to JSON. output prints raw task text.
24
26
 
25
27
  pty launches print their record and keep this process running. Ctrl-C stops the
@@ -51,7 +53,7 @@ async function main() {
51
53
  });
52
54
  });
53
55
  if (mcp) {
54
- const server = new Server({ name: "muster", version: "0.2.0" }, { capabilities: { tools: {} } });
56
+ const server = new Server({ name: "muster", version: "0.4.0" }, { capabilities: { tools: {} } });
55
57
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
56
58
  tools: [
57
59
  {
@@ -72,6 +74,20 @@ async function main() {
72
74
  type: "string",
73
75
  enum: ["auto", "tmux", "pty", "macos-terminal"],
74
76
  },
77
+ open: {
78
+ type: "boolean",
79
+ description: "Open the tmux session in Terminal.app (macOS only).",
80
+ },
81
+ permissions: {
82
+ type: "string",
83
+ enum: ["auto", "deny", "bypass"],
84
+ description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
85
+ },
86
+ sandbox: {
87
+ type: "string",
88
+ enum: ["read-only", "workspace-write", "full-access"],
89
+ description: "Defaults to config (read-only). Full access requires config authorization.",
90
+ },
75
91
  args: { type: "array", items: { type: "string" } },
76
92
  },
77
93
  required: ["runtime", "prompt"],
@@ -153,7 +169,10 @@ async function main() {
153
169
  cwd: { type: "string" },
154
170
  kind: { type: "string" },
155
171
  host: { type: "string" },
172
+ open: { type: "boolean" },
156
173
  format: { type: "string" },
174
+ permissions: { type: "string" },
175
+ sandbox: { type: "string" },
157
176
  },
158
177
  });
159
178
  const { format = "json", ...values } = parsed;
@@ -161,7 +180,9 @@ async function main() {
161
180
  throw new Error("format must be json or human");
162
181
  if (command === "output" && parsed.format !== undefined)
163
182
  throw new Error("output prints raw task text; --format applies to run, list and stop");
164
- const print = (value) => process.stdout.write(format === "human" ? formatHuman(value) : JSON.stringify(value) + "\n");
183
+ const print = (value) => process.stdout.write(format === "human"
184
+ ? formatHuman(value, humanColorEnabled(process.stdout.isTTY, process.env))
185
+ : JSON.stringify(value) + "\n");
165
186
  if (command === "run") {
166
187
  const runtime = positionals[0];
167
188
  const args = positionals.slice(1);
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, launchArgs, launchEnv } from "./guard.js";
7
+ import { runSchema, launchArgs, launchEnv, resolvePermissions, } from "./guard.js";
8
8
  import { Registry } from "./registry.js";
9
9
  import { LaunchLog } from "./log.js";
10
10
  import { hosts, selectHost } from "./hosts/index.js";
@@ -52,13 +52,20 @@ export class Muster {
52
52
  throw new Error("cwd is not a directory");
53
53
  if (req.kind === "task" && req.host)
54
54
  throw new Error("host applies only to session launches");
55
+ const requestedHost = req.host ?? this.config.host;
56
+ if (req.open &&
57
+ (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
58
+ throw new Error("--open requires a tmux session on macOS");
59
+ const permissions = resolvePermissions(req, this.config);
55
60
  const argv = launchArgs(req, this.config), driver = req.kind === "session"
56
- ? await selectHost(req.host ?? this.config.host, this.drivers)
61
+ ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
57
62
  : undefined;
63
+ if (req.open && (!driver?.open || !(await driver.openAvailable?.())))
64
+ throw new Error("--open requires Terminal.app on macOS and tmux");
58
65
  let policy = [];
59
66
  if (req.runtime === "claude")
60
67
  await assertClaudePolicy(this.env);
61
- const reservation = await this.registry.reserve(req, this.config.max_concurrent);
68
+ const reservation = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent);
62
69
  this.owned.add(reservation.launchId);
63
70
  let entry = reservation;
64
71
  const startedAt = Date.now();
@@ -74,7 +81,9 @@ export class Muster {
74
81
  prompt: req.prompt,
75
82
  host: driver?.id ?? null,
76
83
  requester,
84
+ ...(req.open ? { open: true } : {}),
77
85
  args: req.args,
86
+ ...permissions,
78
87
  });
79
88
  if (req.runtime === "codex") {
80
89
  policy = await codexPolicyArgs(this.env, req.cwd, deadline);
@@ -121,9 +130,21 @@ export class Muster {
121
130
  throw new Error("Launch cancelled: Muster is closing");
122
131
  if (!(await isSame(root)))
123
132
  throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
133
+ let openingTerminal = false;
124
134
  try {
125
135
  const identity = req.runtime === "codex"
126
- ? await resolveCodex(root.pid, this.env, deadline)
136
+ ? await resolveCodex(root.pid, this.env, deadline, async (id) => {
137
+ try {
138
+ const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
139
+ return (thread?.id === id &&
140
+ thread.source === "cli" &&
141
+ thread.ephemeral !== true &&
142
+ thread.canAcceptDirectInput !== false);
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ })
127
148
  : await resolveClaude(root.pid, this.env, deadline);
128
149
  if (identity) {
129
150
  diagnostic =
@@ -149,6 +170,7 @@ export class Muster {
149
170
  ])[0];
150
171
  const peer = {
151
172
  kind: "session",
173
+ ...permissions,
152
174
  name: named.display,
153
175
  canonical_id: named.canonicalId,
154
176
  runtime,
@@ -162,6 +184,11 @@ export class Muster {
162
184
  capabilities: driver.capabilities(),
163
185
  attach_hint: driver.attachHint(launched.hostRef),
164
186
  };
187
+ if (req.open) {
188
+ openingTerminal = true;
189
+ await driver.open(launched.hostRef, deadline);
190
+ peer.terminal_opened = true;
191
+ }
165
192
  entry = await this.registry.update(entry.launchId, {
166
193
  id: identity.id,
167
194
  status: "running",
@@ -176,6 +203,8 @@ export class Muster {
176
203
  }
177
204
  }
178
205
  catch (e) {
206
+ if (openingTerminal)
207
+ throw e;
179
208
  if (Date.now() < deadline)
180
209
  diagnostic = e.message;
181
210
  }
@@ -237,6 +266,8 @@ export class Muster {
237
266
  child.unref();
238
267
  const handle = {
239
268
  kind: "task",
269
+ permissions: entry.permissions,
270
+ sandbox: entry.sandbox,
240
271
  id: entry.id,
241
272
  runtime: entry.runtime,
242
273
  state: "running",
@@ -364,6 +395,9 @@ export class Muster {
364
395
  result.push({
365
396
  ...e.peer,
366
397
  kind: "session",
398
+ ...(e.permissions
399
+ ? { permissions: e.permissions, sandbox: e.sandbox }
400
+ : {}),
367
401
  id: e.id,
368
402
  runtime: e.peer?.runtime ?? e.runtime,
369
403
  state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
@@ -378,6 +412,9 @@ export class Muster {
378
412
  catch { }
379
413
  result.push({
380
414
  kind: "task",
415
+ ...(e.permissions
416
+ ? { permissions: e.permissions, sandbox: e.sandbox }
417
+ : {}),
381
418
  id: e.id,
382
419
  runtime: e.runtime,
383
420
  cwd: e.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {