@brutalsystems/muster 3.0.0 → 3.0.2

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.
@@ -102,6 +102,19 @@ export function titleOptions(session, title) {
102
102
  * TERM Ghostty, iTerm2 and Terminal.app report; a terminal that does not
103
103
  * support the request ignores it.
104
104
  *
105
+ * `progressbar` lets tmux pass a pane's progress (OSC 9;4, which Claude Code
106
+ * sends while it works) to the attached terminal, and it has to be declared
107
+ * here rather than left to tmux. Muster starts the agent before any terminal
108
+ * attaches, so its first turn has usually set progress by then; tmux learns
109
+ * Ghostty's `progressbar` only from the XTVERSION reply after attaching, and
110
+ * does not then send the progress it is already holding, so the pane shows no
111
+ * busy state for the whole of that turn. Declared up front, the held state is
112
+ * replayed on attach. It matches any client whose TERM is `xterm-ghostty`
113
+ * (what Ghostty and Scotty set), whether or not that client ever answers
114
+ * XTVERSION, rather than `xterm*`: Terminal.app has no progress bar and iTerm2
115
+ * has read OSC 9 as a notification. It reaches clients that attach after the
116
+ * next launch on a server, not clients already attached.
117
+ *
105
118
  * `-q` because these name options newer than some tmux still installed
106
119
  * (`extended-keys-format` arrived in 3.5, `terminal-features` in 3.2), and an
107
120
  * unknown option is an error that would abort the whole command list and with
@@ -113,6 +126,7 @@ export const SERVER_OPTIONS = [
113
126
  ["-s", "extended-keys", "always"],
114
127
  ["-s", "extended-keys-format", "csi-u"],
115
128
  ["-s", "terminal-features[90]", "xterm*:extkeys"],
129
+ ["-s", "terminal-features[91]", "xterm-ghostty:progressbar"],
116
130
  ];
117
131
  /** `SERVER_OPTIONS` as `;`-terminated `set-option` commands, to lead a
118
132
  * command list. */
package/dist/muster.js CHANGED
@@ -51,6 +51,15 @@ function identitiesHuman(view, color) {
51
51
  return lines.join("\n") + "\n";
52
52
  }
53
53
  async function main() {
54
+ // A reader that leaves early (`muster list | head -1`) makes the rest of a
55
+ // large write fail with EPIPE. Unhandled, that error exits Node on the spot —
56
+ // inside whatever registry transaction `close()` is running, which strands
57
+ // the lock. Nobody is left to read the output, so dropping it is correct.
58
+ for (const stream of [process.stdout, process.stderr])
59
+ stream.on("error", (e) => {
60
+ if (e.code !== "EPIPE")
61
+ throw e;
62
+ });
54
63
  const argv = process.argv.slice(2);
55
64
  if (argv[0] === "--help" || argv[0] === "-h") {
56
65
  process.stdout.write(topLevelHelp());
package/dist/registry.js CHANGED
@@ -1,6 +1,8 @@
1
- import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
4
6
  import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
5
7
  import { LOCAL, requesterKey } from "./requester.js";
6
8
  /**
@@ -22,15 +24,26 @@ export class IdempotencyKeyReuse extends Error {
22
24
  this.name = "IdempotencyKeyReuse";
23
25
  }
24
26
  }
27
+ /**
28
+ * A lock directory with no owner file is presumed live for this long. Long
29
+ * enough that no transaction comes near it, and the holder writes its owner
30
+ * file immediately after taking the lock, so in practice only a lock left by a
31
+ * version that wrote none (an empty directory) ever reaches it.
32
+ */
33
+ const UNOWNED_LOCK_MS = 60_000;
34
+ let self;
25
35
  export class Registry {
26
36
  home;
27
- constructor(home) {
37
+ lockWait;
38
+ /** `lockWait` is how long to wait for a live holder; tests shorten it. */
39
+ constructor(home, lockWait = 5000) {
28
40
  this.home = home;
41
+ this.lockWait = lockWait;
29
42
  }
30
43
  async transaction(fn) {
31
44
  await mkdir(this.home, { recursive: true, mode: 0o700 });
32
45
  const lock = join(this.home, "registry.lock");
33
- const deadline = Date.now() + 5000;
46
+ const deadline = Date.now() + this.lockWait;
34
47
  while (true) {
35
48
  try {
36
49
  await mkdir(lock);
@@ -39,12 +52,27 @@ export class Registry {
39
52
  catch (e) {
40
53
  if (e.code !== "EEXIST")
41
54
  throw e;
42
- if (Date.now() > deadline)
43
- throw new Error("Registry locked; verify no Muster operation is running before removing registry.lock");
55
+ if (await this.breakStale(lock))
56
+ continue;
57
+ if (Date.now() > deadline) {
58
+ const owner = await lockOwner(lock);
59
+ throw new Error(owner
60
+ ? `Registry locked, held by pid ${owner.pid}; verify no Muster operation is running before removing registry.lock`
61
+ : "Registry locked; verify no Muster operation is running before removing registry.lock");
62
+ }
44
63
  await delay(20);
45
64
  }
46
65
  }
47
66
  try {
67
+ // The lock is released only by this process's `finally`, which does not
68
+ // run if the process dies first: an unhandled EPIPE, or a SIGKILL from a
69
+ // client stopping its MCP server. The owner file is what lets the next
70
+ // process see that and take the lock over instead of waiting forever.
71
+ // Cached only once known: a `ps` that failed once must not leave every
72
+ // later transaction of a long-lived server unowned.
73
+ const ref = (self ??= await lockRef(process.pid));
74
+ if (ref)
75
+ await writeFile(join(lock, "owner.json"), JSON.stringify(ref));
48
76
  let entries = [];
49
77
  try {
50
78
  const value = JSON.parse(await readFile(join(this.home, "registry.json"), "utf8"));
@@ -101,6 +129,42 @@ export class Registry {
101
129
  await rm(lock, { recursive: true });
102
130
  }
103
131
  }
132
+ /**
133
+ * Removes the lock if its holder is provably gone: its recorded process is
134
+ * not running, or it recorded none and is older than any transaction.
135
+ *
136
+ * Breaking is serialized by a second directory and the verdict re-checked
137
+ * inside it. Without that, two waiters could both judge the same dead lock,
138
+ * one break it and take a new lock, and the other then remove that live one.
139
+ */
140
+ async breakStale(lock) {
141
+ const breaker = `${lock}.break`;
142
+ try {
143
+ await mkdir(breaker);
144
+ }
145
+ catch (e) {
146
+ if (e.code !== "EEXIST")
147
+ throw e;
148
+ // A breaker holds this for milliseconds; one this old died holding it.
149
+ const age = await ageMs(breaker);
150
+ if (age !== undefined && age > UNOWNED_LOCK_MS)
151
+ await rm(breaker, { recursive: true, force: true });
152
+ return false;
153
+ }
154
+ try {
155
+ const owner = await lockOwner(lock);
156
+ const stale = owner
157
+ ? await holderGone(owner)
158
+ : ((await ageMs(lock)) ?? 0) > UNOWNED_LOCK_MS;
159
+ if (!stale)
160
+ return false;
161
+ await rm(lock, { recursive: true, force: true });
162
+ return true;
163
+ }
164
+ finally {
165
+ await rm(breaker, { recursive: true, force: true });
166
+ }
167
+ }
104
168
  async all() {
105
169
  return this.transaction(async (entries) => structuredClone(entries));
106
170
  }
@@ -172,3 +236,57 @@ export class Registry {
172
236
  });
173
237
  }
174
238
  }
239
+ async function lockOwner(lock) {
240
+ try {
241
+ const value = JSON.parse(await readFile(join(lock, "owner.json"), "utf8"));
242
+ return typeof value.pid === "number" && typeof value.start === "string"
243
+ ? { pid: value.pid, start: value.start }
244
+ : undefined;
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
249
+ }
250
+ /**
251
+ * The holder's identity as written into the lock. `ps` prints the start time in
252
+ * the caller's timezone and language, and the holder and a waiter need not
253
+ * share either (an MCP client's environment, a terminal's). Fixing both here
254
+ * makes the two readings comparable; `processRef`, whose strings are already
255
+ * stored in registry.json, is left as it is.
256
+ */
257
+ async function lockRef(pid) {
258
+ try {
259
+ const { stdout } = await promisify(execFile)("ps", ["-p", String(pid), "-o", "lstart=,stat="], { timeout: 1000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
260
+ const out = stdout.trim();
261
+ if (!out || /\bZ\S*$/.test(out))
262
+ return;
263
+ return { pid, start: out.slice(0, 24) };
264
+ }
265
+ catch {
266
+ return;
267
+ }
268
+ }
269
+ /**
270
+ * Gone only on positive evidence. `processRef` answers undefined for any `ps`
271
+ * failure, a timeout under load included, and breaking a live lock on that
272
+ * would let two transactions interleave their writes.
273
+ */
274
+ async function holderGone(owner) {
275
+ try {
276
+ process.kill(owner.pid, 0);
277
+ }
278
+ catch (e) {
279
+ return e.code === "ESRCH";
280
+ }
281
+ // The pid is in use; it is the holder unless it started at another time.
282
+ const now = await lockRef(owner.pid);
283
+ return now !== undefined && now.start !== owner.start;
284
+ }
285
+ async function ageMs(path) {
286
+ try {
287
+ return Date.now() - (await stat(path)).mtimeMs;
288
+ }
289
+ catch {
290
+ return undefined;
291
+ }
292
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {