@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 +73 -5
- package/dist/config.js +11 -3
- package/dist/format.js +6 -0
- package/dist/guard.js +44 -24
- package/dist/hosts/terminal.js +92 -0
- package/dist/hosts/tmux.js +7 -0
- package/dist/identity/codex.js +14 -4
- package/dist/muster.js +29 -3
- package/dist/run.js +46 -4
- package/package.json +1 -1
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
|
|
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
|
-
`
|
|
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
|
|
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.
|
|
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([
|
|
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 (
|
|
29
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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:
|
|
91
|
+
defaultMode: mode,
|
|
72
92
|
deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
|
|
73
93
|
additionalDirectories: [],
|
|
74
94
|
},
|
|
75
95
|
sandbox: {
|
|
76
|
-
enabled:
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -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
|
}
|
package/dist/identity/codex.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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,
|