@brutalsystems/muster 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,16 +76,24 @@ 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
+ it uses the [Ghostty macOS launch interface](https://github.com/ghostty-org/ghostty/discussions/9221).
95
+ iTerm2 uses its [documented scripting interface](https://iterm2.com/documentation-scripting.html).
96
+ If the launch request to the viewer fails,
89
97
  Muster stops the launched session and returns an error.
90
98
 
91
99
  Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
package/dist/format.js CHANGED
@@ -43,7 +43,7 @@ function recordHuman(record, color) {
43
43
  if (record.host)
44
44
  lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
45
45
  if (record.terminal_opened)
46
- lines.push(field("Terminal", "Terminal.app (opened at launch)"));
46
+ lines.push(field("Terminal", `${record.terminal === "ghostty" ? "Ghostty" : record.terminal === "iterm2" ? "iTerm2" : "Terminal.app"} (opened at launch)`));
47
47
  if (record.exit_code !== undefined)
48
48
  lines.push(field("Exit code", badExit ? paint(clean(record.exit_code), 31) : clean(record.exit_code)));
49
49
  if (record.signal)
package/dist/guard.js CHANGED
@@ -14,6 +14,7 @@ export const runSchema = z
14
14
  kind: z.enum(["session", "task"]).default("session"),
15
15
  host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).optional(),
16
16
  open: z.boolean().optional(),
17
+ terminal: z.enum(["auto", "terminal", "iterm2", "ghostty"]).optional(),
17
18
  permissions: z.enum(["auto", "deny", "bypass"]).optional(),
18
19
  sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
19
20
  args: z.array(z.string()).default([]),
@@ -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,51 @@ 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
+ "-e",
60
+ tmux,
61
+ "-L",
62
+ server,
63
+ "attach-session",
64
+ "-t",
65
+ "muster",
66
+ ";",
67
+ "select-window",
68
+ "-t",
69
+ hostRef,
70
+ ], timeout);
71
+ return;
72
+ }
35
73
  const quoted = "'" + tmux.replaceAll("'", "'\\''") + "'";
36
74
  const shellCommand = `${quoted} -L ${server} attach-session -t muster \\; select-window -t ${hostRef}`;
37
75
  await command("/usr/bin/osascript", [
38
76
  "-e",
39
- `on run argv
77
+ terminal === "iterm2"
78
+ ? `on run argv
79
+ tell application "iTerm"
80
+ create window with default profile command (item 1 of argv)
81
+ activate
82
+ end tell
83
+ end run`
84
+ : `on run argv
40
85
  tell application "Terminal"
41
86
  activate
42
87
  do script (item 1 of argv)
43
88
  end tell
44
89
  end run`,
45
90
  shellCommand,
46
- ], Math.max(1, Math.min(10000, deadline - Date.now())));
91
+ ], timeout);
47
92
  }
@@ -24,11 +24,11 @@ export class TmuxHost {
24
24
  return false;
25
25
  }
26
26
  }
27
- openAvailable() {
28
- return terminalAvailable();
27
+ openAvailable(terminal = "terminal") {
28
+ return terminalAvailable(process.platform, terminal);
29
29
  }
30
- async open(hostRef, deadline) {
31
- await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline);
30
+ async open(hostRef, deadline, terminal = "terminal") {
31
+ await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline, terminal);
32
32
  }
33
33
  capabilities() {
34
34
  return { watchable: true, attachable: true, survivesParentExit: true };
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,8 @@ 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.
25
26
  run, list and stop default to JSON. output prints raw task text.
26
27
 
27
28
  pty launches print their record and keep this process running. Ctrl-C stops the
@@ -53,7 +54,7 @@ async function main() {
53
54
  });
54
55
  });
55
56
  if (mcp) {
56
- const server = new Server({ name: "muster", version: "0.4.0" }, { capabilities: { tools: {} } });
57
+ const server = new Server({ name: "muster", version: "0.5.0" }, { capabilities: { tools: {} } });
57
58
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
58
59
  tools: [
59
60
  {
@@ -76,7 +77,12 @@ async function main() {
76
77
  },
77
78
  open: {
78
79
  type: "boolean",
79
- description: "Open the tmux session in Terminal.app (macOS only).",
80
+ description: "Open the tmux session in a terminal viewer (macOS only).",
81
+ },
82
+ terminal: {
83
+ type: "string",
84
+ enum: ["auto", "terminal", "iterm2", "ghostty"],
85
+ description: "Viewer app; requires open. Auto uses Terminal.app.",
80
86
  },
81
87
  permissions: {
82
88
  type: "string",
@@ -170,6 +176,7 @@ async function main() {
170
176
  kind: { type: "string" },
171
177
  host: { type: "string" },
172
178
  open: { type: "boolean" },
179
+ terminal: { type: "string" },
173
180
  format: { type: "string" },
174
181
  permissions: { type: "string" },
175
182
  sandbox: { type: "string" },
package/dist/run.js CHANGED
@@ -52,6 +52,9 @@ export class Muster {
52
52
  throw new Error("cwd is not a directory");
53
53
  if (req.kind === "task" && req.host)
54
54
  throw new Error("host applies only to session launches");
55
+ if (req.terminal && !req.open)
56
+ throw new Error("--terminal requires --open");
57
+ const terminal = !req.terminal || req.terminal === "auto" ? "terminal" : req.terminal;
55
58
  const requestedHost = req.host ?? this.config.host;
56
59
  if (req.open &&
57
60
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
@@ -60,8 +63,9 @@ export class Muster {
60
63
  const argv = launchArgs(req, this.config), driver = req.kind === "session"
61
64
  ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
62
65
  : undefined;
63
- if (req.open && (!driver?.open || !(await driver.openAvailable?.())))
64
- throw new Error("--open requires Terminal.app on macOS and tmux");
66
+ if (req.open &&
67
+ (!driver?.open || !(await driver.openAvailable?.(terminal))))
68
+ throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
65
69
  let policy = [];
66
70
  if (req.runtime === "claude")
67
71
  await assertClaudePolicy(this.env);
@@ -81,7 +85,7 @@ export class Muster {
81
85
  prompt: req.prompt,
82
86
  host: driver?.id ?? null,
83
87
  requester,
84
- ...(req.open ? { open: true } : {}),
88
+ ...(req.open ? { open: true, terminal } : {}),
85
89
  args: req.args,
86
90
  ...permissions,
87
91
  });
@@ -186,8 +190,9 @@ export class Muster {
186
190
  };
187
191
  if (req.open) {
188
192
  openingTerminal = true;
189
- await driver.open(launched.hostRef, deadline);
193
+ await driver.open(launched.hostRef, deadline, terminal);
190
194
  peer.terminal_opened = true;
195
+ peer.terminal = terminal;
191
196
  }
192
197
  entry = await this.registry.update(entry.launchId, {
193
198
  id: identity.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {