@brutalsystems/muster 0.5.0 → 0.7.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/CONTRACT_PROVENANCE.md +69 -0
- package/README.md +146 -28
- package/dist/codex-policy.js +39 -13
- package/dist/config.js +3 -0
- package/dist/format.js +6 -0
- package/dist/guard.js +18 -8
- package/dist/hosts/terminal.js +7 -6
- package/dist/hosts/tmux.js +12 -14
- package/dist/identity/opencode.js +86 -0
- package/dist/mcp-bridge.js +91 -0
- package/dist/mcp.js +207 -0
- package/dist/muster.js +22 -4
- package/dist/naming.js +3 -2
- package/dist/opencode-http.js +241 -0
- package/dist/opencode-policy.js +382 -0
- package/dist/reach/opencode.js +17 -0
- package/dist/run.js +130 -18
- package/dist/task-worker.js +2 -0
- package/package.json +5 -2
- package/test/fixtures/canonical-id.json +2 -2
package/dist/run.js
CHANGED
|
@@ -4,40 +4,47 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
|
-
import { runSchema, launchArgs, launchEnv, resolvePermissions, } from "./guard.js";
|
|
7
|
+
import { runSchema, launchArgs, launchEnv, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
8
8
|
import { Registry } from "./registry.js";
|
|
9
9
|
import { LaunchLog } from "./log.js";
|
|
10
10
|
import { hosts, selectHost } from "./hosts/index.js";
|
|
11
11
|
import { processRef, isSame, stopTree, delay, descendants, } from "./identity/processes.js";
|
|
12
12
|
import { resolveCodex } from "./identity/codex.js";
|
|
13
13
|
import { resolveClaude } from "./identity/claude.js";
|
|
14
|
+
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
14
15
|
import { codexReachable } from "./reach/codex.js";
|
|
15
16
|
import { claudeReachable } from "./reach/claude.js";
|
|
17
|
+
import { openCodeReachable } from "./reach/opencode.js";
|
|
16
18
|
import { assertClaudePolicy } from "./claude-policy.js";
|
|
17
19
|
import { codexPolicyArgs } from "./codex-policy.js";
|
|
18
20
|
import { CodexRpc } from "./codex-rpc.js";
|
|
19
|
-
import {
|
|
21
|
+
import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
|
|
22
|
+
import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
|
|
23
|
+
import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
|
|
24
|
+
import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
|
|
20
25
|
export class Muster {
|
|
21
26
|
config;
|
|
22
27
|
home;
|
|
23
28
|
env;
|
|
24
29
|
drivers;
|
|
30
|
+
sourceEnv;
|
|
25
31
|
registry;
|
|
26
32
|
log;
|
|
27
33
|
owned = new Set();
|
|
28
34
|
closing = false;
|
|
29
35
|
active = new Set();
|
|
30
|
-
constructor(config, home, env, drivers) {
|
|
36
|
+
constructor(config, home, env, drivers, sourceEnv) {
|
|
31
37
|
this.config = config;
|
|
32
38
|
this.home = home;
|
|
33
39
|
this.env = env;
|
|
34
40
|
this.drivers = drivers;
|
|
41
|
+
this.sourceEnv = sourceEnv;
|
|
35
42
|
this.registry = new Registry(home);
|
|
36
43
|
this.log = new LaunchLog(home);
|
|
37
44
|
}
|
|
38
45
|
static async create(opts = {}) {
|
|
39
46
|
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());
|
|
47
|
+
return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(), { ...(opts.env ?? process.env) });
|
|
41
48
|
}
|
|
42
49
|
run(input, requester = "cli") {
|
|
43
50
|
if (this.closing)
|
|
@@ -60,7 +67,13 @@ export class Muster {
|
|
|
60
67
|
(req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
|
|
61
68
|
throw new Error("--open requires a tmux session on macOS");
|
|
62
69
|
const permissions = resolvePermissions(req, this.config);
|
|
63
|
-
const
|
|
70
|
+
const selectedMcp = selectMcp(req, this.config);
|
|
71
|
+
let argv = [];
|
|
72
|
+
if (req.runtime === "opencode")
|
|
73
|
+
runtimeArgs(req);
|
|
74
|
+
else
|
|
75
|
+
argv = launchArgs(req, this.config);
|
|
76
|
+
const driver = req.kind === "session"
|
|
64
77
|
? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
|
|
65
78
|
: undefined;
|
|
66
79
|
if (req.open &&
|
|
@@ -69,6 +82,8 @@ export class Muster {
|
|
|
69
82
|
let policy = [];
|
|
70
83
|
if (req.runtime === "claude")
|
|
71
84
|
await assertClaudePolicy(this.env);
|
|
85
|
+
if (req.runtime === "opencode")
|
|
86
|
+
await assertOpenCodeVersion(this.env, "1.18.31", Date.now() + 4000);
|
|
72
87
|
const reservation = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent);
|
|
73
88
|
this.owned.add(reservation.launchId);
|
|
74
89
|
let entry = reservation;
|
|
@@ -87,20 +102,53 @@ export class Muster {
|
|
|
87
102
|
requester,
|
|
88
103
|
...(req.open ? { open: true, terminal } : {}),
|
|
89
104
|
args: req.args,
|
|
105
|
+
mcp: selectedMcp.map((s) => s.name),
|
|
90
106
|
...permissions,
|
|
91
107
|
});
|
|
108
|
+
const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
|
|
109
|
+
let runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
|
|
110
|
+
entry = await this.registry.update(entry.launchId, prepared.summary);
|
|
111
|
+
let bridgePath;
|
|
112
|
+
if (req.runtime === "claude" && prepared.servers.length) {
|
|
113
|
+
const dir = join(this.home, "mcp");
|
|
114
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
115
|
+
bridgePath = join(dir, entry.launchId + ".json");
|
|
116
|
+
entry = await this.registry.update(entry.launchId, {
|
|
117
|
+
mcpConfigPath: bridgePath,
|
|
118
|
+
});
|
|
119
|
+
await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
|
|
120
|
+
}
|
|
121
|
+
if (req.runtime !== "opencode")
|
|
122
|
+
argv = launchArgs(req, this.config, prepared.servers, bridgePath);
|
|
92
123
|
if (req.runtime === "codex") {
|
|
93
|
-
policy = await codexPolicyArgs(
|
|
124
|
+
policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers);
|
|
94
125
|
argv.splice(1, 0, ...policy);
|
|
95
126
|
}
|
|
127
|
+
else if (req.runtime === "opencode" && req.kind === "session") {
|
|
128
|
+
const port = await allocateLoopbackPort();
|
|
129
|
+
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
130
|
+
const launch = await openCodeSessionLaunch(req, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline);
|
|
131
|
+
argv = launch.argv;
|
|
132
|
+
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
133
|
+
entry = await this.registry.update(entry.launchId, {
|
|
134
|
+
server_url: launch.serverUrl,
|
|
135
|
+
opencode_port: launch.port,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else if (req.runtime === "opencode" && req.kind === "task") {
|
|
139
|
+
const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
|
|
140
|
+
const launch = await openCodeTaskLaunch(req, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline);
|
|
141
|
+
argv = launch.argv;
|
|
142
|
+
runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
|
|
143
|
+
}
|
|
96
144
|
if (this.closing)
|
|
97
145
|
throw new Error("Launch cancelled: Muster is closing");
|
|
98
146
|
if (req.kind === "task")
|
|
99
|
-
return await this.startTask(entry, argv);
|
|
147
|
+
return await this.startTask(entry, argv, runtimeEnv);
|
|
100
148
|
const launched = await driver.launch({
|
|
101
149
|
argv,
|
|
102
150
|
cwd: req.cwd,
|
|
103
|
-
env:
|
|
151
|
+
env: runtimeEnv,
|
|
104
152
|
label: req.prompt.slice(0, 48),
|
|
105
153
|
deadline,
|
|
106
154
|
});
|
|
@@ -113,9 +161,11 @@ export class Muster {
|
|
|
113
161
|
await this.registry.update(entry.launchId, entry);
|
|
114
162
|
let diagnostic = req.runtime === "claude"
|
|
115
163
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
116
|
-
:
|
|
164
|
+
: req.runtime === "opencode"
|
|
165
|
+
? "no OpenCode session matched the launch endpoint, cwd, and creation window"
|
|
166
|
+
: "no descendant runtime identity found";
|
|
117
167
|
if (req.runtime === "codex")
|
|
118
|
-
rpc = new CodexRpc(this.env, req.cwd
|
|
168
|
+
rpc = new CodexRpc(this.env, req.cwd);
|
|
119
169
|
while (Date.now() < deadline) {
|
|
120
170
|
if (deadline - Date.now() < 25)
|
|
121
171
|
break;
|
|
@@ -149,15 +199,21 @@ export class Muster {
|
|
|
149
199
|
return false;
|
|
150
200
|
}
|
|
151
201
|
})
|
|
152
|
-
:
|
|
202
|
+
: req.runtime === "opencode"
|
|
203
|
+
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
|
|
204
|
+
: await resolveClaude(root.pid, this.env, deadline);
|
|
153
205
|
if (identity) {
|
|
154
206
|
diagnostic =
|
|
155
207
|
req.runtime === "codex"
|
|
156
208
|
? "thread id reserved but no rollout"
|
|
157
|
-
:
|
|
209
|
+
: req.runtime === "opencode"
|
|
210
|
+
? "OpenCode session exists but its endpoint status is not readable"
|
|
211
|
+
: "registry exists but inbox socket is not accepting connections";
|
|
158
212
|
let metadata;
|
|
159
213
|
if (req.runtime === "codex")
|
|
160
214
|
metadata = await codexReachable(rpc, identity.id, deadline);
|
|
215
|
+
else if (req.runtime === "opencode")
|
|
216
|
+
metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
|
|
161
217
|
else {
|
|
162
218
|
const claude = identity;
|
|
163
219
|
if (!(await claudeReachable(claude.socketPath, deadline)))
|
|
@@ -168,13 +224,17 @@ export class Muster {
|
|
|
168
224
|
throw new Error("readiness completed after launch deadline");
|
|
169
225
|
if (!(await isSame(root)) || !(await processRef(identity.pid)))
|
|
170
226
|
throw new Error("process exited before readiness completed");
|
|
171
|
-
|
|
227
|
+
if (req.runtime === "opencode" &&
|
|
228
|
+
!(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
|
|
229
|
+
throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
|
|
230
|
+
const runtime = runtimeName(req.runtime);
|
|
172
231
|
const named = assignNames([
|
|
173
232
|
{ runtime, uuid: identity.id, rawName: metadata.rawName },
|
|
174
233
|
])[0];
|
|
175
234
|
const peer = {
|
|
176
235
|
kind: "session",
|
|
177
236
|
...permissions,
|
|
237
|
+
...prepared.summary,
|
|
178
238
|
name: named.display,
|
|
179
239
|
canonical_id: named.canonicalId,
|
|
180
240
|
runtime,
|
|
@@ -182,7 +242,12 @@ export class Muster {
|
|
|
182
242
|
cwd: req.cwd,
|
|
183
243
|
...(req.runtime === "codex"
|
|
184
244
|
? { thread_id: identity.id }
|
|
185
|
-
:
|
|
245
|
+
: req.runtime === "opencode"
|
|
246
|
+
? {
|
|
247
|
+
session_id: identity.id,
|
|
248
|
+
server_url: entry.server_url,
|
|
249
|
+
}
|
|
250
|
+
: { session_id: identity.id }),
|
|
186
251
|
pid: identity.pid,
|
|
187
252
|
host: driver.id,
|
|
188
253
|
capabilities: driver.capabilities(),
|
|
@@ -233,19 +298,20 @@ export class Muster {
|
|
|
233
298
|
await rpc?.close();
|
|
234
299
|
}
|
|
235
300
|
}
|
|
236
|
-
async startTask(entry, argv) {
|
|
301
|
+
async startTask(entry, argv, env) {
|
|
237
302
|
const dir = join(this.home, "tasks", entry.id);
|
|
238
303
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
239
304
|
const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
|
|
240
305
|
await writeFile(specPath, JSON.stringify({
|
|
241
306
|
argv,
|
|
242
307
|
cwd: entry.cwd,
|
|
243
|
-
env
|
|
308
|
+
env,
|
|
244
309
|
outputPath,
|
|
245
310
|
exitPath,
|
|
246
311
|
startPath,
|
|
247
312
|
logHome: this.home,
|
|
248
313
|
launchId: entry.launchId,
|
|
314
|
+
mcpConfigPath: entry.mcpConfigPath,
|
|
249
315
|
}), { mode: 0o600 });
|
|
250
316
|
const child = spawn(process.execPath, [
|
|
251
317
|
fileURLToPath(new URL("../dist/task-worker.js", import.meta.url)),
|
|
@@ -273,6 +339,8 @@ export class Muster {
|
|
|
273
339
|
kind: "task",
|
|
274
340
|
permissions: entry.permissions,
|
|
275
341
|
sandbox: entry.sandbox,
|
|
342
|
+
mcp: entry.mcp ?? [],
|
|
343
|
+
...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
|
|
276
344
|
id: entry.id,
|
|
277
345
|
runtime: entry.runtime,
|
|
278
346
|
state: "running",
|
|
@@ -293,6 +361,26 @@ export class Muster {
|
|
|
293
361
|
}
|
|
294
362
|
}
|
|
295
363
|
async stopEntry(entry) {
|
|
364
|
+
try {
|
|
365
|
+
const openCodeSessionId = entry.peer?.session_id;
|
|
366
|
+
const abortDeadline = Date.now() + 1000;
|
|
367
|
+
if (entry.runtime === "opencode" &&
|
|
368
|
+
entry.root &&
|
|
369
|
+
entry.server_url &&
|
|
370
|
+
typeof openCodeSessionId === "string" &&
|
|
371
|
+
(await isSame(entry.root)) &&
|
|
372
|
+
(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, abortDeadline)))
|
|
373
|
+
await new OpenCodeHttp(entry.server_url)
|
|
374
|
+
.abort(openCodeSessionId, abortDeadline)
|
|
375
|
+
.catch(() => { });
|
|
376
|
+
await this.stopEntryProcesses(entry);
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
if (entry.mcpConfigPath)
|
|
380
|
+
await rm(entry.mcpConfigPath, { force: true });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async stopEntryProcesses(entry) {
|
|
296
384
|
if (!entry.root)
|
|
297
385
|
return;
|
|
298
386
|
if (!(await isSame(entry.root))) {
|
|
@@ -320,7 +408,7 @@ export class Muster {
|
|
|
320
408
|
throw new Error(`Ambiguous durable id: ${exact.map((e) => e.runtime + ":" + e.id).join(", ")}`);
|
|
321
409
|
const peers = all.filter((e) => e.kind === "session" && e.status === "running" && e.peer);
|
|
322
410
|
const named = peers.map((e) => ({
|
|
323
|
-
runtime: e.runtime
|
|
411
|
+
runtime: runtimeName(e.runtime),
|
|
324
412
|
uuid: e.id,
|
|
325
413
|
rawName: String(e.peer.name),
|
|
326
414
|
slug: String(e.peer.canonical_id)
|
|
@@ -356,6 +444,23 @@ export class Muster {
|
|
|
356
444
|
rpc = new CodexRpc(this.env, entry.cwd);
|
|
357
445
|
metadata = await codexReachable(rpc, entry.id, deadline);
|
|
358
446
|
}
|
|
447
|
+
else if (entry.runtime === "opencode") {
|
|
448
|
+
if (!entry.server_url)
|
|
449
|
+
throw new Error("session endpoint missing");
|
|
450
|
+
if (!(await isSame(entry.root)) ||
|
|
451
|
+
!(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
|
|
452
|
+
throw new Error("session endpoint is not owned by the launch");
|
|
453
|
+
metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), {
|
|
454
|
+
id: entry.id,
|
|
455
|
+
pid: entry.root.pid,
|
|
456
|
+
rawName: null,
|
|
457
|
+
cwd: entry.cwd,
|
|
458
|
+
serverUrl: entry.server_url,
|
|
459
|
+
}, deadline);
|
|
460
|
+
if (!(await isSame(entry.root)) ||
|
|
461
|
+
!(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
|
|
462
|
+
throw new Error("session endpoint ownership changed during refresh");
|
|
463
|
+
}
|
|
359
464
|
else {
|
|
360
465
|
const identity = await resolveClaude(entry.root.pid, this.env, deadline);
|
|
361
466
|
if (!identity ||
|
|
@@ -366,7 +471,7 @@ export class Muster {
|
|
|
366
471
|
}
|
|
367
472
|
const named = assignNames([
|
|
368
473
|
{
|
|
369
|
-
runtime: entry.runtime
|
|
474
|
+
runtime: runtimeName(entry.runtime),
|
|
370
475
|
uuid: entry.id,
|
|
371
476
|
rawName: metadata.rawName,
|
|
372
477
|
},
|
|
@@ -377,6 +482,9 @@ export class Muster {
|
|
|
377
482
|
state: metadata.state,
|
|
378
483
|
name: named.display,
|
|
379
484
|
canonical_id: named.canonicalId,
|
|
485
|
+
...(entry.runtime === "opencode"
|
|
486
|
+
? { server_url: entry.server_url }
|
|
487
|
+
: {}),
|
|
380
488
|
},
|
|
381
489
|
});
|
|
382
490
|
}
|
|
@@ -396,6 +504,8 @@ export class Muster {
|
|
|
396
504
|
if (kind && kind !== original.kind)
|
|
397
505
|
continue;
|
|
398
506
|
const e = await this.refreshed(original);
|
|
507
|
+
if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
|
|
508
|
+
await rm(e.mcpConfigPath, { force: true });
|
|
399
509
|
if (e.kind === "session")
|
|
400
510
|
result.push({
|
|
401
511
|
...e.peer,
|
|
@@ -417,6 +527,8 @@ export class Muster {
|
|
|
417
527
|
catch { }
|
|
418
528
|
result.push({
|
|
419
529
|
kind: "task",
|
|
530
|
+
...(e.mcp ? { mcp: e.mcp } : {}),
|
|
531
|
+
...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
|
|
420
532
|
...(e.permissions
|
|
421
533
|
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
422
534
|
: {}),
|
package/dist/task-worker.js
CHANGED
|
@@ -33,6 +33,8 @@ async function finish(code, signal, error) {
|
|
|
33
33
|
finished = true;
|
|
34
34
|
try {
|
|
35
35
|
await output.close();
|
|
36
|
+
if (spec.mcpConfigPath)
|
|
37
|
+
await rm(spec.mcpConfigPath, { force: true });
|
|
36
38
|
await new LaunchLog(spec.logHome).write({
|
|
37
39
|
event: "task_exit",
|
|
38
40
|
launch_id: spec.launchId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brutalsystems/muster",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Launch instructed, reachable local agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -62,5 +62,8 @@
|
|
|
62
62
|
"CANONICAL_ID.md",
|
|
63
63
|
"CONTRACT_PROVENANCE.md",
|
|
64
64
|
"test/fixtures/canonical-id.json"
|
|
65
|
-
]
|
|
65
|
+
],
|
|
66
|
+
"allowScripts": {
|
|
67
|
+
"node-pty@1.1.0": true
|
|
68
|
+
}
|
|
66
69
|
}
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"case": "claude peers are qualified with their runtime",
|
|
34
|
-
"peers": [ { "runtime": "claude-code", "rawName": "
|
|
35
|
-
"expect": [ { "display": "
|
|
34
|
+
"peers": [ { "runtime": "claude-code", "rawName": "billing-api", "uuid": "5af69d42-2214-41d9-b13f-9c3177eb60ce" } ],
|
|
35
|
+
"expect": [ { "display": "billing-api", "canonicalId": "claude-code:billing-api.0ce" } ]
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"case": "collision: every colliding peer is suffixed, and only those",
|