@brutalsystems/muster 0.2.1 → 0.5.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
@@ -66,11 +66,36 @@ piped output and `TERM=dumb` are always uncolored. JSON never includes colors.
66
66
 
67
67
  `run` accepts an optional `--` followed by runtime arguments. The normal allowlist
68
68
  is `--model` / `-m`, plus Claude's `--effort`. Unknown options, bundled short
69
- 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
70
71
  are passed as a single literal argument after the runtime's option terminator.
71
72
 
72
73
  ## Terminal lifetime
73
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 --terminal ghostty --prompt "Review this project" --format human
80
+ muster run codex --open --terminal iterm2 --prompt "Review this project" --format human
81
+ ```
82
+
83
+ MCP `run` accepts `open: true`. This requires tmux and selects it when the host
84
+ is auto; explicit pty hosts and tasks are rejected. Detached launch remains the
85
+ default. `--terminal auto|terminal|iterm2|ghostty` requires `--open`; omitted or
86
+ `auto` uses Terminal.app. MCP accepts the same `terminal` values. Explicit
87
+ choices never silently fall back. iTerm2 and Ghostty must be installed as
88
+ `iTerm.app` or `Ghostty.app` in `/Applications` or `~/Applications`.
89
+ The host remains tmux. Successful results include `terminal_opened: true` and
90
+ the resolved `terminal` (a launch event, not a live window-status check).
91
+ Closing the Terminal window leaves the session running; use `muster stop <id>`
92
+ to stop it. Terminal.app and iTerm2 may require macOS Automation permission.
93
+ Ghostty opens a separate app instance that exits when its last window closes;
94
+ it uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
95
+ iTerm2 uses its [documented scripting interface](https://iterm2.com/documentation-scripting.html).
96
+ If the launch request to the viewer fails,
97
+ Muster stops the launched session and returns an error.
98
+
74
99
  Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
75
100
  and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
76
101
 
@@ -96,24 +121,67 @@ Missing configuration uses these defaults:
96
121
  host = "auto"
97
122
  launch_timeout_sec = 30
98
123
  max_concurrent = 4
124
+ permissions = "deny"
99
125
  sandbox = "read-only"
100
126
  allow_dangerous_flags = false
101
127
  ```
102
128
 
103
129
  Choose `sandbox = "workspace-write"` yourself when agents should edit files.
104
- `danger-full-access` additionally requires `allow_dangerous_flags = true`.
130
+ `full-access` and `permissions = "bypass"` require `allow_dangerous_flags = true`.
131
+ The old config spelling `danger-full-access` remains accepted as an alias.
132
+
133
+ Per-launch `--permissions` and `--sandbox` override these config defaults. The
134
+ same named fields are accepted by MCP `run`. Both session and task launches
135
+ support them:
136
+
137
+ ```sh
138
+ muster run codex --permissions auto --sandbox workspace-write \
139
+ --prompt 'Implement the change' --format human
140
+ muster run claude --permissions auto --sandbox workspace-write \
141
+ --prompt 'Implement the change' --format human
142
+ ```
143
+
144
+ | Muster permissions | Codex translation | Claude Code translation |
145
+ |---|---|---|
146
+ | `deny` (default) | Approval policy `never` | Permission mode `dontAsk` |
147
+ | `auto` | `--approve-for-me` | Permission mode `auto` |
148
+ | `bypass` | Bypass approvals and sandbox | Permission mode `bypassPermissions` |
149
+
150
+ `deny` refuses actions that would require approval; it does not prohibit tools
151
+ already allowed by the sandbox or permission rules. `auto` delegates permission
152
+ review to the runtime and may still reject an action; it is not blanket approval.
153
+ Auto mode requires `workspace-write` in Muster because the Codex preset selects
154
+ that sandbox. Explicitly select it; Muster never widens a read-only request.
155
+ Bypass requires `full-access`; combinations claiming a sandbox while bypassing
156
+ it are refused. `deny` can be combined with any authorized sandbox setting.
157
+
158
+ `full-access` disables the runtime command sandbox and requires operator-owned
159
+ config authorization even without bypass. Muster never writes that config.
160
+ Config defaults apply to subsequent launches; overrides apply to one launch.
161
+ Resolved `permissions` and `sandbox` are returned in records, shown in human
162
+ output, persisted for listing, and written in the pre-launch log. Old records
163
+ without those fields remain readable; their settings are not guessed.
164
+
165
+ The runtimes' enforcement differs: Codex reviews sandbox escalation requests;
166
+ Claude's classifier reviews tool permission requests while its Bash sandbox is
167
+ separate. Auto-mode availability and decisions remain subject to runtime,
168
+ model, account, and managed policy. These fields describe Muster's resolved
169
+ launch settings, not a continuous attestation of remote policy or user changes.
170
+ See [Codex auto-review](https://learn.chatgpt.com/docs/sandboxing/auto-review) and
171
+ [Claude permission modes](https://code.claude.com/docs/en/permission-modes).
172
+
105
173
  The concurrency cap is shared by separate CLI/MCP processes, including pending
106
174
  launches. Settings apply to both kinds; tasks are read-only by default.
107
175
 
108
- Codex gets an explicit sandbox and never-ask approval policy. Muster enumerates
176
+ Codex defaults to an explicit sandbox and never-ask approval policy. Muster enumerates
109
177
  and explicitly disables inherited MCP servers, then verifies the effective
110
178
  configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
111
179
  and external notifications are disabled for the child. Unknown MCP names that
112
180
  cannot be addressed safely are refused.
113
181
 
114
- Claude's built-in tools default to enabled. Its command sandbox is enabled,
182
+ Claude's built-in tools default to enabled. Unless full access is selected, its command sandbox is enabled,
115
183
  requires availability, and forbids unsandboxed retries. File-writing tools and
116
- sandbox writes are denied in read-only mode; permissions use `dontAsk` so an
184
+ sandbox writes are denied in read-only mode; default permissions use `dontAsk` so an
117
185
  unattended child does not auto-grant escalations. User/project settings and MCP
118
186
  servers are not inherited. Detected managed policy is refused because the CLI
119
187
  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
@@ -36,8 +36,14 @@ function recordHuman(record, color) {
36
36
  if (record.canonical_id)
37
37
  lines.push(field("Address", clean(record.canonical_id)));
38
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)));
39
43
  if (record.host)
40
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", `${record.terminal === "ghostty" ? "Ghostty" : record.terminal === "iterm2" ? "iTerm2" : "Terminal.app"} (opened at launch)`));
41
47
  if (record.exit_code !== undefined)
42
48
  lines.push(field("Exit code", badExit ? paint(clean(record.exit_code), 31) : clean(record.exit_code)));
43
49
  if (record.signal)
package/dist/guard.js CHANGED
@@ -13,28 +13,32 @@ 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
+ terminal: z.enum(["auto", "terminal", "iterm2", "ghostty"]).optional(),
18
+ permissions: z.enum(["auto", "deny", "bypass"]).optional(),
19
+ sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
16
20
  args: z.array(z.string()).default([]),
17
21
  })
18
22
  .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) {
23
+ export function resolvePermissions(req, config) {
24
+ const permissions = req.permissions ?? config.permissions;
25
+ const configuredSandbox = req.sandbox ?? config.sandbox;
26
+ const sandbox = configuredSandbox === "danger-full-access"
27
+ ? "full-access"
28
+ : configuredSandbox;
29
+ if ((permissions === "bypass" || sandbox === "full-access") &&
30
+ !config.allow_dangerous_flags)
31
+ throw new Error("bypass or full-access requires allow_dangerous_flags=true in config.toml");
32
+ if (permissions === "auto" && sandbox !== "workspace-write")
33
+ throw new Error("--permissions auto requires --sandbox workspace-write; no sandbox is widened automatically");
34
+ if (permissions === "bypass" && sandbox !== "full-access")
35
+ throw new Error("--permissions bypass requires --sandbox full-access");
36
+ return { permissions, sandbox };
37
+ }
38
+ function extras(req) {
29
39
  const result = [];
30
40
  for (let i = 0; i < req.args.length; i++) {
31
41
  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
42
  if (!["--model", "-m", "--effort"].includes(flag) ||
39
43
  (flag === "--effort" && req.runtime !== "claude"))
40
44
  throw new Error(`Runtime option refused: ${arg}`);
@@ -50,30 +54,46 @@ function extras(req, config) {
50
54
  return result;
51
55
  }
52
56
  export function launchArgs(req, config) {
53
- const extra = extras(req, config);
57
+ const extra = extras(req);
58
+ const policy = resolvePermissions(req, config);
54
59
  if (req.runtime === "codex")
55
60
  return [
56
61
  "codex",
57
62
  ...(req.kind === "task"
58
63
  ? ["exec", "--ignore-user-config", "--skip-git-repo-check"]
59
64
  : []),
60
- "--sandbox",
61
- config.sandbox,
62
- ...(req.kind === "session" ? ["--ask-for-approval", "never"] : []),
65
+ ...(policy.permissions === "auto"
66
+ ? ["--approve-for-me"]
67
+ : policy.permissions === "bypass"
68
+ ? ["--dangerously-bypass-approvals-and-sandbox"]
69
+ : [
70
+ "--sandbox",
71
+ policy.sandbox === "full-access"
72
+ ? "danger-full-access"
73
+ : policy.sandbox,
74
+ ...(req.kind === "session"
75
+ ? ["--ask-for-approval", "never"]
76
+ : ["-c", 'approval_policy="never"']),
77
+ ]),
63
78
  ...extra,
64
79
  "--",
65
80
  req.prompt,
66
81
  ];
67
- const readonly = config.sandbox === "read-only";
82
+ const readonly = policy.sandbox === "read-only";
83
+ const mode = policy.permissions === "auto"
84
+ ? "auto"
85
+ : policy.permissions === "bypass"
86
+ ? "bypassPermissions"
87
+ : "dontAsk";
68
88
  const settings = {
69
89
  disableAllHooks: true,
70
90
  permissions: {
71
- defaultMode: "dontAsk",
91
+ defaultMode: mode,
72
92
  deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
73
93
  additionalDirectories: [],
74
94
  },
75
95
  sandbox: {
76
- enabled: config.sandbox !== "danger-full-access",
96
+ enabled: policy.sandbox !== "full-access",
77
97
  failIfUnavailable: true,
78
98
  autoAllowBashIfSandboxed: true,
79
99
  allowUnsandboxedCommands: false,
@@ -87,7 +107,7 @@ export function launchArgs(req, config) {
87
107
  "--tools",
88
108
  "default",
89
109
  "--permission-mode",
90
- "dontAsk",
110
+ mode,
91
111
  "--setting-sources",
92
112
  "",
93
113
  "--strict-mcp-config",
@@ -0,0 +1,92 @@
1
+ import { access, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { constants } from "node:fs";
4
+ import { delimiter, join } from "node:path";
5
+ import { command } from "../identity/processes.js";
6
+ export async function terminalAvailable(platform = process.platform, terminal = "terminal") {
7
+ if (platform !== "darwin")
8
+ return false;
9
+ try {
10
+ if (terminal === "terminal") {
11
+ await access("/System/Applications/Utilities/Terminal.app");
12
+ }
13
+ else {
14
+ const name = terminal === "ghostty" ? "Ghostty.app" : "iTerm.app";
15
+ const found = await Promise.all(["/Applications", join(homedir(), "Applications")].map(async (dir) => {
16
+ try {
17
+ await access(join(dir, name));
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }));
24
+ if (!found.some(Boolean))
25
+ return false;
26
+ }
27
+ await access(terminal === "ghostty" ? "/usr/bin/open" : "/usr/bin/osascript", constants.X_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export async function tmuxExecutable() {
35
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
36
+ if (!dir)
37
+ continue;
38
+ const path = join(dir, "tmux");
39
+ try {
40
+ await access(path, constants.X_OK);
41
+ return await realpath(path);
42
+ }
43
+ catch { }
44
+ }
45
+ throw new Error("Cannot locate tmux executable for terminal viewer");
46
+ }
47
+ export async function openTerminal(tmux, server, hostRef, deadline, terminal = "terminal") {
48
+ if (!/^[a-zA-Z0-9_-]+$/.test(server) || !/^@\d+$/.test(hostRef))
49
+ throw new Error("Invalid tmux terminal target");
50
+ if (Date.now() >= deadline)
51
+ throw new Error("Launch deadline expired before opening terminal");
52
+ const timeout = Math.max(1, Math.min(10000, deadline - Date.now()));
53
+ if (terminal === "ghostty") {
54
+ await command("/usr/bin/open", [
55
+ "-na",
56
+ "Ghostty",
57
+ "--args",
58
+ "--quit-after-last-window-closed=true",
59
+ "-e",
60
+ tmux,
61
+ "-L",
62
+ server,
63
+ "attach-session",
64
+ "-t",
65
+ "muster",
66
+ ";",
67
+ "select-window",
68
+ "-t",
69
+ hostRef,
70
+ ], timeout);
71
+ return;
72
+ }
73
+ const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
74
+ const shellCommand = `${quoted} -L ${server} attach-session -t muster \\; select-window -t ${hostRef}`;
75
+ await command("/usr/bin/osascript", [
76
+ "-e",
77
+ terminal === "iterm2"
78
+ ? `on run argv
79
+ tell application "iTerm"
80
+ create window with default profile command (item 1 of argv)
81
+ activate
82
+ end tell
83
+ end run`
84
+ : `on run argv
85
+ tell application "Terminal"
86
+ activate
87
+ do script (item 1 of argv)
88
+ end tell
89
+ end run`,
90
+ shellCommand,
91
+ ], timeout);
92
+ }
@@ -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(terminal = "terminal") {
28
+ return terminalAvailable(process.platform, terminal);
29
+ }
30
+ async open(hostRef, deadline, terminal = "terminal") {
31
+ await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline, terminal);
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
@@ -13,13 +13,16 @@ const listSchema = z
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] [--terminal APP] [-- 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 on macOS. --terminal accepts auto (Terminal.app),
25
+ terminal, iterm2, or ghostty, and requires --open.
23
26
  run, list and stop default to JSON. output prints raw task text.
24
27
 
25
28
  pty launches print their record and keep this process running. Ctrl-C stops the
@@ -51,7 +54,7 @@ async function main() {
51
54
  });
52
55
  });
53
56
  if (mcp) {
54
- const server = new Server({ name: "muster", version: "0.2.1" }, { capabilities: { tools: {} } });
57
+ const server = new Server({ name: "muster", version: "0.5.0" }, { capabilities: { tools: {} } });
55
58
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
56
59
  tools: [
57
60
  {
@@ -72,6 +75,25 @@ async function main() {
72
75
  type: "string",
73
76
  enum: ["auto", "tmux", "pty", "macos-terminal"],
74
77
  },
78
+ open: {
79
+ type: "boolean",
80
+ description: "Open the tmux session in a terminal viewer (macOS only).",
81
+ },
82
+ terminal: {
83
+ type: "string",
84
+ enum: ["auto", "terminal", "iterm2", "ghostty"],
85
+ description: "Viewer app; requires open. Auto uses Terminal.app.",
86
+ },
87
+ permissions: {
88
+ type: "string",
89
+ enum: ["auto", "deny", "bypass"],
90
+ description: "Defaults to config (deny). Auto requires workspace-write; bypass requires authorized full-access.",
91
+ },
92
+ sandbox: {
93
+ type: "string",
94
+ enum: ["read-only", "workspace-write", "full-access"],
95
+ description: "Defaults to config (read-only). Full access requires config authorization.",
96
+ },
75
97
  args: { type: "array", items: { type: "string" } },
76
98
  },
77
99
  required: ["runtime", "prompt"],
@@ -153,7 +175,11 @@ async function main() {
153
175
  cwd: { type: "string" },
154
176
  kind: { type: "string" },
155
177
  host: { type: "string" },
178
+ open: { type: "boolean" },
179
+ terminal: { type: "string" },
156
180
  format: { type: "string" },
181
+ permissions: { type: "string" },
182
+ sandbox: { type: "string" },
157
183
  },
158
184
  });
159
185
  const { format = "json", ...values } = parsed;
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,24 @@ 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
+ if (req.terminal && !req.open)
56
+ throw new Error("--terminal requires --open");
57
+ const terminal = !req.terminal || req.terminal === "auto" ? "terminal" : req.terminal;
58
+ const requestedHost = req.host ?? this.config.host;
59
+ if (req.open &&
60
+ (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
61
+ throw new Error("--open requires a tmux session on macOS");
62
+ const permissions = resolvePermissions(req, this.config);
55
63
  const argv = launchArgs(req, this.config), driver = req.kind === "session"
56
- ? await selectHost(req.host ?? this.config.host, this.drivers)
64
+ ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
57
65
  : undefined;
66
+ if (req.open &&
67
+ (!driver?.open || !(await driver.openAvailable?.(terminal))))
68
+ throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
58
69
  let policy = [];
59
70
  if (req.runtime === "claude")
60
71
  await assertClaudePolicy(this.env);
61
- const reservation = await this.registry.reserve(req, this.config.max_concurrent);
72
+ const reservation = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent);
62
73
  this.owned.add(reservation.launchId);
63
74
  let entry = reservation;
64
75
  const startedAt = Date.now();
@@ -74,7 +85,9 @@ export class Muster {
74
85
  prompt: req.prompt,
75
86
  host: driver?.id ?? null,
76
87
  requester,
88
+ ...(req.open ? { open: true, terminal } : {}),
77
89
  args: req.args,
90
+ ...permissions,
78
91
  });
79
92
  if (req.runtime === "codex") {
80
93
  policy = await codexPolicyArgs(this.env, req.cwd, deadline);
@@ -121,9 +134,21 @@ export class Muster {
121
134
  throw new Error("Launch cancelled: Muster is closing");
122
135
  if (!(await isSame(root)))
123
136
  throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
137
+ let openingTerminal = false;
124
138
  try {
125
139
  const identity = req.runtime === "codex"
126
- ? await resolveCodex(root.pid, this.env, deadline)
140
+ ? await resolveCodex(root.pid, this.env, deadline, async (id) => {
141
+ try {
142
+ const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
143
+ return (thread?.id === id &&
144
+ thread.source === "cli" &&
145
+ thread.ephemeral !== true &&
146
+ thread.canAcceptDirectInput !== false);
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ })
127
152
  : await resolveClaude(root.pid, this.env, deadline);
128
153
  if (identity) {
129
154
  diagnostic =
@@ -149,6 +174,7 @@ export class Muster {
149
174
  ])[0];
150
175
  const peer = {
151
176
  kind: "session",
177
+ ...permissions,
152
178
  name: named.display,
153
179
  canonical_id: named.canonicalId,
154
180
  runtime,
@@ -162,6 +188,12 @@ export class Muster {
162
188
  capabilities: driver.capabilities(),
163
189
  attach_hint: driver.attachHint(launched.hostRef),
164
190
  };
191
+ if (req.open) {
192
+ openingTerminal = true;
193
+ await driver.open(launched.hostRef, deadline, terminal);
194
+ peer.terminal_opened = true;
195
+ peer.terminal = terminal;
196
+ }
165
197
  entry = await this.registry.update(entry.launchId, {
166
198
  id: identity.id,
167
199
  status: "running",
@@ -176,6 +208,8 @@ export class Muster {
176
208
  }
177
209
  }
178
210
  catch (e) {
211
+ if (openingTerminal)
212
+ throw e;
179
213
  if (Date.now() < deadline)
180
214
  diagnostic = e.message;
181
215
  }
@@ -237,6 +271,8 @@ export class Muster {
237
271
  child.unref();
238
272
  const handle = {
239
273
  kind: "task",
274
+ permissions: entry.permissions,
275
+ sandbox: entry.sandbox,
240
276
  id: entry.id,
241
277
  runtime: entry.runtime,
242
278
  state: "running",
@@ -364,6 +400,9 @@ export class Muster {
364
400
  result.push({
365
401
  ...e.peer,
366
402
  kind: "session",
403
+ ...(e.permissions
404
+ ? { permissions: e.permissions, sandbox: e.sandbox }
405
+ : {}),
367
406
  id: e.id,
368
407
  runtime: e.peer?.runtime ?? e.runtime,
369
408
  state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
@@ -378,6 +417,9 @@ export class Muster {
378
417
  catch { }
379
418
  result.push({
380
419
  kind: "task",
420
+ ...(e.permissions
421
+ ? { permissions: e.permissions, sandbox: e.sandbox }
422
+ : {}),
381
423
  id: e.id,
382
424
  runtime: e.runtime,
383
425
  cwd: e.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.2.1",
3
+ "version": "0.5.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {