@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/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 { assignNames, resolvePeer } from "./naming.js";
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 argv = launchArgs(req, this.config), driver = req.kind === "session"
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(this.env, req.cwd, deadline);
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: this.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
- : "no descendant runtime identity found";
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, policy);
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
- : await resolveClaude(root.pid, this.env, deadline);
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
- : "registry exists but inbox socket is not accepting connections";
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
- const runtime = req.runtime === "codex" ? "codex" : "claude-code";
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
- : { session_id: identity.id }),
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: this.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 === "codex" ? "codex" : "claude-code",
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 === "codex" ? "codex" : "claude-code",
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
  : {}),
@@ -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.5.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": "cohort-api", "uuid": "5af69d42-2214-41d9-b13f-9c3177eb60ce" } ],
35
- "expect": [ { "display": "cohort-api", "canonicalId": "claude-code:cohort-api.0ce" } ]
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",