@brutalsystems/muster 3.0.2 → 3.1.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/registry.js CHANGED
@@ -5,6 +5,19 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
7
7
  import { LOCAL, requesterKey } from "./requester.js";
8
+ /**
9
+ * Whether a vanished launch was ended by its stop: a stop was asked, and no
10
+ * task exit file was written before it. The file is the worker's, published
11
+ * by rename, so its mtime is when the exit was recorded.
12
+ */
13
+ async function stoppedBeforeExit(entry) {
14
+ if (!entry.stopRequestedAt)
15
+ return false;
16
+ if (entry.kind !== "task" || !entry.exitPath)
17
+ return true;
18
+ const written = await stat(entry.exitPath).then((s) => s.mtimeMs, () => undefined);
19
+ return written === undefined || written >= Date.parse(entry.stopRequestedAt);
20
+ }
8
21
  /**
9
22
  * A request key arrived again with different parameters. Distinct from a launch
10
23
  * failure: nothing was attempted, and the caller reused a key it should have
@@ -114,7 +127,9 @@ export class Registry {
114
127
  // concluding the session ended: it is the signal that survives both.
115
128
  if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
116
129
  !(entry.group && (await groupAlive(entry.group))))
117
- entry.status = "exited";
130
+ entry.status = (await stoppedBeforeExit(entry))
131
+ ? "stopped"
132
+ : "exited";
118
133
  }
119
134
  }
120
135
  const result = await fn(entries);
@@ -217,6 +232,71 @@ export class Registry {
217
232
  return { entry: { ...entry }, deduped: false };
218
233
  });
219
234
  }
235
+ /**
236
+ * One entry, read without the lock. Safe because every write lands by rename,
237
+ * so a reader sees the old file or the new one and never half of either. For
238
+ * polling only: it does not run the liveness sweep, so a status read here may
239
+ * be stale until the next transaction.
240
+ */
241
+ async peek(launchId) {
242
+ try {
243
+ const entries = JSON.parse(await readFile(join(this.home, "registry.json"), "utf8"));
244
+ return entries.find((e) => e.launchId === launchId);
245
+ }
246
+ catch (e) {
247
+ if (e.code === "ENOENT")
248
+ return undefined;
249
+ throw e;
250
+ }
251
+ }
252
+ /**
253
+ * True at most once per launch. The check and the write share a transaction,
254
+ * so two watchers racing over the same exit cannot both run the command.
255
+ * At most, not exactly: a watcher that dies after claiming runs nothing.
256
+ */
257
+ async claimOnExit(launchId) {
258
+ return this.transaction(async (entries) => {
259
+ const entry = entries.find((e) => e.launchId === launchId);
260
+ if (!entry?.onExit || entry.onExitFiredAt)
261
+ return undefined;
262
+ entry.onExitFiredAt = new Date().toISOString();
263
+ return { ...entry };
264
+ });
265
+ }
266
+ /**
267
+ * Records that `stop` was asked, unless the launch has already ended, in
268
+ * which case nothing changes and undefined is returned: stopping a finished
269
+ * launch must not rewrite how it finished. `ended` is asked inside the
270
+ * transaction, after its sweep, so a process that vanished is already
271
+ * `exited` by the time it looks.
272
+ */
273
+ async requestStop(launchId, ended) {
274
+ return this.transaction(async (entries) => {
275
+ const entry = entries.find((e) => e.launchId === launchId);
276
+ if (!entry)
277
+ throw new Error(`Unknown launch ${launchId}`);
278
+ if (await ended(entry))
279
+ return undefined;
280
+ entry.stopRequestedAt ??= new Date().toISOString();
281
+ return { ...entry };
282
+ });
283
+ }
284
+ /**
285
+ * Records the `--on-exit` command, refusing one already recorded. The check
286
+ * and the write share a transaction: two callers handed the same launch (a
287
+ * request key sent twice) would otherwise both see none and both succeed,
288
+ * with the second command silently replacing the first.
289
+ */
290
+ async armOnExit(launchId, onExit) {
291
+ return this.transaction(async (entries) => {
292
+ const entry = entries.find((e) => e.launchId === launchId);
293
+ if (!entry)
294
+ throw new Error(`Unknown launch ${launchId}`);
295
+ if (entry.onExit)
296
+ throw new Error(`launch ${entry.id} already has an --on-exit command; it is claimed at most once, so a second is refused rather than dropped`);
297
+ entry.onExit = onExit;
298
+ });
299
+ }
220
300
  async update(id, patch) {
221
301
  return this.transaction(async (entries) => {
222
302
  const byLaunch = entries.find((e) => e.launchId === id);
package/dist/run.js CHANGED
@@ -17,9 +17,9 @@ import { hosts, selectHost } from "./hosts/index.js";
17
17
  import { AGENTS } from "./agents.js";
18
18
  import { assertOptionsSupported, screenWorkspace } from "./options.js";
19
19
  import { workspaceTrustKey } from "./identity/workspace-key.js";
20
- import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
20
+ import { processRef, isSame, stopTree, delay, descendants, ownedGroup, groupAlive, } from "./identity/processes.js";
21
21
  import { resolveCodex } from "./identity/codex.js";
22
- import { claudeConfigDir, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
22
+ import { claudeTrustFile, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
23
23
  import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
24
24
  import { codexReachable, threadRejection } from "./reach/codex.js";
25
25
  import { claudeReachable } from "./reach/claude.js";
@@ -33,6 +33,7 @@ import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
33
33
  import { selectMcp, prepareMcp, mcpEnvironment, } from "./mcp.js";
34
34
  import { pollInterval } from "./reap.js";
35
35
  import { selectPlugins } from "./plugins.js";
36
+ import { prepareOnExit, runOnExitCommand } from "./on-exit.js";
36
37
  function taskHandle(entry, pid) {
37
38
  return {
38
39
  kind: "task",
@@ -66,6 +67,86 @@ function taskHandle(entry, pid) {
66
67
  * as "there is no such variable" via `?? entry.identityPath` and an absent
67
68
  * value is what that idiom expects.
68
69
  */
70
+ /**
71
+ * A finished record always answers the three questions a waiter has, even
72
+ * where the answer is "not observed": a session's agent exits inside its
73
+ * terminal host, which keeps no exit status, so only a task carries real ones.
74
+ */
75
+ /**
76
+ * Whether the `--on-exit` command was claimed and what became of it, on every
77
+ * record of a launch that has one: a claim with no result is a watcher that
78
+ * died mid-command, and the only place that shows is here.
79
+ */
80
+ function onExitView(e) {
81
+ if (!e.onExit)
82
+ return {};
83
+ return {
84
+ on_exit: {
85
+ claimed_at: e.onExitFiredAt ?? null,
86
+ result: e.onExitResult ?? null,
87
+ },
88
+ };
89
+ }
90
+ function finalRecord(record) {
91
+ return {
92
+ ...record,
93
+ exit_code: record.exit_code ?? null,
94
+ signal: record.signal ?? null,
95
+ };
96
+ }
97
+ /**
98
+ * A task's exit file, and when it was written: undefined until it holds a
99
+ * record. Only a record that parses counts — a file that exists but cannot be
100
+ * read as one is never the end, whatever wrote it. The time is the file's,
101
+ * which the worker publishes by rename, so it is when the exit was recorded.
102
+ */
103
+ async function readExit(path) {
104
+ if (!path)
105
+ return undefined;
106
+ try {
107
+ const [text, info] = await Promise.all([
108
+ readFile(path, "utf8"),
109
+ stat(path),
110
+ ]);
111
+ const exit = JSON.parse(text);
112
+ if (exit && typeof exit === "object" && "code" in exit)
113
+ return { ...exit, at: info.mtimeMs };
114
+ }
115
+ catch { }
116
+ return undefined;
117
+ }
118
+ /** Whether a stop, not the agent, ended a launch with this exit record. */
119
+ function endedByStop(entry, exit) {
120
+ if (!entry.stopRequestedAt)
121
+ return false;
122
+ return !exit || exit.at >= Date.parse(entry.stopRequestedAt);
123
+ }
124
+ const FAILED_LAUNCH = Symbol("muster.failedLaunch");
125
+ /**
126
+ * The launch a failed `run` had reserved, so the CLI can still fire its
127
+ * `--on-exit` for it. Undefined for a refusal made before anything was
128
+ * reserved — a bad option, or a request key already in use — which is not a
129
+ * launch that ended and must not be reported as one.
130
+ */
131
+ export function failedLaunchId(e) {
132
+ return e && typeof e === "object"
133
+ ? e[FAILED_LAUNCH]
134
+ : undefined;
135
+ }
136
+ /**
137
+ * Where an agent keeps its trust records when no configuration home is named,
138
+ * from the HOME it is spawned with. Codex only: its default `config.toml` is
139
+ * written by Codex just when you change a setting, and its trust entry is
140
+ * appended without rewriting the file. Claude's default is `~/.claude.json`,
141
+ * which every running session rewrites continuously, and a read-modify-write
142
+ * there could undo theirs — so a Claude launch with no CLAUDE_CONFIG_DIR still
143
+ * writes nothing, as before.
144
+ */
145
+ function defaultConfigHome(runtime, env) {
146
+ return runtime === "codex"
147
+ ? join(env.HOME ?? homedir(), ".codex")
148
+ : undefined;
149
+ }
69
150
  function configHomeVar(runtime) {
70
151
  return AGENTS[runtime].configHomeVar ?? undefined;
71
152
  }
@@ -73,6 +154,28 @@ function configHomeVar(runtime) {
73
154
  function provisionalTitle(req) {
74
155
  return sanitiseTitle(`${basename(req.cwd)} · ${req.runtime}`);
75
156
  }
157
+ /**
158
+ * `wait` was given an id that names no launch. A class of its own because the
159
+ * CLI answers it with its own exit status: a caller polling for completion must
160
+ * be able to tell "never existed" from "still running", or a typo waits forever.
161
+ */
162
+ export class UnknownRun extends Error {
163
+ constructor(message) {
164
+ super(message);
165
+ this.name = "UnknownRun";
166
+ }
167
+ }
168
+ /**
169
+ * The id that names a launch for as long as the registry holds it. Not a
170
+ * name or `canonical_id`: those resolve only among running sessions, so a
171
+ * session that exits between readiness and `--on-exit` being armed would no
172
+ * longer be found, and would lose its command.
173
+ */
174
+ export function durableId(record) {
175
+ if (record.kind === "task")
176
+ return record.id;
177
+ return record.runtime === "codex" ? record.thread_id : record.session_id;
178
+ }
76
179
  export class Muster {
77
180
  config;
78
181
  home;
@@ -461,8 +564,17 @@ export class Muster {
461
564
  // Written before the agent starts and into the SAME dir the child will open.
462
565
  // An identity launch has already had its copy trusted above, so this is a
463
566
  // no-op there rather than a second write.
464
- if (req.options.includes("auto-approve-path") && configHome)
465
- await AGENTS[req.runtime].trustWorkspace?.(configHome, await workspaceTrustKey(req.cwd));
567
+ //
568
+ // With no configuration home named, the agent reads its default one, and
569
+ // that is where the record has to go. Skipping it — as this did — made the
570
+ // option a silent no-op for every Codex launch without CODEX_HOME: the
571
+ // launch then sat on Codex's trust dialog and timed out with "no
572
+ // descendant runtime identity found".
573
+ if (req.options.includes("auto-approve-path")) {
574
+ const trustHome = configHome ?? defaultConfigHome(req.runtime, runtimeEnv);
575
+ if (trustHome)
576
+ await AGENTS[req.runtime].trustWorkspace?.(trustHome, await workspaceTrustKey(req.cwd));
577
+ }
466
578
  const resolveEnv = this.resolutionEnv(req.runtime, configHome);
467
579
  let bridgePath;
468
580
  if (req.runtime === "claude" && prepared.servers.length) {
@@ -785,7 +897,7 @@ export class Muster {
785
897
  // runs headless and never sees the dialog, so refusing an untrusted
786
898
  // directory up front would break task launches that work today.
787
899
  if (req.runtime === "claude" && diagnostic === neverRegistered)
788
- diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv), {
900
+ diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeTrustFile(resolveEnv), {
789
901
  bypassWarningPending: req.kind === "session" &&
790
902
  permissions.permissions === "bypass" &&
791
903
  !req.options.includes("accept-bypass-warning"),
@@ -911,6 +1023,8 @@ export class Muster {
911
1023
  });
912
1024
  }
913
1025
  catch (e) {
1026
+ if (e && typeof e === "object")
1027
+ e[FAILED_LAUNCH] = entry.launchId;
914
1028
  await this.stopEntry(entry).catch(() => { });
915
1029
  const message = e.message;
916
1030
  await this.registry.update(entry.launchId, {
@@ -922,6 +1036,12 @@ export class Muster {
922
1036
  .catch(() => { });
923
1037
  throw e;
924
1038
  }
1039
+ finally {
1040
+ // The readiness connection is a `codex app-server` child of THIS
1041
+ // process. Left open, its pipes keep the event loop alive, and the CLI
1042
+ // that launched a Codex session never exits after printing the record.
1043
+ await rpc?.close();
1044
+ }
925
1045
  }
926
1046
  /**
927
1047
  * The answer to a request we have already seen. Never a new launch: a repeat
@@ -1105,10 +1225,35 @@ export class Muster {
1105
1225
  async stop(id, requester = LOCAL) {
1106
1226
  const entry = await this.find(id);
1107
1227
  assertMayAct(entry, requester);
1108
- await this.stopEntry(entry);
1109
- await this.registry.update(entry.launchId, { status: "stopped" });
1110
- await this.log.write({ event: "stopped", launch_id: entry.launchId });
1111
- return { stopped: true, id: entry.id };
1228
+ // Recorded before the kill: any transaction that sees the processes gone
1229
+ // before the write below lands would otherwise sweep the entry to
1230
+ // `exited`, and a waiter would report a stop as the agent ending itself.
1231
+ // A launch that has already ended is left as it ended: relabelling an
1232
+ // exited task `stopped` would tell its waiter the wrong thing.
1233
+ const requested = await this.registry.requestStop(entry.launchId, (e) => this.ended(e));
1234
+ if (!requested) {
1235
+ // As `wait` and `list` report it, not the raw status: a task's exit
1236
+ // file can land while its worker is still tearing down, and then the
1237
+ // registry still says running though the task has exited.
1238
+ const final = (await this.registry.peek(entry.launchId)) ?? entry;
1239
+ return {
1240
+ stopped: false,
1241
+ id: entry.id,
1242
+ state: (await this.view(final)).state,
1243
+ };
1244
+ }
1245
+ await this.stopEntry(requested);
1246
+ // The agent may have exited on its own between the check and the kill;
1247
+ // an exit recorded before the stop was asked is still an exit.
1248
+ const exit = requested.kind === "task"
1249
+ ? await readExit(requested.exitPath)
1250
+ : undefined;
1251
+ const status = endedByStop(requested, exit) ? "stopped" : "exited";
1252
+ await this.registry.update(entry.launchId, { status });
1253
+ await this.log.write({ event: status, launch_id: entry.launchId });
1254
+ return status === "stopped"
1255
+ ? { stopped: true, id: entry.id }
1256
+ : { stopped: false, id: entry.id, state: status };
1112
1257
  }
1113
1258
  /**
1114
1259
  * Where to look for the agent's own session registry. Two different uses of an
@@ -1200,68 +1345,269 @@ export class Muster {
1200
1345
  for (const original of entries) {
1201
1346
  if (kind && kind !== original.kind)
1202
1347
  continue;
1203
- const e = await this.refreshed(original);
1204
- if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
1205
- await rm(e.mcpConfigPath, { force: true });
1206
- if (e.identityPath && !["starting", "running"].includes(e.status))
1207
- await rm(e.identityPath, { recursive: true, force: true });
1208
- if (e.kind === "session")
1209
- result.push({
1210
- ...e.peer,
1211
- kind: "session",
1212
- ...(e.permissions
1213
- ? { permissions: e.permissions, sandbox: e.sandbox }
1214
- : {}),
1215
- // From the entry, not the peer: a listing must report the same fact a
1216
- // launch result does, and an entry predating the field has none.
1217
- ...(e.enforcement ? { enforcement: e.enforcement } : {}),
1218
- ...(e.identity ? { identity: e.identity } : {}),
1219
- // Absent on an entry written before the field, which is a different
1220
- // fact from an explicit null: that one means Muster resolved nothing
1221
- // and the child chose. Both are more useful than a guess.
1222
- ...(e.model !== undefined
1223
- ? { model: e.model, model_source: e.modelSource ?? null }
1224
- : {}),
1225
- ...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
1226
- ...(e.ttl ? { ttl: e.ttl } : {}),
1227
- id: e.id,
1228
- runtime: e.peer?.runtime ?? e.runtime,
1229
- state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
1230
- host: e.host,
1231
- ...(e.error ? { error: e.error } : {}),
1232
- });
1233
- else {
1234
- let exit;
1235
- try {
1236
- exit = JSON.parse(await readFile(e.exitPath, "utf8"));
1237
- }
1238
- catch { }
1239
- result.push({
1240
- kind: "task",
1241
- ...(e.mcp ? { mcp: e.mcp } : {}),
1242
- ...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
1243
- ...(e.permissions
1244
- ? { permissions: e.permissions, sandbox: e.sandbox }
1245
- : {}),
1246
- ...(e.enforcement ? { enforcement: e.enforcement } : {}),
1247
- ...(e.identity ? { identity: e.identity } : {}),
1248
- // Absent on an entry written before the field, which is a different
1249
- // fact from an explicit null: that one means Muster resolved nothing
1250
- // and the child chose. Both are more useful than a guess.
1251
- ...(e.model !== undefined
1252
- ? { model: e.model, model_source: e.modelSource ?? null }
1253
- : {}),
1254
- id: e.id,
1255
- runtime: e.runtime,
1256
- cwd: e.cwd,
1257
- state: exit ? "exited" : e.status,
1258
- ...(e.root ? { pid: e.root.pid } : {}),
1259
- ...(exit ? { exit_code: exit.code, signal: exit.signal } : {}),
1260
- ...(e.error ? { error: e.error } : {}),
1348
+ result.push(await this.view(original));
1349
+ }
1350
+ return result;
1351
+ }
1352
+ /**
1353
+ * One entry as `list` reports it. Shared with `wait`, so the record a waiter
1354
+ * is handed is the record a listing would have shown for the same launch.
1355
+ */
1356
+ async view(original) {
1357
+ const e = await this.refreshed(original);
1358
+ if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
1359
+ await rm(e.mcpConfigPath, { force: true });
1360
+ if (e.identityPath && !["starting", "running"].includes(e.status))
1361
+ await rm(e.identityPath, { recursive: true, force: true });
1362
+ if (e.kind === "session")
1363
+ return {
1364
+ ...e.peer,
1365
+ kind: "session",
1366
+ ...(e.permissions
1367
+ ? { permissions: e.permissions, sandbox: e.sandbox }
1368
+ : {}),
1369
+ // From the entry, not the peer: a listing must report the same fact a
1370
+ // launch result does, and an entry predating the field has none.
1371
+ ...(e.enforcement ? { enforcement: e.enforcement } : {}),
1372
+ ...(e.identity ? { identity: e.identity } : {}),
1373
+ // Absent on an entry written before the field, which is a different
1374
+ // fact from an explicit null: that one means Muster resolved nothing
1375
+ // and the child chose. Both are more useful than a guess.
1376
+ ...(e.model !== undefined
1377
+ ? { model: e.model, model_source: e.modelSource ?? null }
1378
+ : {}),
1379
+ ...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
1380
+ ...(e.ttl ? { ttl: e.ttl } : {}),
1381
+ id: e.id,
1382
+ runtime: e.peer?.runtime ?? e.runtime,
1383
+ state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
1384
+ host: e.host,
1385
+ ...(e.error ? { error: e.error } : {}),
1386
+ ...onExitView(e),
1387
+ };
1388
+ else {
1389
+ const exit = await readExit(e.exitPath);
1390
+ return {
1391
+ kind: "task",
1392
+ ...(e.mcp ? { mcp: e.mcp } : {}),
1393
+ ...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
1394
+ ...(e.permissions
1395
+ ? { permissions: e.permissions, sandbox: e.sandbox }
1396
+ : {}),
1397
+ ...(e.enforcement ? { enforcement: e.enforcement } : {}),
1398
+ ...(e.identity ? { identity: e.identity } : {}),
1399
+ // Absent on an entry written before the field, which is a different
1400
+ // fact from an explicit null: that one means Muster resolved nothing
1401
+ // and the child chose. Both are more useful than a guess.
1402
+ ...(e.model !== undefined
1403
+ ? { model: e.model, model_source: e.modelSource ?? null }
1404
+ : {}),
1405
+ id: e.id,
1406
+ runtime: e.runtime,
1407
+ cwd: e.cwd,
1408
+ // A stop wins over an exit file written after it: the worker writes
1409
+ // that file on the SIGTERM a stop sends, so its presence says the task
1410
+ // ended, not why. One written before the stop was asked is an exit.
1411
+ state: (exit || !["starting", "running"].includes(e.status)) &&
1412
+ endedByStop(e, exit)
1413
+ ? "stopped"
1414
+ : exit
1415
+ ? "exited"
1416
+ : e.status,
1417
+ ...(e.root ? { pid: e.root.pid } : {}),
1418
+ ...(exit ? { exit_code: exit.code, signal: exit.signal } : {}),
1419
+ ...(e.error ? { error: e.error } : {}),
1420
+ ...onExitView(e),
1421
+ };
1422
+ }
1423
+ }
1424
+ /**
1425
+ * The entry an id names, by launch id first. A task's id IS its launch id; a
1426
+ * session's durable id replaces it at readiness, and the `--on-exit` watcher
1427
+ * is armed with the launch id, which is the one handle that never changes.
1428
+ */
1429
+ async locate(id) {
1430
+ const byLaunch = (await this.registry.all()).find((e) => e.launchId === id);
1431
+ if (byLaunch)
1432
+ return byLaunch;
1433
+ try {
1434
+ return await this.find(id);
1435
+ }
1436
+ catch (e) {
1437
+ const message = e.message;
1438
+ // The resolver's "unknown: <every live name>" reads as a list of
1439
+ // matches; the ambiguous case does want its candidates, so it keeps them.
1440
+ throw new UnknownRun(message.startsWith("unknown")
1441
+ ? `no session or task matches ${JSON.stringify(id)}`
1442
+ : message);
1443
+ }
1444
+ }
1445
+ /**
1446
+ * Whether the launch has ended, from cheap evidence about this one entry.
1447
+ * True is provisional for a process that vanished: the caller confirms it
1448
+ * through a transaction, whose sweep is what records `exited`.
1449
+ */
1450
+ async ended(entry) {
1451
+ if (!["starting", "running"].includes(entry.status))
1452
+ return true;
1453
+ // A task's worker writes this the moment the agent exits, before it tears
1454
+ // down its group: the earliest signal there is, and the only exact one.
1455
+ if (entry.kind === "task" && (await readExit(entry.exitPath)))
1456
+ return true;
1457
+ if (!entry.root)
1458
+ return !(await isSame(entry.owner));
1459
+ if (await isSame(entry.root))
1460
+ return false;
1461
+ if ((await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean))
1462
+ return false;
1463
+ return !(entry.group && (await groupAlive(entry.group)));
1464
+ }
1465
+ /**
1466
+ * Blocks until the launch `id` names is no longer running, or `timeoutMs`
1467
+ * passes. An id that names nothing throws `UnknownRun` at once rather than
1468
+ * waiting for something that will never appear.
1469
+ *
1470
+ * Polls one entry, lock-free, backing off to two seconds: a watcher may wait
1471
+ * days on a long session, and a transaction per poll would take the registry
1472
+ * lock and run `ps` for every live entry, every time.
1473
+ */
1474
+ async wait(id, opts = {}) {
1475
+ const { launchId } = await this.locate(id);
1476
+ const deadline = opts.timeoutMs === undefined ? Infinity : Date.now() + opts.timeoutMs;
1477
+ let interval = opts.pollMs ?? 250;
1478
+ while (true) {
1479
+ const peeked = await this.registry.peek(launchId);
1480
+ if (!peeked)
1481
+ throw new UnknownRun(`Unknown launch ${id}`);
1482
+ if (await this.ended(peeked)) {
1483
+ // The transaction's sweep settles what the cheap check guessed.
1484
+ const settled = (await this.registry.all()).find((e) => e.launchId === launchId);
1485
+ if (await this.ended(settled))
1486
+ return { done: true, record: finalRecord(await this.view(settled)) };
1487
+ }
1488
+ const left = deadline - Date.now();
1489
+ if (left <= 0)
1490
+ return { done: false, record: await this.view(peeked) };
1491
+ await delay(Math.min(interval, left));
1492
+ interval = Math.min(interval * 1.5, 2000);
1493
+ }
1494
+ }
1495
+ /**
1496
+ * Records `command` against the launch and starts the detached watcher that
1497
+ * runs it once the agent exits. The watcher is muster, outside any sandbox
1498
+ * the agent runs in, so a sealed agent that can write nothing still produces
1499
+ * the signal — and it fires on a crash or a kill, which a report the agent
1500
+ * makes about itself never would.
1501
+ *
1502
+ * CLI only, deliberately absent from the MCP `run` schema: the command runs
1503
+ * unsandboxed as the operator, which no MCP caller is granted.
1504
+ */
1505
+ async armOnExit(id, command, cwd) {
1506
+ if (!command.trim())
1507
+ throw new Error("--on-exit needs a command");
1508
+ const entry = await this.locate(id);
1509
+ await this.registry.armOnExit(entry.launchId, { command, cwd });
1510
+ const watcher = spawn(process.execPath, [
1511
+ // <root>/dist from src under vitest and from dist in the package
1512
+ // alike. Same idiom as the reap-check job.
1513
+ fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
1514
+ "on-exit-watch",
1515
+ "--home",
1516
+ this.home,
1517
+ entry.launchId,
1518
+ ],
1519
+ // In muster's home, not the caller's directory: the command's directory
1520
+ // is recorded on the entry, and a watcher started inside one that is
1521
+ // later removed cannot even start Node there.
1522
+ { detached: true, stdio: "ignore", env: this.sourceEnv, cwd: this.home });
1523
+ await new Promise((resolve, reject) => {
1524
+ watcher.once("spawn", resolve);
1525
+ watcher.once("error", reject);
1526
+ });
1527
+ watcher.unref();
1528
+ await this.log.write({
1529
+ event: "on_exit_armed",
1530
+ launch_id: entry.launchId,
1531
+ watcher_pid: watcher.pid,
1532
+ });
1533
+ }
1534
+ /**
1535
+ * The watcher's whole job: wait for the launch to end, claim the command so
1536
+ * at most one watcher runs it, and run it. Its output goes to
1537
+ * `<home>/on-exit/<launch id>.log`, its status to the registry entry (shown
1538
+ * as `on_exit` by `wait` and `list`) and then to the launch log.
1539
+ *
1540
+ * Everything that can fail is settled before the claim — where it runs and
1541
+ * where its output goes — because a claim is spent whether or not the
1542
+ * command then starts. After the claim the command is spawned before any
1543
+ * launch-log write, so a log that cannot be written cannot stop it, and its
1544
+ * result lands in the registry first: a claim with no result there is a
1545
+ * watcher that died mid-command, which `on_exit` shows as `result: null`.
1546
+ */
1547
+ async runOnExit(launchId) {
1548
+ const { record } = await this.wait(launchId);
1549
+ // The hook is told how the launch ended, not about itself.
1550
+ const { on_exit: _, ...hookRecord } = record;
1551
+ const armed = (await this.registry.peek(launchId))?.onExit;
1552
+ if (!armed)
1553
+ return null;
1554
+ const prepared = await prepareOnExit({
1555
+ cwd: armed.cwd,
1556
+ fallbackCwd: this.sourceEnv.HOME ?? homedir(),
1557
+ logPath: join(this.home, "on-exit", `${launchId}.log`),
1558
+ });
1559
+ try {
1560
+ const entry = await this.registry.claimOnExit(launchId);
1561
+ if (!entry)
1562
+ return null;
1563
+ let result;
1564
+ try {
1565
+ const outcome = await runOnExitCommand({
1566
+ command: entry.onExit.command,
1567
+ cwd: prepared.cwd,
1568
+ env: this.sourceEnv,
1569
+ entry,
1570
+ record: hookRecord,
1571
+ log: prepared.log,
1572
+ onSpawn: () => void this.log
1573
+ .write({
1574
+ event: "on_exit_claimed",
1575
+ launch_id: launchId,
1576
+ state: record.state,
1577
+ ...(prepared.warnings.length
1578
+ ? { warnings: prepared.warnings }
1579
+ : {}),
1580
+ })
1581
+ .catch(() => { }),
1261
1582
  });
1583
+ result = { finished_at: new Date().toISOString(), ...outcome };
1584
+ }
1585
+ catch (e) {
1586
+ result = {
1587
+ finished_at: new Date().toISOString(),
1588
+ exit_code: null,
1589
+ signal: null,
1590
+ error: e.message,
1591
+ };
1262
1592
  }
1593
+ if (prepared.warnings.length)
1594
+ result.warnings = prepared.warnings;
1595
+ await this.registry.update(launchId, { onExitResult: result });
1596
+ await this.log
1597
+ .write({
1598
+ event: "on_exit",
1599
+ launch_id: launchId,
1600
+ state: record.state,
1601
+ exit_code: result.exit_code,
1602
+ ...(result.signal ? { signal: result.signal } : {}),
1603
+ ...(result.error ? { error: result.error } : {}),
1604
+ })
1605
+ .catch(() => { });
1606
+ return result.exit_code;
1607
+ }
1608
+ finally {
1609
+ await prepared.log?.close();
1263
1610
  }
1264
- return result;
1265
1611
  }
1266
1612
  async output(id, requester = LOCAL) {
1267
1613
  const entry = await this.find(id);