@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 +71 -5
- package/dist/config.js +11 -3
- package/dist/format.js +35 -16
- package/dist/guard.js +43 -24
- package/dist/hosts/terminal.js +47 -0
- package/dist/hosts/tmux.js +7 -0
- package/dist/identity/codex.js +14 -4
- package/dist/muster.js +26 -5
- package/dist/run.js +41 -4
- package/package.json +1 -1
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
|
|
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
|
-
`
|
|
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
|
|
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.
|
|
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([
|
|
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
|
@@ -12,38 +12,57 @@ function argument(value) {
|
|
|
12
12
|
? value
|
|
13
13
|
: "'" + value.replaceAll("'", "'\\''") + "'";
|
|
14
14
|
}
|
|
15
|
-
function
|
|
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
|
|
31
|
+
return `${paint("Stopped", 90)} ${clean(id)}.`;
|
|
19
32
|
const lines = [
|
|
20
|
-
`${clean(record.runtime)} · ${clean(record.kind)} · ${clean(record.state)}`,
|
|
21
|
-
|
|
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(
|
|
25
|
-
lines.push(
|
|
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(
|
|
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(
|
|
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(
|
|
50
|
+
lines.push(field("Signal", clean(record.signal)));
|
|
32
51
|
if (record.error)
|
|
33
|
-
lines.push(
|
|
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(
|
|
55
|
+
lines.push(field("Attach", paint(clean(record.attach_hint), 36)));
|
|
37
56
|
if (record.kind === "task" && id)
|
|
38
|
-
lines.push(
|
|
57
|
+
lines.push(field("Output", paint(`muster output ${argument(clean(id))}`, 36)));
|
|
39
58
|
if (live && id)
|
|
40
|
-
lines.push(
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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:
|
|
90
|
+
defaultMode: mode,
|
|
72
91
|
deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
|
|
73
92
|
additionalDirectories: [],
|
|
74
93
|
},
|
|
75
94
|
sandbox: {
|
|
76
|
-
enabled:
|
|
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
|
-
|
|
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
|
+
}
|
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() {
|
|
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
|
}
|
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
|
@@ -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
|
|
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.
|
|
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"
|
|
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.
|
|
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,
|