@brutalsystems/muster 3.1.1 → 3.2.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/CANONICAL_ID.md +1 -1
- package/CONTRACT_PROVENANCE.md +14 -1
- package/README.md +4 -0
- package/dist/agents.js +9 -4
- package/dist/config.js +57 -6
- package/dist/format.js +1 -1
- package/dist/guard.js +36 -8
- package/dist/help.js +1 -1
- package/dist/hosts/terminal.js +77 -2
- package/dist/hosts/tmux.js +4 -4
- package/dist/hosts/types.js +1 -1
- package/dist/mcp-tools.js +1 -2
- package/dist/run.js +6 -3
- package/package.json +2 -1
package/CANONICAL_ID.md
CHANGED
|
@@ -288,7 +288,7 @@ sessions; the send was a reply, so `reply_misrouted` caught it, but a new
|
|
|
288
288
|
message would have gone through.
|
|
289
289
|
|
|
290
290
|
This is not an exotic collision. It happens whenever a short name and a longer
|
|
291
|
-
one built on it coexist — `muster` / `muster-b1`, `
|
|
291
|
+
one built on it coexist — `muster` / `muster-b1`, `atlas` / `atlas-9b`.
|
|
292
292
|
|
|
293
293
|
**It is kept because it cannot be fixed from the listing alone.** The hazard is
|
|
294
294
|
indistinguishable from the behaviour directly above it in this document: `auth`
|
package/CONTRACT_PROVENANCE.md
CHANGED
|
@@ -513,7 +513,7 @@ The entire delta across both files is three hunks:
|
|
|
513
513
|
- `CANONICAL_ID.md`: the normative version line, `1.9.2` → `2.0.0`.
|
|
514
514
|
- `CANONICAL_ID.md`: the new paragraph described above.
|
|
515
515
|
- the fixture: `tincan_version`, and one `rawName` in the prefix-collision
|
|
516
|
-
case,
|
|
516
|
+
case, renamed to `billing-api`.
|
|
517
517
|
|
|
518
518
|
`naming.ts` does not appear and no expected value moved. The renamed peer is
|
|
519
519
|
**input**, not an expected output, and that case's expected `canonicalId`
|
|
@@ -528,6 +528,19 @@ fixture moved `publish.yml`, `RELEASING.md` and `docs/verification.md` on
|
|
|
528
528
|
their own. Only the two frozen hashes in `test/naming.test.ts` are hand-written,
|
|
529
529
|
and those are the third site behaving as documented.
|
|
530
530
|
|
|
531
|
+
## Local text edit — 2026-10-17
|
|
532
|
+
|
|
533
|
+
One example pair in `CANONICAL_ID.md`'s prefix-collision paragraph used a
|
|
534
|
+
project name from a real machine; it now reads `atlas` / `atlas-9b`. The
|
|
535
|
+
example illustrates the hazard and is not normative, so no rule, case or
|
|
536
|
+
expected value changed. The fixture is untouched.
|
|
537
|
+
|
|
538
|
+
| File | SHA-256 |
|
|
539
|
+
| --- | --- |
|
|
540
|
+
| `CANONICAL_ID.md` | `516d55ba07d191a29c68b74ea1181bc79149ab53b6c821cc9fe6dab14a78b58a` |
|
|
541
|
+
|
|
542
|
+
A future re-sync takes upstream's text for that line.
|
|
543
|
+
|
|
531
544
|
## What the vendored copy covers, and what it deliberately does not
|
|
532
545
|
|
|
533
546
|
The copies pin **the address format Muster generates** — slugify, suffix,
|
package/README.md
CHANGED
|
@@ -77,6 +77,10 @@ muster run codex --kind task --prompt 'Review' --on-exit 'notify "$MUSTER_STATE"
|
|
|
77
77
|
# What is running, and stopping it.
|
|
78
78
|
muster list --format human
|
|
79
79
|
muster stop <id>
|
|
80
|
+
|
|
81
|
+
# Show a session in a terminal: terminal, iterm2, ghostty, or one you define
|
|
82
|
+
# under [open.apps] in config.toml (see docs/launching.md).
|
|
83
|
+
muster run claude --prompt 'Review this project' --open kitty
|
|
80
84
|
```
|
|
81
85
|
|
|
82
86
|
**A Claude launch with no containment flag opens exactly as you would open it
|
package/dist/agents.js
CHANGED
|
@@ -7,8 +7,7 @@ import { setCodexWorkspaceTrust, setWorkspaceTrust } from "./identity-copy.js";
|
|
|
7
7
|
* child's argv twice.
|
|
8
8
|
*
|
|
9
9
|
* Exactly these two spellings. Anything else a caller passes after `--` is in
|
|
10
|
-
* neither list and is refused
|
|
11
|
-
* an error rather than something quietly dropped.
|
|
10
|
+
* neither list and is refused rather than quietly dropped.
|
|
12
11
|
*/
|
|
13
12
|
export const MODEL_FLAGS = ["--model", "-m"];
|
|
14
13
|
/**
|
|
@@ -20,7 +19,10 @@ export const AGENTS = {
|
|
|
20
19
|
claude: {
|
|
21
20
|
configHomeVar: "CLAUDE_CONFIG_DIR",
|
|
22
21
|
enforcement: "kernel",
|
|
23
|
-
runtimeOptions: {
|
|
22
|
+
runtimeOptions: {
|
|
23
|
+
forwarded: ["--effort", "--name"],
|
|
24
|
+
consumed: MODEL_FLAGS,
|
|
25
|
+
},
|
|
24
26
|
trustWorkspace: setWorkspaceTrust,
|
|
25
27
|
beforeStop: null,
|
|
26
28
|
refreshMetadata: refreshClaude,
|
|
@@ -28,7 +30,10 @@ export const AGENTS = {
|
|
|
28
30
|
codex: {
|
|
29
31
|
configHomeVar: "CODEX_HOME",
|
|
30
32
|
enforcement: "kernel",
|
|
31
|
-
runtimeOptions: {
|
|
33
|
+
runtimeOptions: {
|
|
34
|
+
forwarded: ["--add-dir", "-c", "--sandbox"],
|
|
35
|
+
consumed: MODEL_FLAGS,
|
|
36
|
+
},
|
|
32
37
|
trustWorkspace: setCodexWorkspaceTrust,
|
|
33
38
|
beforeStop: null,
|
|
34
39
|
refreshMetadata: refreshCodex,
|
package/dist/config.js
CHANGED
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { mcpName, mcpServerSchema } from "./mcp.js";
|
|
7
7
|
import { pluginName, pluginSchema } from "./plugins.js";
|
|
8
8
|
import { openCodeSectionSchema } from "./providers.js";
|
|
9
|
+
import { builtinTerminals } from "./hosts/types.js";
|
|
9
10
|
import { identityNameSchema } from "./identity-store.js";
|
|
10
11
|
import { authoritySchema, subjectSchema } from "./requester.js";
|
|
11
12
|
import { RequesterConfigError } from "./requester-policy.js";
|
|
@@ -13,7 +14,7 @@ import { RequesterConfigError } from "./requester-policy.js";
|
|
|
13
14
|
* What a remote requester is permitted here.
|
|
14
15
|
*
|
|
15
16
|
* `level` defaults to `normal`: an enrolled remote caller gets a launch like
|
|
16
|
-
* the operator's own, as a local one does
|
|
17
|
+
* the operator's own, as a local one does. Naming `read` or
|
|
17
18
|
* `work` is how a profile caps it. `min_enforcement` follows the level it is
|
|
18
19
|
* written beside — `none` for a normal profile, which muster does not enforce,
|
|
19
20
|
* and `kernel` for a capped one — unless the profile names it.
|
|
@@ -68,6 +69,46 @@ export const requesterEnrolmentSchema = z
|
|
|
68
69
|
profile: z.string().min(1),
|
|
69
70
|
})
|
|
70
71
|
.strict();
|
|
72
|
+
const openerPlaceholder = /\{([^{}]*)\}/g;
|
|
73
|
+
const openerPlaceholders = new Set(["tmux", "server", "target"]);
|
|
74
|
+
/**
|
|
75
|
+
* An extra `--open` target: the argv to run, with `{tmux}`, `{server}` and
|
|
76
|
+
* `{target}` filled in per launch. An argv array and not a string, so a value
|
|
77
|
+
* is never re-parsed by a shell; a program that needs one says so itself, as in
|
|
78
|
+
* `["sh", "-c", "{tmux} -L {server} attach -t {target}"]`.
|
|
79
|
+
*/
|
|
80
|
+
const openerSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
command: z
|
|
83
|
+
.array(z.string().min(1))
|
|
84
|
+
.min(1)
|
|
85
|
+
.superRefine((argv, ctx) => {
|
|
86
|
+
for (const arg of argv)
|
|
87
|
+
for (const m of arg.matchAll(openerPlaceholder))
|
|
88
|
+
if (!openerPlaceholders.has(m[1]))
|
|
89
|
+
ctx.addIssue({
|
|
90
|
+
code: "custom",
|
|
91
|
+
message: `unknown placeholder {${m[1]}}; use {tmux}, {server} or {target}`,
|
|
92
|
+
});
|
|
93
|
+
if (!argv.some((a) => a.includes("{target}")))
|
|
94
|
+
ctx.addIssue({
|
|
95
|
+
code: "custom",
|
|
96
|
+
message: "command must contain {target}",
|
|
97
|
+
});
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
.strict();
|
|
101
|
+
const openSectionSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
apps: z
|
|
104
|
+
.record(z
|
|
105
|
+
.string()
|
|
106
|
+
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
107
|
+
.refine((name) => !["auto", ...builtinTerminals].includes(name), "built-in terminal names cannot be redefined"), openerSchema)
|
|
108
|
+
.default({}),
|
|
109
|
+
})
|
|
110
|
+
.strict()
|
|
111
|
+
.default({});
|
|
71
112
|
/** Raw strings, not parsed durations: a bad value in config.toml must fail with
|
|
72
113
|
* the same message a bad flag does, and that parser lives in one place. */
|
|
73
114
|
const sessionSectionSchema = z
|
|
@@ -83,7 +124,7 @@ const sessionSectionSchema = z
|
|
|
83
124
|
})
|
|
84
125
|
.strict()
|
|
85
126
|
.default({});
|
|
86
|
-
|
|
127
|
+
const configObject = z
|
|
87
128
|
.object({
|
|
88
129
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
89
130
|
/**
|
|
@@ -97,9 +138,8 @@ export const configSchema = z
|
|
|
97
138
|
* hardcoded to Terminal.app, so a developer on Ghostty or iTerm2 had to pass
|
|
98
139
|
* `--terminal` on every launch with no way to state it once.
|
|
99
140
|
*/
|
|
100
|
-
terminal: z
|
|
101
|
-
|
|
102
|
-
.default("terminal"),
|
|
141
|
+
terminal: z.string().min(1).default("terminal"),
|
|
142
|
+
open: openSectionSchema,
|
|
103
143
|
launch_timeout_sec: z.number().finite().positive().max(600).default(30),
|
|
104
144
|
max_concurrent: z.number().int().positive().max(1000).default(4),
|
|
105
145
|
allowed_roots: z.array(z.string().min(1)).default([]),
|
|
@@ -143,7 +183,18 @@ export const configSchema = z
|
|
|
143
183
|
// stay strict for the same reason — an unknown key inside a requester
|
|
144
184
|
// profile is a policy statement that would not apply.
|
|
145
185
|
.strip();
|
|
146
|
-
const
|
|
186
|
+
export const configSchema = configObject.superRefine((config, ctx) => {
|
|
187
|
+
const t = config.terminal;
|
|
188
|
+
if (t !== "auto" &&
|
|
189
|
+
!builtinTerminals.includes(t) &&
|
|
190
|
+
!config.open.apps[t])
|
|
191
|
+
ctx.addIssue({
|
|
192
|
+
code: "custom",
|
|
193
|
+
path: ["terminal"],
|
|
194
|
+
message: `terminal "${t}" is not a built-in (${builtinTerminals.join(", ")}) or defined under [open.apps.${t}]`,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
const knownConfigKeys = new Set(Object.keys(configObject.shape));
|
|
147
198
|
/**
|
|
148
199
|
* Top-level keys this build does not know, sorted. Reported rather than
|
|
149
200
|
* refused, so a newer config degrades to "this Muster ignores that" instead of
|
package/dist/format.js
CHANGED
|
@@ -88,7 +88,7 @@ function recordHuman(record, color) {
|
|
|
88
88
|
if (record.host)
|
|
89
89
|
lines.push(field("Host", `${clean(record.host)}${record.host === "pty" ? " (not watchable or attachable)" : ""}`));
|
|
90
90
|
if (record.terminal_opened)
|
|
91
|
-
lines.push(field("Terminal", `${record.terminal === "ghostty" ? "Ghostty" : record.terminal === "iterm2" ? "iTerm2" : "Terminal.app"} (opened at launch)`));
|
|
91
|
+
lines.push(field("Terminal", `${record.terminal === "ghostty" ? "Ghostty" : record.terminal === "iterm2" ? "iTerm2" : record.terminal && record.terminal !== "terminal" ? record.terminal : "Terminal.app"} (opened at launch)`));
|
|
92
92
|
if (record.exit_code !== undefined)
|
|
93
93
|
lines.push(field("Exit code", badExit ? paint(clean(record.exit_code), 31) : clean(record.exit_code)));
|
|
94
94
|
if (record.signal)
|
package/dist/guard.js
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
7
7
|
import TOML from "@iarna/toml";
|
|
8
8
|
import { claudeConfigDir } from "./identity/claude.js";
|
|
9
|
+
import { builtinTerminals } from "./hosts/types.js";
|
|
9
10
|
import { mcpName } from "./mcp.js";
|
|
10
11
|
import { claudeMcpConfig } from "./claude-policy.js";
|
|
11
12
|
import { pluginName } from "./plugins.js";
|
|
@@ -28,7 +29,10 @@ export const runSchema = z
|
|
|
28
29
|
kind: z.enum(["session", "task"]).default("session"),
|
|
29
30
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).optional(),
|
|
30
31
|
open: z.boolean().optional(),
|
|
31
|
-
terminal: z
|
|
32
|
+
terminal: z
|
|
33
|
+
.string()
|
|
34
|
+
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
35
|
+
.optional(),
|
|
32
36
|
permissions: z.enum(["auto", "deny", "bypass"]).optional(),
|
|
33
37
|
sandbox: z.enum(["read-only", "workspace-write", "full-access"]).optional(),
|
|
34
38
|
mcp: z.array(mcpName).optional(),
|
|
@@ -431,17 +435,33 @@ export function resolveTitle(req, titleable) {
|
|
|
431
435
|
throw new Error(`--title applies to tmux sessions; this launch is ${req.kind === "task" ? "a task" : `hosted on ${req.host ?? "the configured host"}`}`);
|
|
432
436
|
return sanitiseTitle(req.title);
|
|
433
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* `--sandbox` and `-c` are passed to Codex, so they must not be a way round the
|
|
440
|
+
* policy Muster sets: no full-access sandbox, and no config override of the
|
|
441
|
+
* sandbox or the approval policy.
|
|
442
|
+
*/
|
|
443
|
+
function refuseWidening(flag, value) {
|
|
444
|
+
if (flag === "--sandbox" && /full-access/i.test(value))
|
|
445
|
+
throw new Error(`Runtime option refused: --sandbox ${value}`);
|
|
446
|
+
if (flag === "-c" && /^\s*(sandbox|approval_policy)/i.test(value))
|
|
447
|
+
throw new Error(`Runtime option refused: -c ${value}`);
|
|
448
|
+
}
|
|
434
449
|
export function runtimeArgs(req) {
|
|
435
450
|
const result = [];
|
|
436
451
|
const { forwarded, consumed } = AGENTS[req.runtime].runtimeOptions;
|
|
437
452
|
for (let i = 0; i < req.args.length; i++) {
|
|
438
453
|
const arg = req.args[i], flag = normaliseFlag(arg);
|
|
439
|
-
|
|
440
|
-
|
|
454
|
+
// A single-dash flag is case-sensitive: codex's `-C` (working directory)
|
|
455
|
+
// must not pass as `-c` (config override) just because `flag` is lowercased.
|
|
456
|
+
const caseChanged = !arg.startsWith("--") && arg.split("=")[0] !== flag;
|
|
457
|
+
const keep = !caseChanged && forwarded.includes(flag);
|
|
458
|
+
if (caseChanged || (!keep && !consumed.includes(flag)))
|
|
441
459
|
throw new Error(`Runtime option refused: ${arg}`);
|
|
442
460
|
if (arg.includes("=")) {
|
|
443
|
-
if (keep)
|
|
461
|
+
if (keep) {
|
|
462
|
+
refuseWidening(flag, arg.slice(arg.indexOf("=") + 1));
|
|
444
463
|
result.push(arg);
|
|
464
|
+
}
|
|
445
465
|
}
|
|
446
466
|
else {
|
|
447
467
|
// The value is stepped over for a consumed flag exactly as for a
|
|
@@ -450,8 +470,10 @@ export function runtimeArgs(req) {
|
|
|
450
470
|
const value = req.args[++i];
|
|
451
471
|
if (!value || value.startsWith("-"))
|
|
452
472
|
throw new Error(`Missing value for ${arg}`);
|
|
453
|
-
if (keep)
|
|
473
|
+
if (keep) {
|
|
474
|
+
refuseWidening(flag, value);
|
|
454
475
|
result.push(arg, value);
|
|
476
|
+
}
|
|
455
477
|
}
|
|
456
478
|
}
|
|
457
479
|
return result;
|
|
@@ -542,9 +564,15 @@ export function normalizeOpenFlag(argv) {
|
|
|
542
564
|
* app, so it defers to config exactly as an absent value does.
|
|
543
565
|
*/
|
|
544
566
|
export function resolveTerminal(req, config) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
567
|
+
const name = req.terminal && req.terminal !== "auto"
|
|
568
|
+
? req.terminal
|
|
569
|
+
: config.terminal === "auto"
|
|
570
|
+
? "terminal"
|
|
571
|
+
: config.terminal;
|
|
572
|
+
if (!builtinTerminals.includes(name) &&
|
|
573
|
+
!config.open.apps[name])
|
|
574
|
+
throw new Error(`Unknown terminal "${name}": use ${builtinTerminals.join(", ")}, or define it under [open.apps.${name}] in config.toml`);
|
|
575
|
+
return name;
|
|
548
576
|
}
|
|
549
577
|
export function launchArgs(req, config, mcp = [], bridgePath, env = process.env,
|
|
550
578
|
/** Muster's own home, write-protected at workspace-write (#44). */
|
package/dist/help.js
CHANGED
|
@@ -45,7 +45,7 @@ export const COMMANDS = [
|
|
|
45
45
|
["--identity NAME", "run as a configured identity"],
|
|
46
46
|
[
|
|
47
47
|
"--open [APP]",
|
|
48
|
-
|
|
48
|
+
'display the tmux session in a terminal; APP is terminal, iterm2, ghostty (macOS) or a name from [open.apps] in config.toml, defaulting to the configured terminal. An app is an argv template, e.g. [open.apps.kitty] command = ["kitty", "-e", "{tmux}", "-L", "{server}", "attach-session", "-t", "{target}"]',
|
|
49
49
|
],
|
|
50
50
|
[
|
|
51
51
|
"--terminal APP",
|
package/dist/hosts/terminal.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { access, realpath } from "node:fs/promises";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { builtinTerminals } from "./types.js";
|
|
3
5
|
import { constants } from "node:fs";
|
|
4
6
|
import { delimiter, join } from "node:path";
|
|
5
7
|
import { command } from "../identity/processes.js";
|
|
6
|
-
export async function terminalAvailable(platform = process.platform, terminal = "terminal") {
|
|
8
|
+
export async function terminalAvailable(platform = process.platform, terminal = "terminal", opener) {
|
|
9
|
+
if (!isBuiltin(terminal))
|
|
10
|
+
return opener ? openerAvailable(opener) : false;
|
|
7
11
|
if (platform !== "darwin")
|
|
8
12
|
return false;
|
|
9
13
|
try {
|
|
@@ -31,6 +35,63 @@ export async function terminalAvailable(platform = process.platform, terminal =
|
|
|
31
35
|
return false;
|
|
32
36
|
}
|
|
33
37
|
}
|
|
38
|
+
function isBuiltin(terminal) {
|
|
39
|
+
return builtinTerminals.includes(terminal);
|
|
40
|
+
}
|
|
41
|
+
/** The command a configured opener runs: an absolute path, or a name on PATH. */
|
|
42
|
+
async function openerExecutable(file) {
|
|
43
|
+
const dirs = file.includes("/")
|
|
44
|
+
? [""]
|
|
45
|
+
: (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
46
|
+
for (const dir of dirs) {
|
|
47
|
+
const path = file.includes("/") ? file : join(dir, file);
|
|
48
|
+
try {
|
|
49
|
+
await access(path, constants.X_OK);
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function openerAvailable(opener) {
|
|
56
|
+
return (await openerExecutable(opener[0])) !== undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A configured opener's argv with the placeholders filled in. The values are
|
|
60
|
+
* validated by the caller; each argv element stays one element, so nothing here
|
|
61
|
+
* is parsed by a shell.
|
|
62
|
+
*/
|
|
63
|
+
export function openerArgv(opener, values) {
|
|
64
|
+
return opener.map((arg) => arg.replace(/\{(tmux|server|target)\}/g, (_, key) => values[key]));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Starts a configured opener without waiting for it to finish: a terminal that
|
|
68
|
+
* stays in the foreground (`kitty -e …`) runs until its window closes, so
|
|
69
|
+
* waiting would kill it at the timeout. Success is a clean spawn that has not
|
|
70
|
+
* failed within a short grace; a spawn error or a non-zero exit inside the
|
|
71
|
+
* grace is reported.
|
|
72
|
+
*/
|
|
73
|
+
function startOpener(name, file, args, timeout) {
|
|
74
|
+
const grace = Math.min(1500, timeout);
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawn(file, args, { detached: true, stdio: "ignore" });
|
|
77
|
+
const fail = (why) => reject(new Error(`Opener "${name}" failed: ${why}`));
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
child.unref();
|
|
80
|
+
resolve();
|
|
81
|
+
}, grace);
|
|
82
|
+
child.once("error", (e) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
fail(e.message);
|
|
85
|
+
});
|
|
86
|
+
child.once("exit", (code, signal) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
if (code === 0)
|
|
89
|
+
resolve();
|
|
90
|
+
else
|
|
91
|
+
fail(signal ? `killed by ${signal}` : `exit ${code}`);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
34
95
|
export async function tmuxExecutable() {
|
|
35
96
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
36
97
|
if (!dir)
|
|
@@ -44,12 +105,26 @@ export async function tmuxExecutable() {
|
|
|
44
105
|
}
|
|
45
106
|
throw new Error("Cannot locate tmux executable for terminal viewer");
|
|
46
107
|
}
|
|
47
|
-
export async function openTerminal(tmux, server, hostRef, deadline, terminal = "terminal") {
|
|
108
|
+
export async function openTerminal(tmux, server, hostRef, deadline, terminal = "terminal", opener) {
|
|
48
109
|
if (!/^[a-zA-Z0-9_-]+$/.test(server) || !/^@\d+$/.test(hostRef))
|
|
49
110
|
throw new Error("Invalid tmux terminal target");
|
|
50
111
|
if (Date.now() >= deadline)
|
|
51
112
|
throw new Error("Launch deadline expired before opening terminal");
|
|
52
113
|
const timeout = Math.max(1, Math.min(10000, deadline - Date.now()));
|
|
114
|
+
if (!isBuiltin(terminal)) {
|
|
115
|
+
if (!opener)
|
|
116
|
+
throw new Error(`No opener configured for terminal "${terminal}"`);
|
|
117
|
+
const [file, ...args] = openerArgv(opener, {
|
|
118
|
+
tmux,
|
|
119
|
+
server,
|
|
120
|
+
target: hostRef,
|
|
121
|
+
});
|
|
122
|
+
const found = await openerExecutable(file);
|
|
123
|
+
if (!found)
|
|
124
|
+
throw new Error(`Opener "${terminal}": cannot find command "${file}"`);
|
|
125
|
+
await startOpener(terminal, found, args, timeout);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
53
128
|
if (terminal === "ghostty") {
|
|
54
129
|
await command("/usr/bin/open", [
|
|
55
130
|
"-na",
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -176,11 +176,11 @@ export class TmuxHost {
|
|
|
176
176
|
return false;
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
-
openAvailable(terminal = "terminal") {
|
|
180
|
-
return terminalAvailable(process.platform, terminal);
|
|
179
|
+
openAvailable(terminal = "terminal", opener) {
|
|
180
|
+
return terminalAvailable(process.platform, terminal, opener);
|
|
181
181
|
}
|
|
182
|
-
async open(hostRef, deadline, terminal = "terminal") {
|
|
183
|
-
await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline, terminal);
|
|
182
|
+
async open(hostRef, deadline, terminal = "terminal", opener) {
|
|
183
|
+
await openTerminal(await tmuxExecutable(), this.server, hostRef, deadline, terminal, opener);
|
|
184
184
|
}
|
|
185
185
|
capabilities() {
|
|
186
186
|
return { watchable: true, attachable: true, survivesParentExit: true };
|
package/dist/hosts/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const builtinTerminals = ["terminal", "iterm2", "ghostty"];
|
package/dist/mcp-tools.js
CHANGED
|
@@ -38,8 +38,7 @@ export const MCP_TOOLS = [
|
|
|
38
38
|
},
|
|
39
39
|
terminal: {
|
|
40
40
|
type: "string",
|
|
41
|
-
|
|
42
|
-
description: "Viewer app; requires open. Auto uses Terminal.app.",
|
|
41
|
+
description: "Viewer app; requires open. terminal, iterm2, ghostty, or a name defined under [open.apps] in config. Auto uses the configured default.",
|
|
43
42
|
},
|
|
44
43
|
permissions: {
|
|
45
44
|
type: "string",
|
package/dist/run.js
CHANGED
|
@@ -404,9 +404,12 @@ export class Muster {
|
|
|
404
404
|
const driver = req.kind === "session"
|
|
405
405
|
? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
|
|
406
406
|
: undefined;
|
|
407
|
+
const opener = this.config.open.apps[terminal]?.command;
|
|
407
408
|
if (req.open &&
|
|
408
|
-
(!driver?.open || !(await driver.openAvailable?.(terminal))))
|
|
409
|
-
throw new Error(
|
|
409
|
+
(!driver?.open || !(await driver.openAvailable?.(terminal, opener))))
|
|
410
|
+
throw new Error(opener
|
|
411
|
+
? `--open: the command for "${terminal}" was not found (${opener[0]}); check [open.apps.${terminal}] in config.toml, and that tmux is installed`
|
|
412
|
+
: `--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
|
|
410
413
|
// Managed policy is refused because muster cannot prove its own inline
|
|
411
414
|
// settings win over it. A normal launch has no settings of muster's to
|
|
412
415
|
// prove, and obeys managed policy exactly as a hand launch does.
|
|
@@ -865,7 +868,7 @@ export class Muster {
|
|
|
865
868
|
};
|
|
866
869
|
if (req.open) {
|
|
867
870
|
openingTerminal = true;
|
|
868
|
-
await driver.open(launched.hostRef, deadline, terminal);
|
|
871
|
+
await driver.open(launched.hostRef, deadline, terminal, this.config.open.apps[terminal]?.command);
|
|
869
872
|
peer.terminal_opened = true;
|
|
870
873
|
peer.terminal = terminal;
|
|
871
874
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brutalsystems/muster",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Launch instructed, reachable local agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"test": "vitest run",
|
|
15
15
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
16
16
|
"check": "npm run typecheck && npm test && node scripts/check-tarball.mjs",
|
|
17
|
+
"check:public": "scripts/check-public.sh --tree",
|
|
17
18
|
"test:contract": "MUSTER_CONTRACT=1 vitest run test/contract.test.ts",
|
|
18
19
|
"postinstall": "node scripts/prepare-pty.mjs",
|
|
19
20
|
"pretest": "npm run build",
|