@brutalsystems/muster 0.6.0 → 0.7.1

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,19 +4,23 @@ 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";
20
24
  import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
21
25
  export class Muster {
22
26
  config;
@@ -64,7 +68,11 @@ export class Muster {
64
68
  throw new Error("--open requires a tmux session on macOS");
65
69
  const permissions = resolvePermissions(req, this.config);
66
70
  const selectedMcp = selectMcp(req, this.config);
67
- let argv = launchArgs(req, this.config);
71
+ let argv = [];
72
+ if (req.runtime === "opencode")
73
+ runtimeArgs(req);
74
+ else
75
+ argv = launchArgs(req, this.config);
68
76
  const driver = req.kind === "session"
69
77
  ? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
70
78
  : undefined;
@@ -74,6 +82,8 @@ export class Muster {
74
82
  let policy = [];
75
83
  if (req.runtime === "claude")
76
84
  await assertClaudePolicy(this.env);
85
+ if (req.runtime === "opencode")
86
+ await assertOpenCodeVersion(this.env, "1.18.31", Date.now() + 4000);
77
87
  const reservation = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent);
78
88
  this.owned.add(reservation.launchId);
79
89
  let entry = reservation;
@@ -96,7 +106,7 @@ export class Muster {
96
106
  ...permissions,
97
107
  });
98
108
  const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, this.env, this.sourceEnv), req.cwd, deadline);
99
- const runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
109
+ let runtimeEnv = mcpEnvironment(prepared.servers, this.env, this.sourceEnv);
100
110
  entry = await this.registry.update(entry.launchId, prepared.summary);
101
111
  let bridgePath;
102
112
  if (req.runtime === "claude" && prepared.servers.length) {
@@ -108,11 +118,29 @@ export class Muster {
108
118
  });
109
119
  await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
110
120
  }
111
- argv = launchArgs(req, this.config, prepared.servers, bridgePath);
121
+ if (req.runtime !== "opencode")
122
+ argv = launchArgs(req, this.config, prepared.servers, bridgePath);
112
123
  if (req.runtime === "codex") {
113
124
  policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers);
114
125
  argv.splice(1, 0, ...policy);
115
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
+ }
116
144
  if (this.closing)
117
145
  throw new Error("Launch cancelled: Muster is closing");
118
146
  if (req.kind === "task")
@@ -133,7 +161,9 @@ export class Muster {
133
161
  await this.registry.update(entry.launchId, entry);
134
162
  let diagnostic = req.runtime === "claude"
135
163
  ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
136
- : "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";
137
167
  if (req.runtime === "codex")
138
168
  rpc = new CodexRpc(this.env, req.cwd);
139
169
  while (Date.now() < deadline) {
@@ -169,15 +199,21 @@ export class Muster {
169
199
  return false;
170
200
  }
171
201
  })
172
- : 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);
173
205
  if (identity) {
174
206
  diagnostic =
175
207
  req.runtime === "codex"
176
208
  ? "thread id reserved but no rollout"
177
- : "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";
178
212
  let metadata;
179
213
  if (req.runtime === "codex")
180
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);
181
217
  else {
182
218
  const claude = identity;
183
219
  if (!(await claudeReachable(claude.socketPath, deadline)))
@@ -188,7 +224,10 @@ export class Muster {
188
224
  throw new Error("readiness completed after launch deadline");
189
225
  if (!(await isSame(root)) || !(await processRef(identity.pid)))
190
226
  throw new Error("process exited before readiness completed");
191
- 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);
192
231
  const named = assignNames([
193
232
  { runtime, uuid: identity.id, rawName: metadata.rawName },
194
233
  ])[0];
@@ -203,7 +242,12 @@ export class Muster {
203
242
  cwd: req.cwd,
204
243
  ...(req.runtime === "codex"
205
244
  ? { thread_id: identity.id }
206
- : { 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 }),
207
251
  pid: identity.pid,
208
252
  host: driver.id,
209
253
  capabilities: driver.capabilities(),
@@ -318,6 +362,17 @@ export class Muster {
318
362
  }
319
363
  async stopEntry(entry) {
320
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(() => { });
321
376
  await this.stopEntryProcesses(entry);
322
377
  }
323
378
  finally {
@@ -353,7 +408,7 @@ export class Muster {
353
408
  throw new Error(`Ambiguous durable id: ${exact.map((e) => e.runtime + ":" + e.id).join(", ")}`);
354
409
  const peers = all.filter((e) => e.kind === "session" && e.status === "running" && e.peer);
355
410
  const named = peers.map((e) => ({
356
- runtime: e.runtime === "codex" ? "codex" : "claude-code",
411
+ runtime: runtimeName(e.runtime),
357
412
  uuid: e.id,
358
413
  rawName: String(e.peer.name),
359
414
  slug: String(e.peer.canonical_id)
@@ -389,6 +444,23 @@ export class Muster {
389
444
  rpc = new CodexRpc(this.env, entry.cwd);
390
445
  metadata = await codexReachable(rpc, entry.id, deadline);
391
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
+ }
392
464
  else {
393
465
  const identity = await resolveClaude(entry.root.pid, this.env, deadline);
394
466
  if (!identity ||
@@ -399,7 +471,7 @@ export class Muster {
399
471
  }
400
472
  const named = assignNames([
401
473
  {
402
- runtime: entry.runtime === "codex" ? "codex" : "claude-code",
474
+ runtime: runtimeName(entry.runtime),
403
475
  uuid: entry.id,
404
476
  rawName: metadata.rawName,
405
477
  },
@@ -410,6 +482,9 @@ export class Muster {
410
482
  state: metadata.state,
411
483
  name: named.display,
412
484
  canonical_id: named.canonicalId,
485
+ ...(entry.runtime === "opencode"
486
+ ? { server_url: entry.server_url }
487
+ : {}),
413
488
  },
414
489
  });
415
490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",