@brutalsystems/muster 0.4.0 → 0.6.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 +85 -13
- package/dist/codex-policy.js +39 -13
- package/dist/config.js +3 -0
- package/dist/format.js +5 -1
- package/dist/guard.js +10 -3
- package/dist/hosts/terminal.js +56 -10
- package/dist/hosts/tmux.js +16 -18
- package/dist/mcp-bridge.js +91 -0
- package/dist/mcp.js +207 -0
- package/dist/muster.js +27 -5
- package/dist/run.js +55 -13
- package/dist/task-worker.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,24 +76,36 @@ Add `--open` to display the launched session in a new Terminal.app window on mac
|
|
|
76
76
|
|
|
77
77
|
```sh
|
|
78
78
|
muster run codex --open --prompt "Review this project" --format human
|
|
79
|
-
muster run claude --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
|
|
80
81
|
```
|
|
81
82
|
|
|
82
83
|
MCP `run` accepts `open: true`. This requires tmux and selects it when the host
|
|
83
84
|
is auto; explicit pty hosts and tasks are rejected. Detached launch remains the
|
|
84
|
-
default.
|
|
85
|
-
|
|
86
|
-
|
|
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).
|
|
87
91
|
Closing the Terminal window leaves the session running; use `muster stop <id>`
|
|
88
|
-
to stop it.
|
|
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
|
+
that instance disables saved-window restoration so previous tabs and windows
|
|
95
|
+
are not duplicated. It uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
|
|
96
|
+
Terminal.app creates the requested window before activation
|
|
97
|
+
to avoid also opening a default shell window on startup.
|
|
98
|
+
iTerm2 uses its [documented scripting interface](https://iterm2.com/documentation-scripting.html).
|
|
99
|
+
If the launch request to the viewer fails,
|
|
89
100
|
Muster stops the launched session and returns an error.
|
|
90
101
|
|
|
91
102
|
Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
|
|
92
103
|
and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
|
|
93
104
|
|
|
94
|
-
- **tmux:**
|
|
95
|
-
server.
|
|
96
|
-
|
|
105
|
+
- **tmux:** each agent has its own session on the dedicated `muster` tmux
|
|
106
|
+
server. Opening or exiting one agent does not switch another agent's viewer.
|
|
107
|
+
Watchable and attachable; survives the CLI or MCP server exiting.
|
|
108
|
+
Use the returned attach hint to reconnect to that agent.
|
|
97
109
|
- **pty:** not watchable or attachable. The CLI prints the peer record and stays
|
|
98
110
|
running to own the terminal. Ctrl-C stops it. MCP-owned pty sessions stop when
|
|
99
111
|
the MCP server disconnects. There is no persistent pty daemon.
|
|
@@ -116,6 +128,7 @@ max_concurrent = 4
|
|
|
116
128
|
permissions = "deny"
|
|
117
129
|
sandbox = "read-only"
|
|
118
130
|
allow_dangerous_flags = false
|
|
131
|
+
default_mcp = []
|
|
119
132
|
```
|
|
120
133
|
|
|
121
134
|
Choose `sandbox = "workspace-write"` yourself when agents should edit files.
|
|
@@ -166,8 +179,8 @@ The concurrency cap is shared by separate CLI/MCP processes, including pending
|
|
|
166
179
|
launches. Settings apply to both kinds; tasks are read-only by default.
|
|
167
180
|
|
|
168
181
|
Codex defaults to an explicit sandbox and never-ask approval policy. Muster enumerates
|
|
169
|
-
and explicitly disables inherited MCP servers,
|
|
170
|
-
|
|
182
|
+
and explicitly disables inherited MCP servers, enables only the selected
|
|
183
|
+
operator definitions, then verifies the effective server selection. Hooks, plugins, app connectors, automatic skill-MCP installation
|
|
171
184
|
and external notifications are disabled for the child. Unknown MCP names that
|
|
172
185
|
cannot be addressed safely are refused.
|
|
173
186
|
|
|
@@ -181,9 +194,10 @@ arrive after startup; this v1 is not an enterprise policy-enforcement layer.
|
|
|
181
194
|
Claude permissions and its command sandbox are different mechanisms; neither
|
|
182
195
|
claim implies that every external tool is OS-sandboxed.
|
|
183
196
|
|
|
184
|
-
These child restrictions
|
|
185
|
-
|
|
186
|
-
|
|
197
|
+
These child restrictions prevent automatically propagating spawn authority.
|
|
198
|
+
External tools are enabled only through the operator configuration and launch
|
|
199
|
+
selection described below. The child's runtime inbox remains reachable from an
|
|
200
|
+
external Tin Can even when it has no MCP tools of its own.
|
|
187
201
|
No user-level runtime configuration is rewritten, and workspace-trust dialogs
|
|
188
202
|
are never accepted automatically.
|
|
189
203
|
|
|
@@ -194,6 +208,64 @@ requester, runtime, kind and host. Registry and task outputs also live under
|
|
|
194
208
|
closed: verify no Muster operation is running before removing that directory.
|
|
195
209
|
There is no automatic time-based lock theft.
|
|
196
210
|
|
|
211
|
+
## Optional tools for launched agents
|
|
212
|
+
|
|
213
|
+
MCP servers are optional. Muster does not require or install Tin Can (or any
|
|
214
|
+
other MCP server). Register only servers you want launched agents to use in
|
|
215
|
+
`~/.muster/config.toml`. For example, to use an already-installed Tin Can:
|
|
216
|
+
|
|
217
|
+
```toml
|
|
218
|
+
# Optional personal defaults for sessions. Omit to keep launches MCP-free.
|
|
219
|
+
default_mcp = ["tincan"]
|
|
220
|
+
|
|
221
|
+
[mcp_servers.tincan]
|
|
222
|
+
command = "tincan"
|
|
223
|
+
tools = ["peers", "send_peer", "message_log"]
|
|
224
|
+
required = false
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Then either runtime can use it:
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
muster run codex --mcp tincan --prompt 'Coordinate with the other agent'
|
|
231
|
+
muster run claude --mcp tincan --prompt 'Coordinate with the other agent'
|
|
232
|
+
muster run codex --no-mcp --prompt 'Work without external tools'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`--mcp NAME` is repeatable and **replaces** personal defaults. `--no-mcp` clears
|
|
236
|
+
them and cannot be combined with `--mcp`. MCP `run` takes `mcp: ["tincan"]`;
|
|
237
|
+
`mcp: []` disables all servers. Omitted selection uses `default_mcp` for sessions.
|
|
238
|
+
Tasks have no defaults, but accept an explicit selection.
|
|
239
|
+
|
|
240
|
+
Definitions support either `command` plus optional `args`, `env`, and `env_vars`
|
|
241
|
+
for stdio, or `url` plus optional `bearer_token_env_var` for Streamable HTTP.
|
|
242
|
+
`tools` is a required nonempty list of exact tool names. Use `env_vars` and
|
|
243
|
+
`bearer_token_env_var` to reference credentials in the launch environment;
|
|
244
|
+
credentials are not written to launch logs. HTTP OAuth setup shared across
|
|
245
|
+
runtimes, plugin/app tools, MCP resources and prompts are outside this interface.
|
|
246
|
+
Server and tool names must contain only letters, digits, underscores or hyphens.
|
|
247
|
+
Relative stdio commands run from the requested working directory; prefer an
|
|
248
|
+
absolute executable path or a command on PATH.
|
|
249
|
+
|
|
250
|
+
Before agent launch, Muster initializes each selected server and checks its tool
|
|
251
|
+
catalog. No tool is called during preflight. An explicit `--mcp` selection is
|
|
252
|
+
required: a missing server, missing credential, or missing configured tool fails
|
|
253
|
+
that launch. Defaults are optional unless their definition sets `required = true`;
|
|
254
|
+
an unavailable optional default is skipped with `mcp_warnings`. Each preflight is
|
|
255
|
+
bounded by `startup_timeout_sec` (default 10, maximum 60) and the launch deadline.
|
|
256
|
+
JSON results and `list` report selected logical names in `mcp`; human output shows
|
|
257
|
+
`MCP:` and any warnings. These describe launch configuration, not continuous
|
|
258
|
+
server health.
|
|
259
|
+
|
|
260
|
+
Codex receives isolated definitions, using a fresh runtime name when an inherited
|
|
261
|
+
server has the same name, and its native tool allowlist. Claude receives a private
|
|
262
|
+
Muster stdio connection that filters both tool discovery and calls; newly added
|
|
263
|
+
upstream tools cannot bypass the selection. Only selected tools are preapproved.
|
|
264
|
+
Claude connection specifications are stored with owner-only permissions under
|
|
265
|
+
`~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
|
|
266
|
+
session. MCP server access is separate from filesystem sandbox permissions: a
|
|
267
|
+
selected external tool may have its own write or network capabilities.
|
|
268
|
+
|
|
197
269
|
## MCP installation
|
|
198
270
|
|
|
199
271
|
Installed deliberately, in the one session that should hold spawn authority —
|
package/dist/codex-policy.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import TOML from "@iarna/toml";
|
|
4
|
+
import { codexMcpConfig } from "./mcp.js";
|
|
3
5
|
const exec = promisify(execFile);
|
|
4
|
-
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
|
|
6
|
+
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = []) {
|
|
5
7
|
const args = [
|
|
6
8
|
"--disable",
|
|
7
9
|
"hooks",
|
|
@@ -17,24 +19,48 @@ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
|
|
|
17
19
|
"notify=[]",
|
|
18
20
|
];
|
|
19
21
|
async function list(extra) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
|
|
24
|
+
env,
|
|
25
|
+
cwd,
|
|
26
|
+
timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
|
|
27
|
+
maxBuffer: 1024 * 1024,
|
|
28
|
+
});
|
|
29
|
+
const value = JSON.parse(stdout);
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
throw new Error("Cannot verify effective Codex MCP configuration");
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
28
35
|
throw new Error("Cannot verify effective Codex MCP configuration");
|
|
29
|
-
|
|
36
|
+
}
|
|
30
37
|
}
|
|
31
|
-
|
|
38
|
+
const inherited = await list(args);
|
|
39
|
+
const usedNames = new Set(inherited.map((s) => s.name));
|
|
40
|
+
for (const server of inherited) {
|
|
32
41
|
if (typeof server.name !== "string" ||
|
|
33
42
|
!/^[a-zA-Z0-9_-]+$/.test(server.name))
|
|
34
43
|
throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
|
|
35
44
|
args.push("-c", `mcp_servers.${server.name}.enabled=false`);
|
|
36
45
|
}
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const expected = [];
|
|
47
|
+
for (const server of selected) {
|
|
48
|
+
let name = server.name;
|
|
49
|
+
let suffix = 0;
|
|
50
|
+
// Config layers merge nested maps. A fresh key prevents inherited env,
|
|
51
|
+
// credentials or per-tool policy from surviving the requested definition.
|
|
52
|
+
while (usedNames.has(name))
|
|
53
|
+
name = `muster_${server.name}_${++suffix}`;
|
|
54
|
+
usedNames.add(name);
|
|
55
|
+
expected.push(name);
|
|
56
|
+
args.push("-c", `mcp_servers.${name}=${TOML.stringify.value(codexMcpConfig(server))}`);
|
|
57
|
+
}
|
|
58
|
+
const enabled = (await list(args))
|
|
59
|
+
.filter((s) => s.enabled !== false)
|
|
60
|
+
.map((s) => s.name)
|
|
61
|
+
.sort();
|
|
62
|
+
expected.sort();
|
|
63
|
+
if (JSON.stringify(enabled) !== JSON.stringify(expected))
|
|
64
|
+
throw new Error("Inherited Codex MCP servers remain enabled or requested servers are missing; refusing launch");
|
|
39
65
|
return args;
|
|
40
66
|
}
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import TOML from "@iarna/toml";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { mcpName, mcpServerSchema } from "./mcp.js";
|
|
6
7
|
export const configSchema = z
|
|
7
8
|
.object({
|
|
8
9
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
@@ -17,6 +18,8 @@ export const configSchema = z
|
|
|
17
18
|
"danger-full-access",
|
|
18
19
|
])
|
|
19
20
|
.default("read-only"),
|
|
21
|
+
default_mcp: z.array(mcpName).default([]),
|
|
22
|
+
mcp_servers: z.record(mcpName, mcpServerSchema).default({}),
|
|
20
23
|
allow_dangerous_flags: z.boolean().default(false),
|
|
21
24
|
})
|
|
22
25
|
.strict();
|
package/dist/format.js
CHANGED
|
@@ -40,10 +40,14 @@ function recordHuman(record, color) {
|
|
|
40
40
|
lines.push(field("Permissions", clean(record.permissions)));
|
|
41
41
|
if (record.sandbox)
|
|
42
42
|
lines.push(field("Sandbox", clean(record.sandbox)));
|
|
43
|
+
if (record.mcp)
|
|
44
|
+
lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
|
|
45
|
+
for (const warning of record.mcp_warnings ?? [])
|
|
46
|
+
lines.push(field("MCP warning", clean(warning)));
|
|
43
47
|
if (record.host)
|
|
44
48
|
lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
|
|
45
49
|
if (record.terminal_opened)
|
|
46
|
-
lines.push(field("Terminal", "Terminal.app (opened at launch)
|
|
50
|
+
lines.push(field("Terminal", `${record.terminal === "ghostty" ? "Ghostty" : record.terminal === "iterm2" ? "iTerm2" : "Terminal.app"} (opened at launch)`));
|
|
47
51
|
if (record.exit_code !== undefined)
|
|
48
52
|
lines.push(field("Exit code", badExit ? paint(clean(record.exit_code), 31) : clean(record.exit_code)));
|
|
49
53
|
if (record.signal)
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
3
4
|
export const runSchema = z
|
|
4
5
|
.object({
|
|
5
6
|
runtime: z.enum(["codex", "claude"]),
|
|
@@ -14,8 +15,10 @@ export const runSchema = z
|
|
|
14
15
|
kind: z.enum(["session", "task"]).default("session"),
|
|
15
16
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).optional(),
|
|
16
17
|
open: z.boolean().optional(),
|
|
18
|
+
terminal: z.enum(["auto", "terminal", "iterm2", "ghostty"]).optional(),
|
|
17
19
|
permissions: z.enum(["auto", "deny", "bypass"]).optional(),
|
|
18
20
|
sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
|
|
21
|
+
mcp: z.array(mcpName).optional(),
|
|
19
22
|
args: z.array(z.string()).default([]),
|
|
20
23
|
})
|
|
21
24
|
.strict();
|
|
@@ -52,7 +55,7 @@ function extras(req) {
|
|
|
52
55
|
}
|
|
53
56
|
return result;
|
|
54
57
|
}
|
|
55
|
-
export function launchArgs(req, config) {
|
|
58
|
+
export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
56
59
|
const extra = extras(req);
|
|
57
60
|
const policy = resolvePermissions(req, config);
|
|
58
61
|
if (req.runtime === "codex")
|
|
@@ -88,7 +91,11 @@ export function launchArgs(req, config) {
|
|
|
88
91
|
disableAllHooks: true,
|
|
89
92
|
permissions: {
|
|
90
93
|
defaultMode: mode,
|
|
91
|
-
|
|
94
|
+
allow: mcp.flatMap((s) => s.config.tools.map((t) => `mcp__${s.name}__${t}`)),
|
|
95
|
+
deny: [
|
|
96
|
+
...(readonly ? ["Edit", "Write", "NotebookEdit"] : []),
|
|
97
|
+
...mcp.flatMap((s) => s.excludedTools.map((t) => `mcp__${s.name}__${t}`)),
|
|
98
|
+
],
|
|
92
99
|
additionalDirectories: [],
|
|
93
100
|
},
|
|
94
101
|
sandbox: {
|
|
@@ -111,7 +118,7 @@ export function launchArgs(req, config) {
|
|
|
111
118
|
"",
|
|
112
119
|
"--strict-mcp-config",
|
|
113
120
|
"--mcp-config",
|
|
114
|
-
|
|
121
|
+
JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
|
|
115
122
|
"--settings",
|
|
116
123
|
JSON.stringify(settings),
|
|
117
124
|
...extra,
|
package/dist/hosts/terminal.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { access, realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
4
|
import { delimiter, join } from "node:path";
|
|
4
5
|
import { command } from "../identity/processes.js";
|
|
5
|
-
export async function terminalAvailable(platform = process.platform) {
|
|
6
|
+
export async function terminalAvailable(platform = process.platform, terminal = "terminal") {
|
|
6
7
|
if (platform !== "darwin")
|
|
7
8
|
return false;
|
|
8
9
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
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);
|
|
11
28
|
return true;
|
|
12
29
|
}
|
|
13
30
|
catch {
|
|
@@ -25,23 +42,52 @@ export async function tmuxExecutable() {
|
|
|
25
42
|
}
|
|
26
43
|
catch { }
|
|
27
44
|
}
|
|
28
|
-
throw new Error("Cannot locate tmux executable for
|
|
45
|
+
throw new Error("Cannot locate tmux executable for terminal viewer");
|
|
29
46
|
}
|
|
30
|
-
export async function openTerminal(tmux, server, hostRef, deadline) {
|
|
47
|
+
export async function openTerminal(tmux, server, hostRef, deadline, terminal = "terminal") {
|
|
31
48
|
if (!/^[a-zA-Z0-9_-]+$/.test(server) || !/^@\d+$/.test(hostRef))
|
|
32
49
|
throw new Error("Invalid tmux terminal target");
|
|
33
50
|
if (Date.now() >= deadline)
|
|
34
|
-
throw new Error("Launch deadline expired before opening
|
|
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
|
+
// A separate instance otherwise restores the user's saved tabs/windows.
|
|
60
|
+
"--window-save-state=never",
|
|
61
|
+
"-e",
|
|
62
|
+
tmux,
|
|
63
|
+
"-L",
|
|
64
|
+
server,
|
|
65
|
+
"attach-session",
|
|
66
|
+
"-t",
|
|
67
|
+
hostRef,
|
|
68
|
+
], timeout);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
35
71
|
const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
|
|
36
|
-
const shellCommand = `${quoted} -L ${server} attach-session -t
|
|
72
|
+
const shellCommand = `${quoted} -L ${server} attach-session -t ${hostRef}`;
|
|
73
|
+
// Terminal must create the requested window before activation. Activating
|
|
74
|
+
// a cold app first also creates its default shell window.
|
|
37
75
|
await command("/usr/bin/osascript", [
|
|
38
76
|
"-e",
|
|
39
|
-
|
|
40
|
-
|
|
77
|
+
terminal === "iterm2"
|
|
78
|
+
? `on run argv
|
|
79
|
+
tell application "iTerm"
|
|
80
|
+
create window with default profile command (item 1 of argv)
|
|
41
81
|
activate
|
|
82
|
+
end tell
|
|
83
|
+
end run`
|
|
84
|
+
: `on run argv
|
|
85
|
+
tell application "Terminal"
|
|
86
|
+
launch
|
|
42
87
|
do script (item 1 of argv)
|
|
88
|
+
activate
|
|
43
89
|
end tell
|
|
44
90
|
end run`,
|
|
45
91
|
shellCommand,
|
|
46
|
-
],
|
|
92
|
+
], timeout);
|
|
47
93
|
}
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,11 +25,11 @@ export class TmuxHost {
|
|
|
24
25
|
return false;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
|
-
openAvailable() {
|
|
28
|
-
return terminalAvailable();
|
|
28
|
+
openAvailable(terminal = "terminal") {
|
|
29
|
+
return terminalAvailable(process.platform, terminal);
|
|
29
30
|
}
|
|
30
|
-
async open(hostRef, deadline) {
|
|
31
|
-
await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline);
|
|
31
|
+
async open(hostRef, deadline, terminal = "terminal") {
|
|
32
|
+
await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline, terminal);
|
|
32
33
|
}
|
|
33
34
|
capabilities() {
|
|
34
35
|
return { watchable: true, attachable: true, survivesParentExit: true };
|
|
@@ -52,13 +53,9 @@ export class TmuxHost {
|
|
|
52
53
|
file,
|
|
53
54
|
];
|
|
54
55
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
output = await this.call(["new-window", "-t", "muster", ...common], opts.deadline);
|
|
61
|
-
}
|
|
56
|
+
// Window selection is session-wide: sharing a session makes every
|
|
57
|
+
// attached terminal follow whichever agent was opened most recently.
|
|
58
|
+
const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
|
|
62
59
|
const [hostRef, pid] = output.trim().split("\t");
|
|
63
60
|
if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
|
|
64
61
|
throw new Error("tmux returned an invalid window identity");
|
|
@@ -69,8 +66,7 @@ export class TmuxHost {
|
|
|
69
66
|
try {
|
|
70
67
|
const windows = await this.call([
|
|
71
68
|
"list-windows",
|
|
72
|
-
"-
|
|
73
|
-
"muster",
|
|
69
|
+
"-a",
|
|
74
70
|
"-F",
|
|
75
71
|
"#{window_id}\t#{pane_start_command}",
|
|
76
72
|
]);
|
|
@@ -87,14 +83,16 @@ export class TmuxHost {
|
|
|
87
83
|
try {
|
|
88
84
|
return (await this.call([
|
|
89
85
|
"list-windows",
|
|
90
|
-
"-
|
|
91
|
-
"muster",
|
|
86
|
+
"-a",
|
|
92
87
|
"-F",
|
|
93
|
-
"#{window_id}\t#{pane_pid}\t#{window_name}",
|
|
88
|
+
"#{window_id}\t#{pane_pid}\t#{window_name}\t#{session_name}",
|
|
94
89
|
]))
|
|
95
90
|
.trim()
|
|
96
91
|
.split("\n")
|
|
97
|
-
.filter(
|
|
92
|
+
.filter((l) => {
|
|
93
|
+
const session = l.split("\t")[3];
|
|
94
|
+
return (session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""));
|
|
95
|
+
})
|
|
98
96
|
.map((l) => {
|
|
99
97
|
const [hostRef, pid, label] = l.split("\t");
|
|
100
98
|
return { hostRef: hostRef, pid: Number(pid), label: label };
|
|
@@ -117,6 +115,6 @@ export class TmuxHost {
|
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
attachHint(hostRef) {
|
|
120
|
-
return `tmux -L ${this.server} attach-session -t
|
|
118
|
+
return `tmux -L ${this.server} attach-session -t ${hostRef}`;
|
|
121
119
|
}
|
|
122
120
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Claude has permission rules, but no MCP exposure allowlist. This stdio
|
|
2
|
+
// connection exposes and dispatches only the operator-selected tools, including
|
|
3
|
+
// when the upstream server changes its tool catalog after launch.
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { mcpServerSchema, mcpName, mcpTransport } from "./mcp.js";
|
|
10
|
+
async function main() {
|
|
11
|
+
const [path, name] = process.argv.slice(2);
|
|
12
|
+
if (!path || !name || !mcpName.safeParse(name).success)
|
|
13
|
+
throw new Error("Invalid MCP bridge arguments");
|
|
14
|
+
const definitions = JSON.parse(await readFile(path, "utf8"));
|
|
15
|
+
const config = mcpServerSchema.parse(definitions[name]);
|
|
16
|
+
const client = new Client({ name: "muster-tool-bridge", version: "1" }, { capabilities: {} });
|
|
17
|
+
const server = new Server({ name: `muster-${name}`, version: "1" }, { capabilities: { tools: {} } });
|
|
18
|
+
let shutdown;
|
|
19
|
+
const close = () => (shutdown ??= Promise.resolve().then(async () => {
|
|
20
|
+
await client.close().catch(() => { });
|
|
21
|
+
await server.close().catch(() => { });
|
|
22
|
+
}));
|
|
23
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
24
|
+
process.once(signal, () => {
|
|
25
|
+
void close().finally(() => process.exit(0));
|
|
26
|
+
});
|
|
27
|
+
process.stdin.once("end", () => {
|
|
28
|
+
void close();
|
|
29
|
+
});
|
|
30
|
+
process.stdin.once("close", () => {
|
|
31
|
+
void close();
|
|
32
|
+
});
|
|
33
|
+
server.onclose = () => {
|
|
34
|
+
void close();
|
|
35
|
+
};
|
|
36
|
+
client.onclose = () => {
|
|
37
|
+
void close();
|
|
38
|
+
};
|
|
39
|
+
const transport = mcpTransport(config, process.env, process.cwd());
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
void close().finally(() => process.exit(1));
|
|
42
|
+
}, config.startup_timeout_sec * 1000);
|
|
43
|
+
try {
|
|
44
|
+
await client.connect(transport);
|
|
45
|
+
if (shutdown) {
|
|
46
|
+
await close();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await close();
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
58
|
+
const tools = [];
|
|
59
|
+
let cursor;
|
|
60
|
+
do {
|
|
61
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
62
|
+
tools.push(...page.tools.filter((t) => config.tools.includes(t.name)));
|
|
63
|
+
cursor = page.nextCursor;
|
|
64
|
+
} while (cursor);
|
|
65
|
+
return { tools };
|
|
66
|
+
});
|
|
67
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
68
|
+
if (!config.tools.includes(params.name))
|
|
69
|
+
return {
|
|
70
|
+
isError: true,
|
|
71
|
+
content: [
|
|
72
|
+
{ type: "text", text: "Tool not enabled for this Muster launch" },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
return await client.callTool({
|
|
76
|
+
name: params.name,
|
|
77
|
+
arguments: params.arguments,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
await server.connect(new StdioServerTransport());
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await close();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
main().catch(() => {
|
|
89
|
+
process.stderr.write("Muster MCP connection failed; check the selected server configuration.\n");
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
});
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
+
export const mcpName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
|
|
7
|
+
const environmentName = z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/);
|
|
8
|
+
export const mcpServerSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
command: z.string().min(1).optional(),
|
|
11
|
+
args: z.array(z.string()).default([]),
|
|
12
|
+
url: z
|
|
13
|
+
.string()
|
|
14
|
+
.url()
|
|
15
|
+
.refine((s) => /^https?:\/\//.test(s), "MCP URL must use HTTP(S)")
|
|
16
|
+
.optional(),
|
|
17
|
+
env: z.record(z.string()).default({}),
|
|
18
|
+
env_vars: z.array(environmentName).default([]),
|
|
19
|
+
bearer_token_env_var: environmentName.optional(),
|
|
20
|
+
tools: z.array(mcpName).min(1),
|
|
21
|
+
required: z.boolean().default(false),
|
|
22
|
+
startup_timeout_sec: z.number().positive().max(60).default(10),
|
|
23
|
+
})
|
|
24
|
+
.strict()
|
|
25
|
+
.superRefine((v, ctx) => {
|
|
26
|
+
if (!!v.command === !!v.url)
|
|
27
|
+
ctx.addIssue({
|
|
28
|
+
code: "custom",
|
|
29
|
+
message: "MCP server requires exactly one of command or url",
|
|
30
|
+
});
|
|
31
|
+
if (v.command && v.bearer_token_env_var)
|
|
32
|
+
ctx.addIssue({
|
|
33
|
+
code: "custom",
|
|
34
|
+
message: "bearer_token_env_var requires an HTTP server",
|
|
35
|
+
});
|
|
36
|
+
if (v.url &&
|
|
37
|
+
(v.args.length || Object.keys(v.env).length || v.env_vars.length))
|
|
38
|
+
ctx.addIssue({
|
|
39
|
+
code: "custom",
|
|
40
|
+
message: "args, env and env_vars require a stdio server",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
export function selectMcp(req, config) {
|
|
44
|
+
// Personal defaults apply to sessions, not unattended one-shot tasks.
|
|
45
|
+
const names = req.mcp ?? (req.kind === "session" ? config.default_mcp : []);
|
|
46
|
+
return [...new Set(names)].map((name) => {
|
|
47
|
+
const server = Object.hasOwn(config.mcp_servers, name)
|
|
48
|
+
? config.mcp_servers[name]
|
|
49
|
+
: undefined;
|
|
50
|
+
if (!server)
|
|
51
|
+
throw new Error(`Unknown MCP server: ${name}`);
|
|
52
|
+
return {
|
|
53
|
+
name,
|
|
54
|
+
config: server,
|
|
55
|
+
required: req.mcp !== undefined || server.required,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function mcpEnvironment(servers, base, source) {
|
|
60
|
+
const env = { ...base };
|
|
61
|
+
for (const { config } of servers) {
|
|
62
|
+
for (const key of [
|
|
63
|
+
...config.env_vars,
|
|
64
|
+
...(config.bearer_token_env_var ? [config.bearer_token_env_var] : []),
|
|
65
|
+
]) {
|
|
66
|
+
if (source[key] !== undefined)
|
|
67
|
+
env[key] = source[key];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
function serverEnv(config, env) {
|
|
73
|
+
const result = { ...config.env };
|
|
74
|
+
for (const key of config.env_vars) {
|
|
75
|
+
if (env[key] === undefined)
|
|
76
|
+
throw new Error(`Missing MCP environment variable: ${key}`);
|
|
77
|
+
result[key] = env[key];
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
function headers(config, env) {
|
|
82
|
+
if (!config.bearer_token_env_var)
|
|
83
|
+
return {};
|
|
84
|
+
const value = env[config.bearer_token_env_var];
|
|
85
|
+
if (!value)
|
|
86
|
+
throw new Error("Missing MCP bearer token environment variable");
|
|
87
|
+
return { Authorization: `Bearer ${value}` };
|
|
88
|
+
}
|
|
89
|
+
export function mcpTransport(config, env, cwd) {
|
|
90
|
+
const transport = config.command
|
|
91
|
+
? new StdioClientTransport({
|
|
92
|
+
command: config.command,
|
|
93
|
+
args: config.args,
|
|
94
|
+
env: { ...env, ...serverEnv(config, env) },
|
|
95
|
+
cwd,
|
|
96
|
+
stderr: "pipe",
|
|
97
|
+
})
|
|
98
|
+
: new StreamableHTTPClientTransport(new URL(config.url), {
|
|
99
|
+
requestInit: { headers: headers(config, env) },
|
|
100
|
+
});
|
|
101
|
+
if (transport instanceof StdioClientTransport)
|
|
102
|
+
transport.stderr?.on("data", () => { });
|
|
103
|
+
return transport;
|
|
104
|
+
}
|
|
105
|
+
export async function prepareMcp(selected, env, cwd, deadline) {
|
|
106
|
+
const servers = [], warnings = [];
|
|
107
|
+
for (const server of selected) {
|
|
108
|
+
const client = new Client({ name: "muster-preflight", version: "1" }, { capabilities: {} });
|
|
109
|
+
let timer;
|
|
110
|
+
let diagnostic = "startup or tool discovery failed (check executable, credentials and server configuration)";
|
|
111
|
+
try {
|
|
112
|
+
const timeout = Math.min(server.config.startup_timeout_sec * 1000, deadline - Date.now());
|
|
113
|
+
if (timeout <= 0)
|
|
114
|
+
throw new Error("deadline");
|
|
115
|
+
const transport = mcpTransport(server.config, env, cwd);
|
|
116
|
+
const discover = async () => {
|
|
117
|
+
await client.connect(transport);
|
|
118
|
+
const names = new Set();
|
|
119
|
+
let cursor;
|
|
120
|
+
do {
|
|
121
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
122
|
+
for (const tool of page.tools) {
|
|
123
|
+
if (!mcpName.safeParse(tool.name).success)
|
|
124
|
+
throw new Error("Unsupported tool name");
|
|
125
|
+
names.add(tool.name);
|
|
126
|
+
}
|
|
127
|
+
cursor = page.nextCursor;
|
|
128
|
+
} while (cursor);
|
|
129
|
+
const missing = server.config.tools.filter((name) => !names.has(name));
|
|
130
|
+
if (missing.length) {
|
|
131
|
+
diagnostic = `configured tools not found: ${missing.join(", ")}`;
|
|
132
|
+
throw new Error("missing tools");
|
|
133
|
+
}
|
|
134
|
+
return [...names].filter((name) => !server.config.tools.includes(name));
|
|
135
|
+
};
|
|
136
|
+
const excludedTools = await Promise.race([
|
|
137
|
+
discover(),
|
|
138
|
+
new Promise((_, reject) => {
|
|
139
|
+
timer = setTimeout(() => reject(new Error("timeout")), timeout);
|
|
140
|
+
}),
|
|
141
|
+
]);
|
|
142
|
+
servers.push({ ...server, excludedTools });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Server errors may include tokens or environment values; never log them.
|
|
146
|
+
const message = `MCP server ${server.name}: ${diagnostic}`;
|
|
147
|
+
if (server.required)
|
|
148
|
+
throw new Error(message);
|
|
149
|
+
warnings.push(`${message}; skipped optional default`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
if (timer)
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
await client.close().catch(() => { });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
servers,
|
|
159
|
+
summary: {
|
|
160
|
+
mcp: servers.map((s) => s.name),
|
|
161
|
+
...(warnings.length ? { mcp_warnings: warnings } : {}),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export function codexMcpConfig(server) {
|
|
166
|
+
const c = server.config;
|
|
167
|
+
return {
|
|
168
|
+
...(c.command
|
|
169
|
+
? { command: c.command, args: c.args, env: c.env, env_vars: c.env_vars }
|
|
170
|
+
: {
|
|
171
|
+
url: c.url,
|
|
172
|
+
...(c.bearer_token_env_var
|
|
173
|
+
? { bearer_token_env_var: c.bearer_token_env_var }
|
|
174
|
+
: {}),
|
|
175
|
+
}),
|
|
176
|
+
enabled: true,
|
|
177
|
+
required: server.required,
|
|
178
|
+
enabled_tools: c.tools,
|
|
179
|
+
disabled_tools: [],
|
|
180
|
+
startup_timeout_sec: c.startup_timeout_sec,
|
|
181
|
+
default_tools_approval_mode: "approve",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function claudeMcpConfig(servers, bridgePath) {
|
|
185
|
+
if (servers.length && !bridgePath)
|
|
186
|
+
throw new Error("Missing MCP bridge configuration");
|
|
187
|
+
return {
|
|
188
|
+
mcpServers: Object.fromEntries(servers.map(({ name, config }) => [
|
|
189
|
+
name,
|
|
190
|
+
{
|
|
191
|
+
type: "stdio",
|
|
192
|
+
command: process.execPath,
|
|
193
|
+
args: [
|
|
194
|
+
fileURLToPath(new URL("../dist/mcp-bridge.js", import.meta.url)),
|
|
195
|
+
bridgePath,
|
|
196
|
+
name,
|
|
197
|
+
],
|
|
198
|
+
env: Object.fromEntries([
|
|
199
|
+
...config.env_vars,
|
|
200
|
+
...(config.bearer_token_env_var
|
|
201
|
+
? [config.bearer_token_env_var]
|
|
202
|
+
: []),
|
|
203
|
+
].map((key) => [key, "${" + key + "}"])),
|
|
204
|
+
},
|
|
205
|
+
])),
|
|
206
|
+
};
|
|
207
|
+
}
|
package/dist/muster.js
CHANGED
|
@@ -13,7 +13,7 @@ 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] [--open] [-- 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>
|
|
@@ -21,7 +21,10 @@ const help = `Usage:
|
|
|
21
21
|
|
|
22
22
|
run also accepts --format json|human, --permissions auto|deny|bypass,
|
|
23
23
|
and --sandbox read-only|workspace-write|full-access before -- RUNTIME_OPTIONS.
|
|
24
|
-
--open displays a tmux session
|
|
24
|
+
--open displays a tmux session on macOS. --terminal accepts auto (Terminal.app),
|
|
25
|
+
terminal, iterm2, or ghostty, and requires --open.
|
|
26
|
+
--mcp NAME is repeatable and replaces configured session defaults; --no-mcp
|
|
27
|
+
clears them. Tasks use no MCP defaults; select servers explicitly.
|
|
25
28
|
run, list and stop default to JSON. output prints raw task text.
|
|
26
29
|
|
|
27
30
|
pty launches print their record and keep this process running. Ctrl-C stops the
|
|
@@ -53,7 +56,7 @@ async function main() {
|
|
|
53
56
|
});
|
|
54
57
|
});
|
|
55
58
|
if (mcp) {
|
|
56
|
-
const server = new Server({ name: "muster", version: "0.
|
|
59
|
+
const server = new Server({ name: "muster", version: "0.6.0" }, { capabilities: { tools: {} } });
|
|
57
60
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
58
61
|
tools: [
|
|
59
62
|
{
|
|
@@ -76,7 +79,12 @@ async function main() {
|
|
|
76
79
|
},
|
|
77
80
|
open: {
|
|
78
81
|
type: "boolean",
|
|
79
|
-
description: "Open the tmux session in
|
|
82
|
+
description: "Open the tmux session in a terminal viewer (macOS only).",
|
|
83
|
+
},
|
|
84
|
+
terminal: {
|
|
85
|
+
type: "string",
|
|
86
|
+
enum: ["auto", "terminal", "iterm2", "ghostty"],
|
|
87
|
+
description: "Viewer app; requires open. Auto uses Terminal.app.",
|
|
80
88
|
},
|
|
81
89
|
permissions: {
|
|
82
90
|
type: "string",
|
|
@@ -88,6 +96,11 @@ async function main() {
|
|
|
88
96
|
enum: ["read-only", "workspace-write", "full-access"],
|
|
89
97
|
description: "Defaults to config (read-only). Full access requires config authorization.",
|
|
90
98
|
},
|
|
99
|
+
mcp: {
|
|
100
|
+
type: "array",
|
|
101
|
+
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
102
|
+
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
103
|
+
},
|
|
91
104
|
args: { type: "array", items: { type: "string" } },
|
|
92
105
|
},
|
|
93
106
|
required: ["runtime", "prompt"],
|
|
@@ -170,12 +183,21 @@ async function main() {
|
|
|
170
183
|
kind: { type: "string" },
|
|
171
184
|
host: { type: "string" },
|
|
172
185
|
open: { type: "boolean" },
|
|
186
|
+
terminal: { type: "string" },
|
|
173
187
|
format: { type: "string" },
|
|
174
188
|
permissions: { type: "string" },
|
|
175
189
|
sandbox: { type: "string" },
|
|
190
|
+
mcp: { type: "string", multiple: true },
|
|
191
|
+
"no-mcp": { type: "boolean" },
|
|
176
192
|
},
|
|
177
193
|
});
|
|
178
|
-
const { format = "json", ...values } = parsed;
|
|
194
|
+
const { format = "json", "no-mcp": noMcp, ...values } = parsed;
|
|
195
|
+
if (noMcp !== undefined && command !== "run")
|
|
196
|
+
throw new Error("--no-mcp applies only to run");
|
|
197
|
+
if (noMcp && values.mcp)
|
|
198
|
+
throw new Error("--mcp and --no-mcp cannot be combined");
|
|
199
|
+
if (noMcp)
|
|
200
|
+
values.mcp = [];
|
|
179
201
|
if (format !== "json" && format !== "human")
|
|
180
202
|
throw new Error("format must be json or human");
|
|
181
203
|
if (command === "output" && parsed.format !== undefined)
|
package/dist/run.js
CHANGED
|
@@ -17,27 +17,30 @@ import { assertClaudePolicy } from "./claude-policy.js";
|
|
|
17
17
|
import { codexPolicyArgs } from "./codex-policy.js";
|
|
18
18
|
import { CodexRpc } from "./codex-rpc.js";
|
|
19
19
|
import { assignNames, resolvePeer } from "./naming.js";
|
|
20
|
+
import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
|
|
20
21
|
export class Muster {
|
|
21
22
|
config;
|
|
22
23
|
home;
|
|
23
24
|
env;
|
|
24
25
|
drivers;
|
|
26
|
+
sourceEnv;
|
|
25
27
|
registry;
|
|
26
28
|
log;
|
|
27
29
|
owned = new Set();
|
|
28
30
|
closing = false;
|
|
29
31
|
active = new Set();
|
|
30
|
-
constructor(config, home, env, drivers) {
|
|
32
|
+
constructor(config, home, env, drivers, sourceEnv) {
|
|
31
33
|
this.config = config;
|
|
32
34
|
this.home = home;
|
|
33
35
|
this.env = env;
|
|
34
36
|
this.drivers = drivers;
|
|
37
|
+
this.sourceEnv = sourceEnv;
|
|
35
38
|
this.registry = new Registry(home);
|
|
36
39
|
this.log = new LaunchLog(home);
|
|
37
40
|
}
|
|
38
41
|
static async create(opts = {}) {
|
|
39
42
|
const env = launchEnv(opts.env ?? process.env), home = opts.home ?? join(env.HOME ?? homedir(), ".muster");
|
|
40
|
-
return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts());
|
|
43
|
+
return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(), { ...(opts.env ?? process.env) });
|
|
41
44
|
}
|
|
42
45
|
run(input, requester = "cli") {
|
|
43
46
|
if (this.closing)
|
|
@@ -52,16 +55,22 @@ export class Muster {
|
|
|
52
55
|
throw new Error("cwd is not a directory");
|
|
53
56
|
if (req.kind === "task" && req.host)
|
|
54
57
|
throw new Error("host applies only to session launches");
|
|
58
|
+
if (req.terminal && !req.open)
|
|
59
|
+
throw new Error("--terminal requires --open");
|
|
60
|
+
const terminal = !req.terminal || req.terminal === "auto" ? "terminal" : req.terminal;
|
|
55
61
|
const requestedHost = req.host ?? this.config.host;
|
|
56
62
|
if (req.open &&
|
|
57
63
|
(req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
|
|
58
64
|
throw new Error("--open requires a tmux session on macOS");
|
|
59
65
|
const permissions = resolvePermissions(req, this.config);
|
|
60
|
-
const
|
|
66
|
+
const selectedMcp = selectMcp(req, this.config);
|
|
67
|
+
let argv = launchArgs(req, this.config);
|
|
68
|
+
const driver = req.kind === "session"
|
|
61
69
|
? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
|
|
62
70
|
: undefined;
|
|
63
|
-
if (req.open &&
|
|
64
|
-
|
|
71
|
+
if (req.open &&
|
|
72
|
+
(!driver?.open || !(await driver.openAvailable?.(terminal))))
|
|
73
|
+
throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
|
|
65
74
|
let policy = [];
|
|
66
75
|
if (req.runtime === "claude")
|
|
67
76
|
await assertClaudePolicy(this.env);
|
|
@@ -81,22 +90,37 @@ export class Muster {
|
|
|
81
90
|
prompt: req.prompt,
|
|
82
91
|
host: driver?.id ?? null,
|
|
83
92
|
requester,
|
|
84
|
-
...(req.open ? { open: true } : {}),
|
|
93
|
+
...(req.open ? { open: true, terminal } : {}),
|
|
85
94
|
args: req.args,
|
|
95
|
+
mcp: selectedMcp.map((s) => s.name),
|
|
86
96
|
...permissions,
|
|
87
97
|
});
|
|
98
|
+
const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
|
|
99
|
+
const runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
|
|
100
|
+
entry = await this.registry.update(entry.launchId, prepared.summary);
|
|
101
|
+
let bridgePath;
|
|
102
|
+
if (req.runtime === "claude" && prepared.servers.length) {
|
|
103
|
+
const dir = join(this.home, "mcp");
|
|
104
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
bridgePath = join(dir, entry.launchId + ".json");
|
|
106
|
+
entry = await this.registry.update(entry.launchId, {
|
|
107
|
+
mcpConfigPath: bridgePath,
|
|
108
|
+
});
|
|
109
|
+
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
argv = launchArgs(req, this.config, prepared.servers, bridgePath);
|
|
88
112
|
if (req.runtime === "codex") {
|
|
89
|
-
policy = await codexPolicyArgs(
|
|
113
|
+
policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers);
|
|
90
114
|
argv.splice(1, 0, ...policy);
|
|
91
115
|
}
|
|
92
116
|
if (this.closing)
|
|
93
117
|
throw new Error("Launch cancelled: Muster is closing");
|
|
94
118
|
if (req.kind === "task")
|
|
95
|
-
return await this.startTask(entry, argv);
|
|
119
|
+
return await this.startTask(entry, argv, runtimeEnv);
|
|
96
120
|
const launched = await driver.launch({
|
|
97
121
|
argv,
|
|
98
122
|
cwd: req.cwd,
|
|
99
|
-
env:
|
|
123
|
+
env: runtimeEnv,
|
|
100
124
|
label: req.prompt.slice(0, 48),
|
|
101
125
|
deadline,
|
|
102
126
|
});
|
|
@@ -111,7 +135,7 @@ export class Muster {
|
|
|
111
135
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
112
136
|
: "no descendant runtime identity found";
|
|
113
137
|
if (req.runtime === "codex")
|
|
114
|
-
rpc = new CodexRpc(this.env, req.cwd
|
|
138
|
+
rpc = new CodexRpc(this.env, req.cwd);
|
|
115
139
|
while (Date.now() < deadline) {
|
|
116
140
|
if (deadline - Date.now() < 25)
|
|
117
141
|
break;
|
|
@@ -171,6 +195,7 @@ export class Muster {
|
|
|
171
195
|
const peer = {
|
|
172
196
|
kind: "session",
|
|
173
197
|
...permissions,
|
|
198
|
+
...prepared.summary,
|
|
174
199
|
name: named.display,
|
|
175
200
|
canonical_id: named.canonicalId,
|
|
176
201
|
runtime,
|
|
@@ -186,8 +211,9 @@ export class Muster {
|
|
|
186
211
|
};
|
|
187
212
|
if (req.open) {
|
|
188
213
|
openingTerminal = true;
|
|
189
|
-
await driver.open(launched.hostRef, deadline);
|
|
214
|
+
await driver.open(launched.hostRef, deadline, terminal);
|
|
190
215
|
peer.terminal_opened = true;
|
|
216
|
+
peer.terminal = terminal;
|
|
191
217
|
}
|
|
192
218
|
entry = await this.registry.update(entry.launchId, {
|
|
193
219
|
id: identity.id,
|
|
@@ -228,19 +254,20 @@ export class Muster {
|
|
|
228
254
|
await rpc?.close();
|
|
229
255
|
}
|
|
230
256
|
}
|
|
231
|
-
async startTask(entry, argv) {
|
|
257
|
+
async startTask(entry, argv, env) {
|
|
232
258
|
const dir = join(this.home, "tasks", entry.id);
|
|
233
259
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
234
260
|
const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
|
|
235
261
|
await writeFile(specPath, JSON.stringify({
|
|
236
262
|
argv,
|
|
237
263
|
cwd: entry.cwd,
|
|
238
|
-
env
|
|
264
|
+
env,
|
|
239
265
|
outputPath,
|
|
240
266
|
exitPath,
|
|
241
267
|
startPath,
|
|
242
268
|
logHome: this.home,
|
|
243
269
|
launchId: entry.launchId,
|
|
270
|
+
mcpConfigPath: entry.mcpConfigPath,
|
|
244
271
|
}), { mode: 0o600 });
|
|
245
272
|
const child = spawn(process.execPath, [
|
|
246
273
|
fileURLToPath(new URL("../dist/task-worker.js", import.meta.url)),
|
|
@@ -268,6 +295,8 @@ export class Muster {
|
|
|
268
295
|
kind: "task",
|
|
269
296
|
permissions: entry.permissions,
|
|
270
297
|
sandbox: entry.sandbox,
|
|
298
|
+
mcp: entry.mcp ?? [],
|
|
299
|
+
...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
|
|
271
300
|
id: entry.id,
|
|
272
301
|
runtime: entry.runtime,
|
|
273
302
|
state: "running",
|
|
@@ -288,6 +317,15 @@ export class Muster {
|
|
|
288
317
|
}
|
|
289
318
|
}
|
|
290
319
|
async stopEntry(entry) {
|
|
320
|
+
try {
|
|
321
|
+
await this.stopEntryProcesses(entry);
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
if (entry.mcpConfigPath)
|
|
325
|
+
await rm(entry.mcpConfigPath, { force: true });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async stopEntryProcesses(entry) {
|
|
291
329
|
if (!entry.root)
|
|
292
330
|
return;
|
|
293
331
|
if (!(await isSame(entry.root))) {
|
|
@@ -391,6 +429,8 @@ export class Muster {
|
|
|
391
429
|
if (kind && kind !== original.kind)
|
|
392
430
|
continue;
|
|
393
431
|
const e = await this.refreshed(original);
|
|
432
|
+
if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
|
|
433
|
+
await rm(e.mcpConfigPath, { force: true });
|
|
394
434
|
if (e.kind === "session")
|
|
395
435
|
result.push({
|
|
396
436
|
...e.peer,
|
|
@@ -412,6 +452,8 @@ export class Muster {
|
|
|
412
452
|
catch { }
|
|
413
453
|
result.push({
|
|
414
454
|
kind: "task",
|
|
455
|
+
...(e.mcp ? { mcp: e.mcp } : {}),
|
|
456
|
+
...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
|
|
415
457
|
...(e.permissions
|
|
416
458
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
417
459
|
: {}),
|
package/dist/task-worker.js
CHANGED
|
@@ -33,6 +33,8 @@ async function finish(code, signal, error) {
|
|
|
33
33
|
finished = true;
|
|
34
34
|
try {
|
|
35
35
|
await output.close();
|
|
36
|
+
if (spec.mcpConfigPath)
|
|
37
|
+
await rm(spec.mcpConfigPath, { force: true });
|
|
36
38
|
await new LaunchLog(spec.logHome).write({
|
|
37
39
|
event: "task_exit",
|
|
38
40
|
launch_id: spec.launchId,
|