@brutalsystems/muster 0.1.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 ADDED
@@ -0,0 +1,424 @@
1
+ import { stat, readFile, writeFile, mkdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+ import { loadConfig } from "./config.js";
7
+ import { runSchema, launchArgs, launchEnv } from "./guard.js";
8
+ import { Registry } from "./registry.js";
9
+ import { LaunchLog } from "./log.js";
10
+ import { hosts, selectHost } from "./hosts/index.js";
11
+ import { processRef, isSame, stopTree, delay, descendants, } from "./identity/processes.js";
12
+ import { resolveCodex } from "./identity/codex.js";
13
+ import { resolveClaude } from "./identity/claude.js";
14
+ import { codexReachable } from "./reach/codex.js";
15
+ import { claudeReachable } from "./reach/claude.js";
16
+ import { assertClaudePolicy } from "./claude-policy.js";
17
+ import { codexPolicyArgs } from "./codex-policy.js";
18
+ import { CodexRpc } from "./codex-rpc.js";
19
+ import { assignNames, resolvePeer } from "./naming.js";
20
+ export class Muster {
21
+ config;
22
+ home;
23
+ env;
24
+ drivers;
25
+ registry;
26
+ log;
27
+ owned = new Set();
28
+ closing = false;
29
+ active = new Set();
30
+ constructor(config, home, env, drivers) {
31
+ this.config = config;
32
+ this.home = home;
33
+ this.env = env;
34
+ this.drivers = drivers;
35
+ this.registry = new Registry(home);
36
+ this.log = new LaunchLog(home);
37
+ }
38
+ static async create(opts = {}) {
39
+ 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());
41
+ }
42
+ run(input, requester = "cli") {
43
+ if (this.closing)
44
+ return Promise.reject(new Error("Muster is closing"));
45
+ const operation = this.launch(input, requester);
46
+ this.active.add(operation);
47
+ return operation.finally(() => this.active.delete(operation));
48
+ }
49
+ async launch(input, requester) {
50
+ const req = runSchema.parse(input);
51
+ if (!(await stat(req.cwd)).isDirectory())
52
+ throw new Error("cwd is not a directory");
53
+ if (req.kind === "task" && req.host)
54
+ throw new Error("host applies only to session launches");
55
+ const argv = launchArgs(req, this.config), driver = req.kind === "session"
56
+ ? await selectHost(req.host ?? this.config.host, this.drivers)
57
+ : undefined;
58
+ let policy = [];
59
+ if (req.runtime === "claude")
60
+ await assertClaudePolicy(this.env);
61
+ const reservation = await this.registry.reserve(req, this.config.max_concurrent);
62
+ this.owned.add(reservation.launchId);
63
+ let entry = reservation;
64
+ const startedAt = Date.now();
65
+ const deadline = startedAt + this.config.launch_timeout_sec * 1000;
66
+ let rpc;
67
+ try {
68
+ await this.log.write({
69
+ event: "intent",
70
+ launch_id: entry.launchId,
71
+ runtime: req.runtime,
72
+ kind: req.kind,
73
+ cwd: req.cwd,
74
+ prompt: req.prompt,
75
+ host: driver?.id ?? null,
76
+ requester,
77
+ args: req.args,
78
+ });
79
+ if (req.runtime === "codex") {
80
+ policy = await codexPolicyArgs(this.env, req.cwd, deadline);
81
+ argv.splice(1, 0, ...policy);
82
+ }
83
+ if (this.closing)
84
+ throw new Error("Launch cancelled: Muster is closing");
85
+ if (req.kind === "task")
86
+ return await this.startTask(entry, argv);
87
+ const launched = await driver.launch({
88
+ argv,
89
+ cwd: req.cwd,
90
+ env: this.env,
91
+ label: req.prompt.slice(0, 48),
92
+ deadline,
93
+ });
94
+ const root = await processRef(launched.pid);
95
+ if (!root) {
96
+ await driver.stop(launched.hostRef);
97
+ throw new Error("process exited during launch");
98
+ }
99
+ entry = { ...entry, root, host: driver.id, hostRef: launched.hostRef };
100
+ await this.registry.update(entry.launchId, entry);
101
+ let diagnostic = req.runtime === "claude"
102
+ ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
103
+ : "no descendant runtime identity found";
104
+ if (req.runtime === "codex")
105
+ rpc = new CodexRpc(this.env, req.cwd, policy);
106
+ while (Date.now() < deadline) {
107
+ if (deadline - Date.now() < 25)
108
+ break;
109
+ if (await isSame(root)) {
110
+ const tracked = (await Promise.all((await descendants(root.pid)).map(processRef))).filter((p) => !!p);
111
+ const refs = [...(entry.descendants ?? [])];
112
+ for (const ref of tracked)
113
+ if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
114
+ refs.push(ref);
115
+ if (refs.length !== (entry.descendants ?? []).length) {
116
+ entry = { ...entry, descendants: refs };
117
+ await this.registry.update(entry.launchId, { descendants: refs });
118
+ }
119
+ }
120
+ if (this.closing)
121
+ throw new Error("Launch cancelled: Muster is closing");
122
+ if (!(await isSame(root)))
123
+ throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
124
+ try {
125
+ const identity = req.runtime === "codex"
126
+ ? await resolveCodex(root.pid, this.env, deadline)
127
+ : await resolveClaude(root.pid, this.env, deadline);
128
+ if (identity) {
129
+ diagnostic =
130
+ req.runtime === "codex"
131
+ ? "thread id reserved but no rollout"
132
+ : "registry exists but inbox socket is not accepting connections";
133
+ let metadata;
134
+ if (req.runtime === "codex")
135
+ metadata = await codexReachable(rpc, identity.id, deadline);
136
+ else {
137
+ const claude = identity;
138
+ if (!(await claudeReachable(claude.socketPath, deadline)))
139
+ throw new Error(diagnostic);
140
+ metadata = claude;
141
+ }
142
+ if (Date.now() >= deadline)
143
+ throw new Error("readiness completed after launch deadline");
144
+ if (!(await isSame(root)) || !(await processRef(identity.pid)))
145
+ throw new Error("process exited before readiness completed");
146
+ const runtime = req.runtime === "codex" ? "codex" : "claude-code";
147
+ const named = assignNames([
148
+ { runtime, uuid: identity.id, rawName: metadata.rawName },
149
+ ])[0];
150
+ const peer = {
151
+ kind: "session",
152
+ name: named.display,
153
+ canonical_id: named.canonicalId,
154
+ runtime,
155
+ state: metadata.state,
156
+ cwd: req.cwd,
157
+ ...(req.runtime === "codex"
158
+ ? { thread_id: identity.id }
159
+ : { session_id: identity.id }),
160
+ pid: identity.pid,
161
+ host: driver.id,
162
+ capabilities: driver.capabilities(),
163
+ attach_hint: driver.attachHint(launched.hostRef),
164
+ };
165
+ entry = await this.registry.update(entry.launchId, {
166
+ id: identity.id,
167
+ status: "running",
168
+ peer,
169
+ });
170
+ await this.log.write({
171
+ event: "ready",
172
+ launch_id: entry.launchId,
173
+ peer,
174
+ });
175
+ return peer;
176
+ }
177
+ }
178
+ catch (e) {
179
+ if (Date.now() < deadline)
180
+ diagnostic = e.message;
181
+ }
182
+ await delay(Math.min(250, Math.max(0, deadline - Date.now())));
183
+ }
184
+ throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
185
+ }
186
+ catch (e) {
187
+ await this.stopEntry(entry).catch(() => { });
188
+ const message = e.message;
189
+ await this.registry.update(entry.launchId, {
190
+ status: "failed",
191
+ error: message,
192
+ });
193
+ await this.log
194
+ .write({ event: "failure", launch_id: entry.launchId, error: message })
195
+ .catch(() => { });
196
+ throw e;
197
+ }
198
+ finally {
199
+ await rpc?.close();
200
+ }
201
+ }
202
+ async startTask(entry, argv) {
203
+ const dir = join(this.home, "tasks", entry.id);
204
+ await mkdir(dir, { recursive: true, mode: 0o700 });
205
+ const outputPath = join(dir, "output.txt"), exitPath = join(dir, "exit.json"), startPath = join(dir, "start"), specPath = join(dir, "launch.json");
206
+ await writeFile(specPath, JSON.stringify({
207
+ argv,
208
+ cwd: entry.cwd,
209
+ env: this.env,
210
+ outputPath,
211
+ exitPath,
212
+ startPath,
213
+ logHome: this.home,
214
+ launchId: entry.launchId,
215
+ }), { mode: 0o600 });
216
+ const child = spawn(process.execPath, [
217
+ fileURLToPath(new URL("../dist/task-worker.js", import.meta.url)),
218
+ specPath,
219
+ ], { detached: true, stdio: "ignore" });
220
+ await new Promise((resolve, reject) => {
221
+ child.once("spawn", resolve);
222
+ child.once("error", reject);
223
+ });
224
+ const root = await processRef(child.pid);
225
+ if (!root) {
226
+ child.kill();
227
+ throw new Error("task owner exited during launch");
228
+ }
229
+ try {
230
+ await this.registry.update(entry.launchId, {
231
+ root,
232
+ outputPath,
233
+ exitPath,
234
+ status: "running",
235
+ });
236
+ await writeFile(startPath, "start", { mode: 0o600 });
237
+ child.unref();
238
+ const handle = {
239
+ kind: "task",
240
+ id: entry.id,
241
+ runtime: entry.runtime,
242
+ state: "running",
243
+ cwd: entry.cwd,
244
+ pid: root.pid,
245
+ };
246
+ await this.log.write({
247
+ event: "started",
248
+ launch_id: entry.launchId,
249
+ run: handle,
250
+ });
251
+ return handle;
252
+ }
253
+ catch (e) {
254
+ await stopTree(root);
255
+ await rm(specPath, { force: true });
256
+ throw e;
257
+ }
258
+ }
259
+ async stopEntry(entry) {
260
+ if (!entry.root)
261
+ return;
262
+ if (!(await isSame(entry.root))) {
263
+ for (const ref of entry.descendants ?? [])
264
+ await stopTree(ref);
265
+ return;
266
+ }
267
+ // Validate the persisted process identity before ever using a window reference.
268
+ const driver = this.drivers.find((d) => d.id === entry.host);
269
+ if (driver &&
270
+ entry.hostRef &&
271
+ (await driver.list()).some((s) => s.hostRef === entry.hostRef && s.pid === entry.root.pid))
272
+ await driver.stop(entry.hostRef);
273
+ else
274
+ await stopTree(entry.root);
275
+ for (const ref of entry.descendants ?? [])
276
+ await stopTree(ref);
277
+ }
278
+ async find(id) {
279
+ const all = await this.registry.all();
280
+ const exact = all.filter((e) => e.id === id);
281
+ if (exact.length === 1)
282
+ return exact[0];
283
+ if (exact.length > 1)
284
+ throw new Error(`Ambiguous durable id: ${exact.map((e) => e.runtime + ":" + e.id).join(", ")}`);
285
+ const peers = all.filter((e) => e.kind === "session" && e.status === "running" && e.peer);
286
+ const named = peers.map((e) => ({
287
+ runtime: e.runtime === "codex" ? "codex" : "claude-code",
288
+ uuid: e.id,
289
+ rawName: String(e.peer.name),
290
+ slug: String(e.peer.canonical_id)
291
+ .split(":")[1]
292
+ .replace(/\.[^.]*$/, ""),
293
+ suffix: String(e.peer.canonical_id).split(".").at(-1),
294
+ canonicalId: String(e.peer.canonical_id),
295
+ display: String(e.peer.name),
296
+ }));
297
+ const result = resolvePeer(named, id);
298
+ if (!result.ok)
299
+ throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
300
+ return peers[named.indexOf(result.peer)];
301
+ }
302
+ async stop(id) {
303
+ const entry = await this.find(id);
304
+ await this.stopEntry(entry);
305
+ await this.registry.update(entry.launchId, { status: "stopped" });
306
+ await this.log.write({ event: "stopped", launch_id: entry.launchId });
307
+ return { stopped: true, id: entry.id };
308
+ }
309
+ async refreshed(entry) {
310
+ if (entry.kind !== "session" ||
311
+ entry.status !== "running" ||
312
+ !entry.root ||
313
+ !entry.peer)
314
+ return entry;
315
+ const deadline = Date.now() + 1500;
316
+ let rpc;
317
+ try {
318
+ let metadata;
319
+ if (entry.runtime === "codex") {
320
+ rpc = new CodexRpc(this.env, entry.cwd);
321
+ metadata = await codexReachable(rpc, entry.id, deadline);
322
+ }
323
+ else {
324
+ const identity = await resolveClaude(entry.root.pid, this.env, deadline);
325
+ if (!identity ||
326
+ identity.id !== entry.id ||
327
+ !(await claudeReachable(identity.socketPath, deadline)))
328
+ throw new Error("session unreachable");
329
+ metadata = identity;
330
+ }
331
+ const named = assignNames([
332
+ {
333
+ runtime: entry.runtime === "codex" ? "codex" : "claude-code",
334
+ uuid: entry.id,
335
+ rawName: metadata.rawName,
336
+ },
337
+ ])[0];
338
+ return await this.registry.update(entry.launchId, {
339
+ peer: {
340
+ ...entry.peer,
341
+ state: metadata.state,
342
+ name: named.display,
343
+ canonical_id: named.canonicalId,
344
+ },
345
+ });
346
+ }
347
+ catch {
348
+ return await this.registry.update(entry.launchId, {
349
+ peer: { ...entry.peer, state: "unreachable" },
350
+ });
351
+ }
352
+ finally {
353
+ await rpc?.close();
354
+ }
355
+ }
356
+ async list(kind) {
357
+ const entries = await this.registry.all();
358
+ const result = [];
359
+ for (const original of entries) {
360
+ if (kind && kind !== original.kind)
361
+ continue;
362
+ const e = await this.refreshed(original);
363
+ if (e.kind === "session")
364
+ result.push({
365
+ ...e.peer,
366
+ kind: "session",
367
+ id: e.id,
368
+ runtime: e.peer?.runtime ?? e.runtime,
369
+ state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
370
+ host: e.host,
371
+ ...(e.error ? { error: e.error } : {}),
372
+ });
373
+ else {
374
+ let exit;
375
+ try {
376
+ exit = JSON.parse(await readFile(e.exitPath, "utf8"));
377
+ }
378
+ catch { }
379
+ result.push({
380
+ kind: "task",
381
+ id: e.id,
382
+ runtime: e.runtime,
383
+ cwd: e.cwd,
384
+ state: exit ? "exited" : e.status,
385
+ ...(e.root ? { pid: e.root.pid } : {}),
386
+ ...(exit ? { exit_code: exit.code, signal: exit.signal } : {}),
387
+ ...(e.error ? { error: e.error } : {}),
388
+ });
389
+ }
390
+ }
391
+ return result;
392
+ }
393
+ async output(id) {
394
+ const entry = await this.find(id);
395
+ if (entry.kind !== "task")
396
+ throw new Error("output is available only for task runs");
397
+ if (!entry.outputPath)
398
+ return "";
399
+ try {
400
+ return await readFile(entry.outputPath, "utf8");
401
+ }
402
+ catch (e) {
403
+ if (e.code === "ENOENT")
404
+ return "";
405
+ throw e;
406
+ }
407
+ }
408
+ async close() {
409
+ this.closing = true;
410
+ await Promise.allSettled([...this.active]);
411
+ for (const entry of await this.registry.all())
412
+ if (entry.host === "pty" &&
413
+ this.owned.has(entry.launchId) &&
414
+ entry.status === "running") {
415
+ await this.stopEntry(entry);
416
+ await this.registry.update(entry.launchId, { status: "stopped" });
417
+ }
418
+ }
419
+ async hasOwnedPty() {
420
+ return (await this.registry.all()).some((e) => e.host === "pty" &&
421
+ this.owned.has(e.launchId) &&
422
+ e.status === "running");
423
+ }
424
+ }
@@ -0,0 +1,65 @@
1
+ // A per-task owner captures output and the exit result; no pools, retries or daemon.
2
+ import { readFile, writeFile, rm, open } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { delay, finishOwnedGroup, processRef, stopTree, } from "./identity/processes.js";
5
+ import { LaunchLog } from "./log.js";
6
+ const file = process.argv[2];
7
+ const spec = JSON.parse(await readFile(file, "utf8"));
8
+ await rm(file);
9
+ // The parent records ownership before authorizing the runtime to start.
10
+ const deadline = Date.now() + 10000;
11
+ while (true) {
12
+ try {
13
+ await readFile(spec.startPath);
14
+ await rm(spec.startPath);
15
+ break;
16
+ }
17
+ catch {
18
+ if (Date.now() > deadline)
19
+ process.exit(1);
20
+ await delay(25);
21
+ }
22
+ }
23
+ const output = await open(spec.outputPath, "a", 0o600);
24
+ const child = spawn(spec.argv[0], spec.argv.slice(1), {
25
+ cwd: spec.cwd,
26
+ env: spec.env,
27
+ stdio: ["ignore", output.fd, output.fd],
28
+ });
29
+ let finished = false;
30
+ async function finish(code, signal, error) {
31
+ if (finished)
32
+ return;
33
+ finished = true;
34
+ try {
35
+ await output.close();
36
+ await new LaunchLog(spec.logHome).write({
37
+ event: "task_exit",
38
+ launch_id: spec.launchId,
39
+ exit_code: code,
40
+ signal,
41
+ error,
42
+ });
43
+ }
44
+ finally {
45
+ try {
46
+ await writeFile(spec.exitPath, JSON.stringify({ code, signal, error }), {
47
+ mode: 0o600,
48
+ });
49
+ }
50
+ finally {
51
+ await finishOwnedGroup(code ?? 1);
52
+ }
53
+ }
54
+ }
55
+ child.once("error", (e) => void finish(null, null, e.message));
56
+ child.once("exit", (code, signal) => void finish(code, signal));
57
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
58
+ process.once(signal, () => {
59
+ void (async () => {
60
+ const ref = child.pid ? await processRef(child.pid) : undefined;
61
+ if (ref)
62
+ await stopTree(ref);
63
+ await finish(null, signal);
64
+ })();
65
+ });
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@brutalsystems/muster",
3
+ "version": "0.1.0",
4
+ "description": "Launch instructed, reachable local agent sessions",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.12"
8
+ },
9
+ "bin": {
10
+ "muster": "dist/muster.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc && node scripts/prepare-bin.mjs",
14
+ "test": "vitest run",
15
+ "test:contract": "MUSTER_CONTRACT=1 vitest run test/contract.test.ts",
16
+ "postinstall": "node scripts/prepare-pty.mjs",
17
+ "pretest": "npm run build",
18
+ "pretest:contract": "npm run build",
19
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
20
+ "prepack": "npm run build",
21
+ "prepublishOnly": "npm test"
22
+ },
23
+ "dependencies": {
24
+ "@iarna/toml": "^2.2.5",
25
+ "@modelcontextprotocol/sdk": "^1.20.0",
26
+ "node-pty": "^1.1.0",
27
+ "zod": "^3.25.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.10.0",
31
+ "prettier": "^3.9.8",
32
+ "typescript": "~5.9.0",
33
+ "vitest": "^4.1.11"
34
+ },
35
+ "license": "MIT",
36
+ "author": "Mike Williams",
37
+ "keywords": [
38
+ "mcp",
39
+ "model-context-protocol",
40
+ "claude-code",
41
+ "codex",
42
+ "agents",
43
+ "agent-launcher"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/BrutalSystems/muster.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/BrutalSystems/muster/issues"
51
+ },
52
+ "homepage": "https://github.com/BrutalSystems/muster#readme",
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "files": [
57
+ "dist",
58
+ "scripts/prepare-pty.mjs",
59
+ "README.md",
60
+ "LICENSE",
61
+ "TINCAN_LICENSE",
62
+ "CANONICAL_ID.md",
63
+ "CONTRACT_PROVENANCE.md",
64
+ "test/fixtures/canonical-id.json"
65
+ ]
66
+ }
@@ -0,0 +1,19 @@
1
+ // node-pty 1.1.0's macOS prebuilt helper ships without its executable bit.
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { chmod, stat } from "node:fs/promises";
5
+ if (process.platform === "darwin") {
6
+ const require = createRequire(import.meta.url);
7
+ const root = dirname(require.resolve("node-pty/package.json"));
8
+ for (const file of [
9
+ join(root, "prebuilds", `darwin-${process.arch}`, "spawn-helper"),
10
+ join(root, "build", "Release", "spawn-helper"),
11
+ ]) {
12
+ try {
13
+ const info = await stat(file);
14
+ await chmod(file, info.mode | 0o111);
15
+ } catch (e) {
16
+ if (e.code !== "ENOENT") throw e;
17
+ }
18
+ }
19
+ }