@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 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. 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).
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. macOS may require Terminal automation permission. If opening fails,
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:** a window in the dedicated `muster` session on the `muster` tmux
95
- server. Watchable and attachable; survives the CLI or MCP server exiting.
96
- Use the returned attach hint, or `tmux -L muster attach -t muster`.
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, then verifies the effective
170
- configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
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 deliberately prevent propagating spawn authority.
185
- Custom MCP tools—including Tin Can—are not automatically injected into the
186
- child. The child's runtime inbox remains reachable from an external Tin Can.
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 —
@@ -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
- const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
21
- env,
22
- cwd,
23
- timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
24
- maxBuffer: 1024 * 1024,
25
- });
26
- const value = JSON.parse(stdout);
27
- if (!Array.isArray(value))
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
- return value;
36
+ }
30
37
  }
31
- for (const server of await list(args)) {
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
- if ((await list(args)).some((s) => s.enabled !== false))
38
- throw new Error("Inherited Codex MCP servers remain enabled; refusing launch");
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
- deny: readonly ? ["Edit", "Write", "NotebookEdit"] : [],
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
- '{"mcpServers":{}}',
121
+ JSON.stringify(claudeMcpConfig(mcp, bridgePath)),
115
122
  "--settings",
116
123
  JSON.stringify(settings),
117
124
  ...extra,
@@ -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
- await access("/System/Applications/Utilities/Terminal.app");
10
- await access("/usr/bin/osascript", constants.X_OK);
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 Terminal.app");
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 Terminal.app");
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 muster \\; select-window -t ${hostRef}`;
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
- `on run argv
40
- tell application "Terminal"
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
- ], Math.max(1, Math.min(10000, deadline - Date.now())));
92
+ ], timeout);
47
93
  }
@@ -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
- let output;
56
- try {
57
- output = await this.call(["new-session", "-s", "muster", ...common], opts.deadline);
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
- "-t",
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
- "-t",
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(Boolean)
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 muster \\; select-window -t ${hostRef}`;
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 in Terminal.app on macOS.
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.4.0" }, { capabilities: { tools: {} } });
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 Terminal.app (macOS only).",
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 argv = launchArgs(req, this.config), driver = req.kind === "session"
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 && (!driver?.open || !(await driver.openAvailable?.())))
64
- throw new Error("--open requires Terminal.app on macOS and tmux");
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(this.env, req.cwd, deadline);
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: this.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, policy);
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: this.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
  : {}),
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {