@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/muster.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ import { z } from "zod";
7
+ import { Muster } from "./run.js";
8
+ import { runSchema } from "./guard.js";
9
+ import { delay } from "./identity/processes.js";
10
+ const listSchema = z
11
+ .object({ kind: z.enum(["session", "task"]).optional() })
12
+ .strict();
13
+ const idSchema = z.object({ id: z.string().min(1) }).strict();
14
+ const help = `Usage:
15
+ muster run <codex|claude> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [-- RUNTIME_OPTIONS]
16
+ muster list [--kind session|task]
17
+ muster stop <id>
18
+ muster output <id>
19
+ muster mcp
20
+
21
+ pty launches print their record and keep this process running. Ctrl-C stops the
22
+ owned pty session. tmux launches survive this process exiting.
23
+ MCP: install deliberately in the one authorized session, never at user scope.
24
+ `;
25
+ const text = (value) => ({
26
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
27
+ });
28
+ async function main() {
29
+ const argv = process.argv.slice(2);
30
+ if (argv[0] === "--help" || argv[0] === "-h") {
31
+ process.stdout.write(help);
32
+ return;
33
+ }
34
+ const mcp = argv.length === 0 || argv[0] === "mcp";
35
+ if (mcp && argv.length > 1)
36
+ throw new Error("mcp takes no arguments");
37
+ const muster = await Muster.create();
38
+ let shutdown;
39
+ const close = () => (shutdown ??= (async () => {
40
+ await muster.close();
41
+ })());
42
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"])
43
+ process.once(signal, () => {
44
+ void close().then(() => process.exit(0), (e) => {
45
+ process.stderr.write(String(e) + "\n");
46
+ process.exit(1);
47
+ });
48
+ });
49
+ if (mcp) {
50
+ const server = new Server({ name: "muster", version: "0.1.0" }, { capabilities: { tools: {} } });
51
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
52
+ tools: [
53
+ {
54
+ name: "run",
55
+ description: "Launch an instructed agent. Sessions return only when reachable; tasks return a non-messageable run handle.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ runtime: { type: "string", enum: ["codex", "claude"] },
60
+ prompt: { type: "string", minLength: 1 },
61
+ cwd: { type: "string" },
62
+ kind: {
63
+ type: "string",
64
+ enum: ["session", "task"],
65
+ default: "session",
66
+ },
67
+ host: {
68
+ type: "string",
69
+ enum: ["auto", "tmux", "pty", "macos-terminal"],
70
+ },
71
+ args: { type: "array", items: { type: "string" } },
72
+ },
73
+ required: ["runtime", "prompt"],
74
+ additionalProperties: false,
75
+ },
76
+ },
77
+ {
78
+ name: "list",
79
+ description: "List Muster-owned sessions and tasks, including host capabilities.",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: { kind: { type: "string", enum: ["session", "task"] } },
83
+ additionalProperties: false,
84
+ },
85
+ },
86
+ {
87
+ name: "stop",
88
+ description: "Stop a Muster-owned run by durable id or unambiguous peer name.",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: { id: { type: "string", minLength: 1 } },
92
+ required: ["id"],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ {
97
+ name: "output",
98
+ description: "Read captured output from a task run.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: { id: { type: "string", minLength: 1 } },
102
+ required: ["id"],
103
+ additionalProperties: false,
104
+ },
105
+ },
106
+ ],
107
+ }));
108
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
109
+ try {
110
+ const args = params.arguments ?? {};
111
+ switch (params.name) {
112
+ case "run":
113
+ return text(await muster.run(runSchema.parse(args), `mcp:${server.getClientVersion()?.name ?? "unknown"}`));
114
+ case "list":
115
+ return text(await muster.list(listSchema.parse(args).kind));
116
+ case "stop":
117
+ return text(await muster.stop(idSchema.parse(args).id));
118
+ case "output":
119
+ return text(await muster.output(idSchema.parse(args).id));
120
+ default:
121
+ throw new Error(`Unknown tool ${params.name}`);
122
+ }
123
+ }
124
+ catch (e) {
125
+ return {
126
+ content: [{ type: "text", text: e.message }],
127
+ isError: true,
128
+ };
129
+ }
130
+ });
131
+ server.onclose = () => {
132
+ void close().catch((e) => {
133
+ process.stderr.write(String(e) + "\n");
134
+ process.exitCode = 1;
135
+ });
136
+ };
137
+ server.onerror = (e) => process.stderr.write(`[muster] ${e.message}\n`);
138
+ await server.connect(new StdioServerTransport());
139
+ return;
140
+ }
141
+ try {
142
+ const command = argv.shift();
143
+ const { values, positionals } = parseArgs({
144
+ args: argv,
145
+ allowPositionals: true,
146
+ strict: true,
147
+ options: {
148
+ prompt: { type: "string" },
149
+ cwd: { type: "string" },
150
+ kind: { type: "string" },
151
+ host: { type: "string" },
152
+ },
153
+ });
154
+ if (command === "run") {
155
+ const runtime = positionals[0];
156
+ const args = positionals.slice(1);
157
+ const record = await muster.run({ runtime, ...values, args });
158
+ process.stdout.write(JSON.stringify(record) + "\n");
159
+ if (record.kind === "session" && record.host === "pty") {
160
+ process.stderr.write("[muster] pty: not watchable or attachable; this process owns the session. Ctrl-C stops it.\n");
161
+ while (await muster.hasOwnedPty())
162
+ await delay(250);
163
+ }
164
+ }
165
+ else if (command === "list") {
166
+ if (positionals.length)
167
+ throw new Error("list takes no positional arguments");
168
+ process.stdout.write(JSON.stringify(await muster.list(listSchema.parse(values).kind)) + "\n");
169
+ }
170
+ else if (command === "stop" || command === "output") {
171
+ if (positionals.length !== 1 || Object.keys(values).length)
172
+ throw new Error(`${command} requires exactly one id`);
173
+ const { id } = idSchema.parse({ id: positionals[0] });
174
+ if (command === "stop")
175
+ process.stdout.write(JSON.stringify(await muster.stop(id)) + "\n");
176
+ else
177
+ process.stdout.write(await muster.output(id));
178
+ }
179
+ else
180
+ throw new Error(help);
181
+ }
182
+ finally {
183
+ await close();
184
+ }
185
+ }
186
+ main().catch((e) => {
187
+ process.stderr.write(`[muster] ${e.message}\n`);
188
+ process.exitCode = 1;
189
+ });
package/dist/naming.js ADDED
@@ -0,0 +1,68 @@
1
+ /** Vendored from Tin Can src/naming.ts at fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). */
2
+ /** Peer naming per the handoff §7: slug for humans, canonical id for the log. */
3
+ export function slugify(raw) {
4
+ return raw
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "");
8
+ }
9
+ /**
10
+ * The last three hex characters, not the first. Codex thread ids are UUIDv7:
11
+ * their leading hex is a shared timestamp, so every live thread on a machine
12
+ * starts with the same three characters and a leading suffix disambiguates
13
+ * nothing. The trailing characters are random in both v4 and v7.
14
+ */
15
+ export function suffixOf(uuid) {
16
+ return uuid
17
+ .replace(/[^0-9a-f]/gi, "")
18
+ .slice(-3)
19
+ .toLowerCase();
20
+ }
21
+ export function assignNames(peers) {
22
+ const slugs = peers.map((peer) => {
23
+ const s = peer.rawName ? slugify(peer.rawName) : "";
24
+ return s.length > 0 ? s : "thread";
25
+ });
26
+ const counts = new Map();
27
+ for (const s of slugs)
28
+ counts.set(s, (counts.get(s) ?? 0) + 1);
29
+ return peers.map((peer, i) => {
30
+ const slug = slugs[i];
31
+ const suffix = suffixOf(peer.uuid);
32
+ const qualified = `${slug}.${suffix}`;
33
+ // An unnamed thread has no name to stand on, so it always carries its suffix.
34
+ const collides = (counts.get(slug) ?? 0) > 1 || peer.rawName === null;
35
+ return {
36
+ ...peer,
37
+ slug,
38
+ suffix,
39
+ canonicalId: `${peer.runtime}:${qualified}`,
40
+ display: collides ? qualified : slug,
41
+ };
42
+ });
43
+ }
44
+ export function resolvePeer(peers, input) {
45
+ const q = input.trim().toLowerCase();
46
+ const qualified = (p) => `${p.slug}.${p.suffix}`;
47
+ const exact = peers.filter((p) => p.display.toLowerCase() === q ||
48
+ qualified(p) === q ||
49
+ p.canonicalId.toLowerCase() === q);
50
+ if (exact.length === 1)
51
+ return { ok: true, peer: exact[0] };
52
+ if (exact.length > 1)
53
+ return { ok: false, reason: "ambiguous", candidates: exact.map(qualified) };
54
+ const prefixed = peers.filter((p) => p.slug.startsWith(q) || qualified(p).startsWith(q));
55
+ if (prefixed.length === 1)
56
+ return { ok: true, peer: prefixed[0] };
57
+ if (prefixed.length > 1)
58
+ return {
59
+ ok: false,
60
+ reason: "ambiguous",
61
+ candidates: prefixed.map(qualified),
62
+ };
63
+ return {
64
+ ok: false,
65
+ reason: "unknown",
66
+ candidates: peers.map((p) => p.display),
67
+ };
68
+ }
@@ -0,0 +1,18 @@
1
+ import net from "node:net";
2
+ export function claudeReachable(socketPath, deadline) {
3
+ return new Promise((resolve) => {
4
+ const conn = net.createConnection(socketPath);
5
+ let settled = false;
6
+ const timer = setTimeout(() => finish(false), Math.max(1, Math.min(250, deadline - Date.now())));
7
+ function finish(result) {
8
+ if (settled)
9
+ return;
10
+ settled = true;
11
+ clearTimeout(timer);
12
+ conn.destroy();
13
+ resolve(result);
14
+ }
15
+ conn.once("connect", () => finish(true));
16
+ conn.once("error", () => finish(false));
17
+ });
18
+ }
@@ -0,0 +1,25 @@
1
+ export async function codexReachable(rpc, id, deadline) {
2
+ const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
3
+ if (!thread || thread.id !== id)
4
+ throw new Error("thread/read returned no matching thread");
5
+ if (thread.source === "exec")
6
+ throw new Error("source is exec, not a messageable session");
7
+ if (thread.ephemeral === true || thread.canAcceptDirectInput === false)
8
+ throw new Error("thread cannot accept direct input");
9
+ const listing = await rpc.call("thread/list", { limit: 100, useStateDbOnly: true }, deadline);
10
+ const metadata = Array.isArray(listing.data)
11
+ ? listing.data.find((t) => t.id === id)
12
+ : undefined;
13
+ // Tin Can derives names from thread/list rather than thread/read.
14
+ const rawName = typeof metadata?.name === "string" && metadata.name !== ""
15
+ ? metadata.name
16
+ : null;
17
+ const status = metadata?.status ?? thread.status;
18
+ const state = status === "idle" ||
19
+ status === undefined ||
20
+ status?.type === "idle" ||
21
+ status?.type === "notLoaded"
22
+ ? "idle"
23
+ : "busy";
24
+ return { rawName, state: state };
25
+ }
@@ -0,0 +1,105 @@
1
+ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { processRef, isSame, delay, } from "./identity/processes.js";
5
+ export class Registry {
6
+ home;
7
+ constructor(home) {
8
+ this.home = home;
9
+ }
10
+ async transaction(fn) {
11
+ await mkdir(this.home, { recursive: true, mode: 0o700 });
12
+ const lock = join(this.home, "registry.lock");
13
+ const deadline = Date.now() + 5000;
14
+ while (true) {
15
+ try {
16
+ await mkdir(lock);
17
+ break;
18
+ }
19
+ catch (e) {
20
+ if (e.code !== "EEXIST")
21
+ throw e;
22
+ if (Date.now() > deadline)
23
+ throw new Error("Registry locked; verify no Muster operation is running before removing registry.lock");
24
+ await delay(20);
25
+ }
26
+ }
27
+ try {
28
+ let entries = [];
29
+ try {
30
+ const value = JSON.parse(await readFile(join(this.home, "registry.json"), "utf8"));
31
+ if (!Array.isArray(value))
32
+ throw new Error("Invalid registry");
33
+ entries = value;
34
+ }
35
+ catch (e) {
36
+ if (e.code !== "ENOENT")
37
+ throw e;
38
+ }
39
+ for (const entry of entries) {
40
+ if (entry.status === "starting" &&
41
+ !(await isSame(entry.owner)) &&
42
+ !entry.root) {
43
+ entry.status = "failed";
44
+ entry.error = "launch owner exited before recording process";
45
+ }
46
+ if (["starting", "running"].includes(entry.status) &&
47
+ entry.root &&
48
+ !(await isSame(entry.root))) {
49
+ if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean))
50
+ entry.status = "exited";
51
+ }
52
+ }
53
+ const result = await fn(entries);
54
+ const path = join(this.home, `registry.${randomUUID()}.tmp`);
55
+ await writeFile(path, JSON.stringify(entries, null, 2) + "\n", {
56
+ mode: 0o600,
57
+ });
58
+ await rename(path, join(this.home, "registry.json"));
59
+ return result;
60
+ }
61
+ finally {
62
+ await rm(lock, { recursive: true });
63
+ }
64
+ }
65
+ async all() {
66
+ return this.transaction(async (entries) => structuredClone(entries));
67
+ }
68
+ async reserve(req, cap) {
69
+ const owner = await processRef(process.pid);
70
+ if (!owner)
71
+ throw new Error("Cannot identify Muster owner process");
72
+ return this.transaction(async (entries) => {
73
+ if (entries.filter((e) => e.status === "starting" || e.status === "running")
74
+ .length >= cap)
75
+ throw new Error(`Concurrency cap reached (${cap})`);
76
+ const id = randomUUID();
77
+ const entry = {
78
+ ...req,
79
+ id,
80
+ launchId: id,
81
+ status: "starting",
82
+ createdAt: new Date().toISOString(),
83
+ owner,
84
+ };
85
+ entries.push(entry);
86
+ return { ...entry };
87
+ });
88
+ }
89
+ async update(id, patch) {
90
+ return this.transaction(async (entries) => {
91
+ const byLaunch = entries.find((e) => e.launchId === id);
92
+ const durable = entries.filter((e) => e.id === id);
93
+ if (!byLaunch && durable.length > 1)
94
+ throw new Error("Ambiguous durable id; update by launchId");
95
+ const entry = byLaunch ?? durable[0];
96
+ if (!entry)
97
+ throw new Error(`Unknown launch ${id}`);
98
+ if (patch.id &&
99
+ entries.some((e) => e !== entry && e.runtime === entry.runtime && e.id === patch.id))
100
+ throw new Error("Durable identity already registered");
101
+ Object.assign(entry, patch);
102
+ return structuredClone(entry);
103
+ });
104
+ }
105
+ }