@brutalsystems/muster 0.7.6 → 0.7.8
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 +14 -4
- package/dist/hosts/tmux.js +40 -23
- package/dist/identity/codex.js +16 -4
- package/dist/reach/codex.js +16 -5
- package/dist/run.js +9 -7
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -37,6 +37,15 @@ Updating on disk is not enough; restart the session too.
|
|
|
37
37
|
|
|
38
38
|
## Build and run locally
|
|
39
39
|
|
|
40
|
+
`npm run check` is the local gate: it builds, runs the suite, then packs and
|
|
41
|
+
verifies the tarball — the same `scripts/check-tarball.mjs` the CI packing job
|
|
42
|
+
runs, so the two cannot disagree about what "verified" means. `npm test` alone
|
|
43
|
+
does not pack anything, so tarball drift is green locally and red on push.
|
|
44
|
+
|
|
45
|
+
It is not everything CI does: CI additionally runs the suite across the Node
|
|
46
|
+
version matrix, and the contract suite runs only on release. Passing `check` is
|
|
47
|
+
necessary rather than sufficient.
|
|
48
|
+
|
|
40
49
|
Requires Node 22.12+ (tested with 24.16), TypeScript 5, Codex 0.155.1, Claude
|
|
41
50
|
Code 2.1.267, and OpenCode 1.18.31 or newer. Newer OpenCode releases must retain
|
|
42
51
|
the CLI and loopback HTTP API used by Muster. Terminal drivers use tmux or
|
|
@@ -447,10 +456,11 @@ built-in list is fixed, so a tool added by a future OpenCode release would not
|
|
|
447
456
|
be denied by it; launches that select no MCP server keep the wildcard. With no plugins
|
|
448
457
|
selected it also uses `--pure` and pins an empty plugin list, so inherited
|
|
449
458
|
plugins do not enter the child process. Selecting a plugin with `--plugin`
|
|
450
|
-
necessarily relaxes that: OpenCode merges project-local
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
459
|
+
necessarily relaxes that: OpenCode merges project-local plugin discovery into
|
|
460
|
+
any non-empty plugin list, so the child also loads whatever plugins the target
|
|
461
|
+
repository ships. It globs both spellings — `.opencode/plugin` and
|
|
462
|
+
`.opencode/plugins` — so auditing only one of them misses half of what a
|
|
463
|
+
repository can load. Select plugins only for repositories trusted with that.
|
|
454
464
|
Claude connection specifications are stored with owner-only permissions under
|
|
455
465
|
`~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
|
|
456
466
|
session. MCP server access is separate from filesystem sandbox permissions: a
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -5,6 +5,41 @@ import { join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { command, processRef, stopTree } from "../identity/processes.js";
|
|
7
7
|
import { terminalAvailable, tmuxExecutable, openTerminal } from "./terminal.js";
|
|
8
|
+
/** tmux rewrites every control character in `-F` output to an underscore when
|
|
9
|
+
* it runs without a UTF-8 locale — LANG, LC_ALL and LC_CTYPE all unset, which is
|
|
10
|
+
* ordinary for a launchd job, a bare `ssh host cmd`, or an agent harness. A tab
|
|
11
|
+
* delimiter then arrives as `@0_11657` and nothing parses. That is #21, filed as
|
|
12
|
+
* a tmux 3.7c bug on two Macs; it is neither version nor machine specific —
|
|
13
|
+
* `env -u LANG -u LC_ALL -u LC_CTYPE` reproduces it anywhere, and exporting a
|
|
14
|
+
* UTF-8 locale makes it vanish on the machines that "had" it.
|
|
15
|
+
*
|
|
16
|
+
* So no format here may contain a character tmux is willing to rewrite.
|
|
17
|
+
* Printable ASCII passes through untouched, and a pipe cannot occur in
|
|
18
|
+
* #{window_id} (@N), #{pane_pid} (digits), or a session name muster generates.
|
|
19
|
+
* The one free-text field is read last and keeps whatever it splits into. */
|
|
20
|
+
const FIELD = "|";
|
|
21
|
+
export const LAUNCH_FORMAT = `#{window_id}${FIELD}#{pane_pid}`;
|
|
22
|
+
export const LIST_FORMAT = `#{window_id}${FIELD}#{pane_pid}${FIELD}#{session_name}${FIELD}#{window_name}`;
|
|
23
|
+
export const CLEANUP_FORMAT = `#{window_id}${FIELD}#{pane_start_command}`;
|
|
24
|
+
export function parseWindowRow(output) {
|
|
25
|
+
const [hostRef, pid] = output.trim().split(FIELD);
|
|
26
|
+
if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
|
|
27
|
+
throw new Error("tmux returned an invalid window identity: " +
|
|
28
|
+
JSON.stringify(output.length > 200 ? output.slice(0, 200) + "\u2026" : output));
|
|
29
|
+
return { hostRef, pid: Number(pid) };
|
|
30
|
+
}
|
|
31
|
+
export function parseWindowList(output) {
|
|
32
|
+
return output
|
|
33
|
+
.trim()
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map((row) => row.split(FIELD))
|
|
36
|
+
.filter(([, , session]) => session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""))
|
|
37
|
+
.map(([hostRef, pid, , ...label]) => ({
|
|
38
|
+
hostRef: hostRef,
|
|
39
|
+
pid: Number(pid),
|
|
40
|
+
label: label.join(FIELD),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
8
43
|
export class TmuxHost {
|
|
9
44
|
server;
|
|
10
45
|
id = "tmux";
|
|
@@ -43,7 +78,7 @@ export class TmuxHost {
|
|
|
43
78
|
"-d",
|
|
44
79
|
"-P",
|
|
45
80
|
"-F",
|
|
46
|
-
|
|
81
|
+
LAUNCH_FORMAT,
|
|
47
82
|
"-n",
|
|
48
83
|
opts.label.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64) || "agent",
|
|
49
84
|
"-c",
|
|
@@ -56,10 +91,7 @@ export class TmuxHost {
|
|
|
56
91
|
// Window selection is session-wide: sharing a session makes every
|
|
57
92
|
// attached terminal follow whichever agent was opened most recently.
|
|
58
93
|
const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
|
|
59
|
-
|
|
60
|
-
if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
|
|
61
|
-
throw new Error("tmux returned an invalid window identity");
|
|
62
|
-
return { hostRef, pid: Number(pid) };
|
|
94
|
+
return parseWindowRow(output);
|
|
63
95
|
}
|
|
64
96
|
catch (e) {
|
|
65
97
|
// The client can time out just after tmux creates the window.
|
|
@@ -68,11 +100,11 @@ export class TmuxHost {
|
|
|
68
100
|
"list-windows",
|
|
69
101
|
"-a",
|
|
70
102
|
"-F",
|
|
71
|
-
|
|
103
|
+
CLEANUP_FORMAT,
|
|
72
104
|
]);
|
|
73
105
|
for (const row of windows.split("\n"))
|
|
74
106
|
if (row.includes(file))
|
|
75
|
-
await this.stop(row.split(
|
|
107
|
+
await this.stop(row.split(FIELD)[0]);
|
|
76
108
|
}
|
|
77
109
|
catch { }
|
|
78
110
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -81,22 +113,7 @@ export class TmuxHost {
|
|
|
81
113
|
}
|
|
82
114
|
async list() {
|
|
83
115
|
try {
|
|
84
|
-
return (await this.call([
|
|
85
|
-
"list-windows",
|
|
86
|
-
"-a",
|
|
87
|
-
"-F",
|
|
88
|
-
"#{window_id}\t#{pane_pid}\t#{window_name}\t#{session_name}",
|
|
89
|
-
]))
|
|
90
|
-
.trim()
|
|
91
|
-
.split("\n")
|
|
92
|
-
.filter((l) => {
|
|
93
|
-
const session = l.split("\t")[3];
|
|
94
|
-
return (session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""));
|
|
95
|
-
})
|
|
96
|
-
.map((l) => {
|
|
97
|
-
const [hostRef, pid, label] = l.split("\t");
|
|
98
|
-
return { hostRef: hostRef, pid: Number(pid), label: label };
|
|
99
|
-
});
|
|
116
|
+
return parseWindowList(await this.call(["list-windows", "-a", "-F", LIST_FORMAT]));
|
|
100
117
|
}
|
|
101
118
|
catch {
|
|
102
119
|
return [];
|
package/dist/identity/codex.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readdir, realpath } from "node:fs/promises";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { command, descendants } from "./processes.js";
|
|
8
|
-
export async function resolveCodex(root, env, deadline,
|
|
8
|
+
export async function resolveCodex(root, env, deadline, rejectionOf) {
|
|
9
9
|
let dir = join(env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex"), "thread-writer-locks");
|
|
10
10
|
try {
|
|
11
11
|
dir = await realpath(dir);
|
|
@@ -49,13 +49,25 @@ export async function resolveCodex(root, env, deadline, isDirectCli) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
let candidates = [...matches];
|
|
52
|
-
if (candidates.length
|
|
52
|
+
if (candidates.length && rejectionOf) {
|
|
53
53
|
// Auto-review owns internal threads in the same process. Inspect only
|
|
54
54
|
// identities already tied to our descendants; never diff global sessions.
|
|
55
|
+
// Screening the sole candidate too costs one extra round trip, and buys the
|
|
56
|
+
// reason a lock cannot be messaged: returning the thread and letting
|
|
57
|
+
// readiness reject it puts that reason after the launch deadline, where the
|
|
58
|
+
// loop discards it (#20). Raised here it becomes the diagnostic, and a
|
|
59
|
+
// later pass can still find a messageable thread.
|
|
55
60
|
const selected = [];
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
let rejection;
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
const reason = await rejectionOf(candidate[0]);
|
|
64
|
+
if (reason === null)
|
|
58
65
|
selected.push(candidate);
|
|
66
|
+
else
|
|
67
|
+
rejection ??= reason;
|
|
68
|
+
}
|
|
69
|
+
if (!selected.length)
|
|
70
|
+
throw new Error(rejection);
|
|
59
71
|
candidates = selected;
|
|
60
72
|
}
|
|
61
73
|
if (candidates.length > 1)
|
package/dist/reach/codex.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** Why a thread cannot serve as a messageable session identity, or null if it
|
|
2
|
+
* can. One source of truth: the launch loop screens candidate writer locks with
|
|
3
|
+
* this, and readiness re-checks it on the thread it settled on. */
|
|
4
|
+
export function threadRejection(thread, id) {
|
|
3
5
|
if (!thread || thread.id !== id)
|
|
4
|
-
|
|
6
|
+
return "thread/read returned no matching thread";
|
|
5
7
|
if (thread.source === "exec")
|
|
6
|
-
|
|
8
|
+
return "source is exec, not a messageable session";
|
|
7
9
|
if (thread.ephemeral === true || thread.canAcceptDirectInput === false)
|
|
8
|
-
|
|
10
|
+
return "thread cannot accept direct input";
|
|
11
|
+
if (thread.source !== "cli")
|
|
12
|
+
return "source is not a direct CLI session";
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export async function codexReachable(rpc, id, deadline) {
|
|
16
|
+
const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
|
|
17
|
+
const rejection = threadRejection(thread, id);
|
|
18
|
+
if (rejection)
|
|
19
|
+
throw new Error(rejection);
|
|
9
20
|
const listing = await rpc.call("thread/list", { limit: 100, useStateDbOnly: true }, deadline);
|
|
10
21
|
const metadata = Array.isArray(listing.data)
|
|
11
22
|
? listing.data.find((t) => t.id === id)
|
package/dist/run.js
CHANGED
|
@@ -12,7 +12,7 @@ import { processRef, isSame, stopTree, delay, descendants, } from "./identity/pr
|
|
|
12
12
|
import { resolveCodex } from "./identity/codex.js";
|
|
13
13
|
import { resolveClaude } from "./identity/claude.js";
|
|
14
14
|
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
15
|
-
import { codexReachable } from "./reach/codex.js";
|
|
15
|
+
import { codexReachable, threadRejection } from "./reach/codex.js";
|
|
16
16
|
import { claudeReachable } from "./reach/claude.js";
|
|
17
17
|
import { openCodeReachable } from "./reach/opencode.js";
|
|
18
18
|
import { assertClaudePolicy } from "./claude-policy.js";
|
|
@@ -195,13 +195,10 @@ export class Muster {
|
|
|
195
195
|
? await resolveCodex(root.pid, this.env, deadline, async (id) => {
|
|
196
196
|
try {
|
|
197
197
|
const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
|
|
198
|
-
return (thread
|
|
199
|
-
thread.source === "cli" &&
|
|
200
|
-
thread.ephemeral !== true &&
|
|
201
|
-
thread.canAcceptDirectInput !== false);
|
|
198
|
+
return threadRejection(thread, id);
|
|
202
199
|
}
|
|
203
|
-
catch {
|
|
204
|
-
return
|
|
200
|
+
catch (e) {
|
|
201
|
+
return e.message;
|
|
205
202
|
}
|
|
206
203
|
})
|
|
207
204
|
: req.runtime === "opencode"
|
|
@@ -280,6 +277,11 @@ export class Muster {
|
|
|
280
277
|
catch (e) {
|
|
281
278
|
if (openingTerminal)
|
|
282
279
|
throw e;
|
|
280
|
+
// An error arriving after the deadline is dropped, because most of
|
|
281
|
+
// them are artifacts of the deadline itself — aborted round trips
|
|
282
|
+
// that would overwrite a real diagnosis with "timed out". The cost is
|
|
283
|
+
// that a genuine answer landing a moment late is lost too, so raise
|
|
284
|
+
// one as early as it is known rather than at readiness (#20).
|
|
283
285
|
if (Date.now() < deadline)
|
|
284
286
|
diagnostic = e.message;
|
|
285
287
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brutalsystems/muster",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"description": "Launch instructed, reachable local agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc && node scripts/prepare-bin.mjs",
|
|
14
14
|
"test": "vitest run",
|
|
15
|
+
"check": "npm test && node scripts/check-tarball.mjs",
|
|
15
16
|
"test:contract": "MUSTER_CONTRACT=1 vitest run test/contract.test.ts",
|
|
16
17
|
"postinstall": "node scripts/prepare-pty.mjs",
|
|
17
18
|
"pretest": "npm run build",
|