@brutalsystems/muster 0.7.7 → 0.7.9

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/README.md CHANGED
@@ -180,6 +180,37 @@ OpenCode sessions otherwise use the same tmux/pty, viewer, list, stop, and human
180
180
  output behavior described above; OpenCode tasks use the same detached output
181
181
  and exit-status lifecycle as other tasks.
182
182
 
183
+ ## Process ownership
184
+
185
+ Muster identifies a launched process by its **pid paired with its start time**,
186
+ and revalidates that pair whenever the registry is touched — including after
187
+ Muster itself restarts.
188
+
189
+ This is not a compromise chosen for convenience. On macOS it is the strongest
190
+ check the public platform offers:
191
+
192
+ - There is no cgroup. Process groups and sessions are the nearest approximation
193
+ and are escapable by design, so they bound a tree rather than owning it.
194
+ - The kernel's unique process identifier is not exposed in the public SDK.
195
+ - The audit-token route requires a message from the target process, which a
196
+ supervisor observing arbitrary children never has.
197
+
198
+ Two consequences worth stating, because both look like redundant work to a
199
+ reader who does not know why they are there:
200
+
201
+ - **Identity is revalidated at the moment of use, not at discovery.** Every
202
+ signal, every status answer and every registry pass re-checks the pair. A pid
203
+ discovered a moment ago may already belong to something else.
204
+ - **Revalidation after a Muster restart is deliberate.** Comparable supervisors
205
+ trust what they recorded before they died. Muster does not, which is why a
206
+ session that ended while Muster was not running is reported as ended rather
207
+ than as running.
208
+
209
+ Process-tree membership is derived from the parent chain, which has a known
210
+ limit: a child reparented after its own parent exits leaves the chain and cannot
211
+ be recovered from it. Tracking that needs a membership signal the chain does not
212
+ carry.
213
+
183
214
  ## Configuration and permissions
184
215
 
185
216
  Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
@@ -5,6 +5,41 @@ import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { command, processRef, stopTree } from "../identity/processes.js";
7
7
  import { terminalAvailable, tmuxExecutable, openTerminal } from "./terminal.js";
8
+ /** tmux rewrites every control character in `-F` output to an underscore when
9
+ * it runs without a UTF-8 locale — LANG, LC_ALL and LC_CTYPE all unset, which is
10
+ * ordinary for a launchd job, a bare `ssh host cmd`, or an agent harness. A tab
11
+ * delimiter then arrives as `@0_11657` and nothing parses. That is #21, filed as
12
+ * a tmux 3.7c bug on two Macs; it is neither version nor machine specific —
13
+ * `env -u LANG -u LC_ALL -u LC_CTYPE` reproduces it anywhere, and exporting a
14
+ * UTF-8 locale makes it vanish on the machines that "had" it.
15
+ *
16
+ * So no format here may contain a character tmux is willing to rewrite.
17
+ * Printable ASCII passes through untouched, and a pipe cannot occur in
18
+ * #{window_id} (@N), #{pane_pid} (digits), or a session name muster generates.
19
+ * The one free-text field is read last and keeps whatever it splits into. */
20
+ const FIELD = "|";
21
+ export const LAUNCH_FORMAT = `#{window_id}${FIELD}#{pane_pid}`;
22
+ export const LIST_FORMAT = `#{window_id}${FIELD}#{pane_pid}${FIELD}#{session_name}${FIELD}#{window_name}`;
23
+ export const CLEANUP_FORMAT = `#{window_id}${FIELD}#{pane_start_command}`;
24
+ export function parseWindowRow(output) {
25
+ const [hostRef, pid] = output.trim().split(FIELD);
26
+ if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
27
+ throw new Error("tmux returned an invalid window identity: " +
28
+ JSON.stringify(output.length > 200 ? output.slice(0, 200) + "\u2026" : output));
29
+ return { hostRef, pid: Number(pid) };
30
+ }
31
+ export function parseWindowList(output) {
32
+ return output
33
+ .trim()
34
+ .split("\n")
35
+ .map((row) => row.split(FIELD))
36
+ .filter(([, , session]) => session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""))
37
+ .map(([hostRef, pid, , ...label]) => ({
38
+ hostRef: hostRef,
39
+ pid: Number(pid),
40
+ label: label.join(FIELD),
41
+ }));
42
+ }
8
43
  export class TmuxHost {
9
44
  server;
10
45
  id = "tmux";
@@ -43,7 +78,7 @@ export class TmuxHost {
43
78
  "-d",
44
79
  "-P",
45
80
  "-F",
46
- "#{window_id}\t#{pane_pid}",
81
+ LAUNCH_FORMAT,
47
82
  "-n",
48
83
  opts.label.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64) || "agent",
49
84
  "-c",
@@ -56,10 +91,7 @@ export class TmuxHost {
56
91
  // Window selection is session-wide: sharing a session makes every
57
92
  // attached terminal follow whichever agent was opened most recently.
58
93
  const output = await this.call(["new-session", "-s", `muster-${randomUUID()}`, ...common], opts.deadline);
59
- const [hostRef, pid] = output.trim().split("\t");
60
- if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
61
- throw new Error("tmux returned an invalid window identity");
62
- return { hostRef, pid: Number(pid) };
94
+ return parseWindowRow(output);
63
95
  }
64
96
  catch (e) {
65
97
  // The client can time out just after tmux creates the window.
@@ -68,11 +100,11 @@ export class TmuxHost {
68
100
  "list-windows",
69
101
  "-a",
70
102
  "-F",
71
- "#{window_id}\t#{pane_start_command}",
103
+ CLEANUP_FORMAT,
72
104
  ]);
73
105
  for (const row of windows.split("\n"))
74
106
  if (row.includes(file))
75
- await this.stop(row.split("\t")[0]);
107
+ await this.stop(row.split(FIELD)[0]);
76
108
  }
77
109
  catch { }
78
110
  await rm(dir, { recursive: true, force: true });
@@ -81,22 +113,7 @@ export class TmuxHost {
81
113
  }
82
114
  async list() {
83
115
  try {
84
- return (await this.call([
85
- "list-windows",
86
- "-a",
87
- "-F",
88
- "#{window_id}\t#{pane_pid}\t#{window_name}\t#{session_name}",
89
- ]))
90
- .trim()
91
- .split("\n")
92
- .filter((l) => {
93
- const session = l.split("\t")[3];
94
- return (session === "muster" || /^muster-[0-9a-f-]{36}$/.test(session ?? ""));
95
- })
96
- .map((l) => {
97
- const [hostRef, pid, label] = l.split("\t");
98
- return { hostRef: hostRef, pid: Number(pid), label: label };
99
- });
116
+ return parseWindowList(await this.call(["list-windows", "-a", "-F", LIST_FORMAT]));
100
117
  }
101
118
  catch {
102
119
  return [];
@@ -5,7 +5,7 @@ import { readdir, realpath } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { command, descendants } from "./processes.js";
8
- export async function resolveCodex(root, env, deadline, isDirectCli) {
8
+ export async function resolveCodex(root, env, deadline, rejectionOf) {
9
9
  let dir = join(env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex"), "thread-writer-locks");
10
10
  try {
11
11
  dir = await realpath(dir);
@@ -49,13 +49,25 @@ export async function resolveCodex(root, env, deadline, isDirectCli) {
49
49
  }
50
50
  }
51
51
  let candidates = [...matches];
52
- if (candidates.length > 1 && isDirectCli) {
52
+ if (candidates.length && rejectionOf) {
53
53
  // Auto-review owns internal threads in the same process. Inspect only
54
54
  // identities already tied to our descendants; never diff global sessions.
55
+ // Screening the sole candidate too costs one extra round trip, and buys the
56
+ // reason a lock cannot be messaged: returning the thread and letting
57
+ // readiness reject it puts that reason after the launch deadline, where the
58
+ // loop discards it (#20). Raised here it becomes the diagnostic, and a
59
+ // later pass can still find a messageable thread.
55
60
  const selected = [];
56
- for (const candidate of candidates)
57
- if (await isDirectCli(candidate[0]))
61
+ let rejection;
62
+ for (const candidate of candidates) {
63
+ const reason = await rejectionOf(candidate[0]);
64
+ if (reason === null)
58
65
  selected.push(candidate);
66
+ else
67
+ rejection ??= reason;
68
+ }
69
+ if (!selected.length)
70
+ throw new Error(rejection);
59
71
  candidates = selected;
60
72
  }
61
73
  if (candidates.length > 1)
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Process identity on macOS.
3
+ *
4
+ * A launched process is identified by `(pid, start time)`. That pair is the
5
+ * ceiling of what this platform offers a supervisor, not a shortcut taken to
6
+ * avoid something better: there is no cgroup to own a tree, the kernel's unique
7
+ * process identifier is absent from the public SDK, and `audit_token` requires
8
+ * a message from the target that arbitrary children never send.
9
+ *
10
+ * Two rules follow, and both look like wasted work if the reason is not stated:
11
+ *
12
+ * 1. Revalidate at the moment of use, never at discovery. The gap between
13
+ * learning a pid and acting on it is where reuse happens, so `isSame()` is
14
+ * called immediately before each signal rather than once per operation.
15
+ * 2. Revalidation survives our own restart. The registry re-checks generations
16
+ * on every transaction, so a process that died while Muster was not running
17
+ * is reported as dead rather than trusted from what was recorded.
18
+ *
19
+ * Membership is derived from the parent chain and inherits its one real limit:
20
+ * a reparented child is unreachable from the root. See issue #24.
21
+ */
1
22
  import { execFile } from "node:child_process";
2
23
  import { promisify } from "node:util";
3
24
  const exec = promisify(execFile);
@@ -35,25 +56,59 @@ export async function descendants(root, deadline = Date.now() + 1000) {
35
56
  }
36
57
  return [...found];
37
58
  }
59
+ async function liveTree(root) {
60
+ return (await Promise.all((await descendants(root)).map(processRef))).filter((r) => !!r);
61
+ }
62
+ function signal(ref, name) {
63
+ try {
64
+ process.kill(ref.pid, name);
65
+ }
66
+ catch { }
67
+ }
68
+ /**
69
+ * A tree enumerated once, before the wait between SIGTERM and SIGKILL, cannot
70
+ * contain anything the tree forked during that wait — and a process asked to
71
+ * terminate is exactly the kind that spawns a last child. So the membership is
72
+ * re-read after the wait and unioned with what was already known, and only then
73
+ * is the kill pass run. Each entry is still revalidated immediately before
74
+ * every signal, because a pid that exits between discovery and the signal may
75
+ * already belong to something else.
76
+ *
77
+ * Reparented descendants are a separate gap: once an intermediate process dies,
78
+ * its children leave the parent chain entirely and no re-read will find them.
79
+ * That needs a membership signal the chain does not provide (see issue #24).
80
+ */
38
81
  export async function stopTree(ref) {
39
82
  if (!(await isSame(ref)))
40
83
  return;
41
- const refs = (await Promise.all((await descendants(ref.pid)).map(processRef))).filter((r) => !!r);
42
- for (const r of refs.reverse())
43
- if (await isSame(r)) {
44
- try {
45
- process.kill(r.pid, "SIGTERM");
84
+ const known = new Map();
85
+ const remember = (refs) => {
86
+ const added = [];
87
+ for (const r of refs) {
88
+ const key = `${r.pid}:${r.start}`;
89
+ if (!known.has(key)) {
90
+ known.set(key, r);
91
+ added.push(r);
46
92
  }
47
- catch { }
48
93
  }
94
+ return added;
95
+ };
96
+ const deepestFirst = () => [...known.values()].reverse();
97
+ remember(await liveTree(ref.pid));
98
+ for (const r of deepestFirst())
99
+ if (await isSame(r))
100
+ signal(r, "SIGTERM");
49
101
  await delay(150);
50
- for (const r of refs)
51
- if (await isSame(r)) {
52
- try {
53
- process.kill(r.pid, "SIGKILL");
54
- }
55
- catch { }
56
- }
102
+ const late = (await isSame(ref)) ? remember(await liveTree(ref.pid)) : [];
103
+ if (late.length) {
104
+ for (const r of late.reverse())
105
+ if (await isSame(r))
106
+ signal(r, "SIGTERM");
107
+ await delay(50);
108
+ }
109
+ for (const r of deepestFirst())
110
+ if (await isSame(r))
111
+ signal(r, "SIGKILL");
57
112
  }
58
113
  // Called only by a terminal/task supervisor that is the leader of its own group.
59
114
  // Keeping the supervisor alive until this point prevents process-group ID reuse.
@@ -1,11 +1,22 @@
1
- export async function codexReachable(rpc, id, deadline) {
2
- const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
1
+ /** Why a thread cannot serve as a messageable session identity, or null if it
2
+ * can. One source of truth: the launch loop screens candidate writer locks with
3
+ * this, and readiness re-checks it on the thread it settled on. */
4
+ export function threadRejection(thread, id) {
3
5
  if (!thread || thread.id !== id)
4
- throw new Error("thread/read returned no matching thread");
6
+ return "thread/read returned no matching thread";
5
7
  if (thread.source === "exec")
6
- throw new Error("source is exec, not a messageable session");
8
+ return "source is exec, not a messageable session";
7
9
  if (thread.ephemeral === true || thread.canAcceptDirectInput === false)
8
- throw new Error("thread cannot accept direct input");
10
+ return "thread cannot accept direct input";
11
+ if (thread.source !== "cli")
12
+ return "source is not a direct CLI session";
13
+ return null;
14
+ }
15
+ export async function codexReachable(rpc, id, deadline) {
16
+ const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
17
+ const rejection = threadRejection(thread, id);
18
+ if (rejection)
19
+ throw new Error(rejection);
9
20
  const listing = await rpc.call("thread/list", { limit: 100, useStateDbOnly: true }, deadline);
10
21
  const metadata = Array.isArray(listing.data)
11
22
  ? listing.data.find((t) => t.id === id)
package/dist/run.js CHANGED
@@ -12,7 +12,7 @@ import { processRef, isSame, stopTree, delay, descendants, } from "./identity/pr
12
12
  import { resolveCodex } from "./identity/codex.js";
13
13
  import { resolveClaude } from "./identity/claude.js";
14
14
  import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
15
- import { codexReachable } from "./reach/codex.js";
15
+ import { codexReachable, threadRejection } from "./reach/codex.js";
16
16
  import { claudeReachable } from "./reach/claude.js";
17
17
  import { openCodeReachable } from "./reach/opencode.js";
18
18
  import { assertClaudePolicy } from "./claude-policy.js";
@@ -195,13 +195,10 @@ export class Muster {
195
195
  ? await resolveCodex(root.pid, this.env, deadline, async (id) => {
196
196
  try {
197
197
  const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
198
- return (thread?.id === id &&
199
- thread.source === "cli" &&
200
- thread.ephemeral !== true &&
201
- thread.canAcceptDirectInput !== false);
198
+ return threadRejection(thread, id);
202
199
  }
203
- catch {
204
- return false;
200
+ catch (e) {
201
+ return e.message;
205
202
  }
206
203
  })
207
204
  : req.runtime === "opencode"
@@ -280,6 +277,11 @@ export class Muster {
280
277
  catch (e) {
281
278
  if (openingTerminal)
282
279
  throw e;
280
+ // An error arriving after the deadline is dropped, because most of
281
+ // them are artifacts of the deadline itself — aborted round trips
282
+ // that would overwrite a real diagnosis with "timed out". The cost is
283
+ // that a genuine answer landing a moment late is lost too, so raise
284
+ // one as early as it is known rather than at readiness (#20).
283
285
  if (Date.now() < deadline)
284
286
  diagnostic = e.message;
285
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {