@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/README.md CHANGED
@@ -70,6 +70,10 @@ muster run claude --prompt 'Review this project' --cwd .
70
70
  muster run codex --kind task --prompt 'Summarise the failing tests' --read-only
71
71
  muster output <id>
72
72
 
73
+ # Knowing it has finished: block on it, or have muster run a command then.
74
+ muster wait <id> --timeout 30m
75
+ muster run codex --kind task --prompt 'Review' --on-exit 'notify "$MUSTER_STATE"'
76
+
73
77
  # What is running, and stopping it.
74
78
  muster list --format human
75
79
  muster stop <id>
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Every flag any command takes, in one table so `asksForHelp` can tell which
3
+ * flags consume the next word: `--prompt --help` is a prompt, not a request
4
+ * for help.
5
+ */
6
+ export const CLI_OPTIONS = {
7
+ prompt: { type: "string" },
8
+ cwd: { type: "string" },
9
+ kind: { type: "string" },
10
+ host: { type: "string" },
11
+ open: { type: "boolean" },
12
+ terminal: { type: "string" },
13
+ format: { type: "string" },
14
+ permissions: { type: "string" },
15
+ sandbox: { type: "string" },
16
+ level: { type: "string" },
17
+ "read-only": { type: "boolean" },
18
+ "write-here": { type: "boolean" },
19
+ unrestricted: { type: "boolean" },
20
+ mcp: { type: "string", multiple: true },
21
+ options: { type: "string", multiple: true },
22
+ "no-mcp": { type: "boolean" },
23
+ plugin: { type: "string", multiple: true },
24
+ model: { type: "string" },
25
+ "idle-timeout": { type: "string" },
26
+ ttl: { type: "string" },
27
+ title: { type: "string" },
28
+ socket: { type: "string" },
29
+ home: { type: "string" },
30
+ "stop-now": { type: "boolean" },
31
+ "no-plugin": { type: "boolean" },
32
+ "request-key": { type: "string" },
33
+ project: { type: "string" },
34
+ identity: { type: "string" },
35
+ agent: { type: "string" },
36
+ "token-env": { type: "string" },
37
+ interactive: { type: "boolean" },
38
+ "on-exit": { type: "string" },
39
+ timeout: { type: "string" },
40
+ };
41
+ /**
42
+ * Whether a command's arguments ask for its help: `--help` or `-h` before any
43
+ * `--`, and not the value of a flag that takes one.
44
+ */
45
+ export function asksForHelp(args) {
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg === "--")
49
+ return false;
50
+ if (arg === "--help" || arg === "-h")
51
+ return true;
52
+ const name = arg.startsWith("--") ? arg.slice(2) : undefined;
53
+ const option = name && !name.includes("=")
54
+ ? CLI_OPTIONS[name]
55
+ : undefined;
56
+ if (option?.type === "string")
57
+ i++;
58
+ }
59
+ return false;
60
+ }
package/dist/duration.js CHANGED
@@ -19,3 +19,20 @@ export function parseDuration(value, label) {
19
19
  throw new Error(`${label} must be a duration like 90s, 30m, 4h, or off (got ${JSON.stringify(value)})`);
20
20
  return seconds;
21
21
  }
22
+ /**
23
+ * `wait --timeout`: seconds as a bare number, which is what `timeout(1)` and
24
+ * shell callers write, or a duration. A lifecycle limit refuses the bare
25
+ * number because a wrong unit reaps a session; here the unit is the obvious
26
+ * one. `off` is refused: no timeout is spelled by leaving the flag out.
27
+ */
28
+ export function waitSeconds(value) {
29
+ const raw = value.trim();
30
+ const seconds = /^\d+$/.test(raw)
31
+ ? Number(raw)
32
+ : /^\d+[smh]$/i.test(raw)
33
+ ? parseDuration(raw, "--timeout")
34
+ : 0;
35
+ if (seconds <= 0)
36
+ throw new Error(`--timeout must be seconds or a duration like 90s, 30m, 4h (got ${JSON.stringify(value)})`);
37
+ return seconds;
38
+ }
package/dist/format.js CHANGED
@@ -38,6 +38,9 @@ function recordHuman(record, color) {
38
38
  const id = record.id ?? record.thread_id ?? record.session_id;
39
39
  if (record.stopped === true)
40
40
  return `${paint("Stopped", 90)} ${clean(id)}.`;
41
+ // `stop` on a launch that had already ended changes nothing and says so.
42
+ if (record.stopped === false)
43
+ return `Not stopped: ${clean(id)} had already ${clean(record.state)}.`;
41
44
  // `muster title` answers with the id and the title alone, no kind.
42
45
  if (record.kind === undefined && record.title !== undefined)
43
46
  return `Titled ${clean(id)}: ${clean(record.title)}.`;
package/dist/help.js CHANGED
@@ -97,6 +97,10 @@ export const COMMANDS = [
97
97
  "--request-key KEY",
98
98
  "name the request; sending it again returns the same launch",
99
99
  ],
100
+ [
101
+ "--on-exit COMMAND",
102
+ "run COMMAND with /bin/sh once the agent exits, however it ends; muster runs it, outside any sandbox, with MUSTER_ID, MUSTER_RUNTIME, MUSTER_KIND, MUSTER_STATE, MUSTER_EXIT_CODE, MUSTER_SIGNAL, MUSTER_OUTPUT, MUSTER_ERROR and MUSTER_RECORD set",
103
+ ],
100
104
  ["--format FORMAT", "json (default) or human"],
101
105
  ["-- RUNTIME_OPTIONS", "everything after -- is passed to the runtime"],
102
106
  ],
@@ -105,11 +109,32 @@ export const COMMANDS = [
105
109
  "A sealed launch does not read your profile's or the project's settings, so it also loads no rules or CLAUDE.md.",
106
110
  "Use one of the three flags, --level, or --permissions/--sandbox; naming two is an error rather than a precedence rule.",
107
111
  "A pty launch keeps this process running and Ctrl-C stops it; a tmux launch survives this process exiting.",
112
+ "--on-exit: MUSTER_EXIT_CODE and MUSTER_SIGNAL are empty for a session, whose terminal keeps no exit status, and MUSTER_OUTPUT (the captured output's path) is empty for one too. The command's own output goes to ~/.muster/on-exit/<launch id>.log. A launch that fails at readiness runs it too, with MUSTER_STATE=failed and MUSTER_ERROR. wait and list show it as on_exit: {claimed_at, result}. It is not run if the machine restarts first.",
108
113
  ],
109
114
  examples: [
110
115
  "muster run claude --prompt 'Review this project' --cwd .",
111
116
  "muster run claude --read-only --prompt 'Audit this' --cwd .",
112
117
  "muster run codex --identity work --write-here --prompt 'Ship it'",
118
+ "muster run codex --kind task --prompt 'Review' --on-exit 'notify.sh'",
119
+ ],
120
+ },
121
+ {
122
+ name: "wait",
123
+ summary: "block until a session or task has ended",
124
+ synopsis: "muster wait <id> [--timeout SECONDS] [--format json|human]",
125
+ options: [
126
+ [
127
+ "--timeout SECONDS",
128
+ "give up after this long: seconds, or 90s, 30m, 4h",
129
+ ],
130
+ ["--format FORMAT", "json (default) or human"],
131
+ ],
132
+ notes: [
133
+ "Prints the final record, with state, exit_code and signal (null for a session, whose terminal keeps no exit status). Exits 0 when it has ended, 2 for an id that names nothing, and 124 on timeout, printing the record as it stands.",
134
+ ],
135
+ examples: [
136
+ "muster wait 6fae755b-8f5d-4425-8ed7-69e84bfac8c5",
137
+ "muster wait simple-cms-46 --timeout 30m",
113
138
  ],
114
139
  },
115
140
  {
@@ -1,10 +1,11 @@
1
1
  /** Registry-reading mechanics vendored from Tin Can src/claude/discover.ts at
2
2
  * fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). Only captured descendants are
3
3
  * read, rather than scanning and diffing live sessions. */
4
- import { readFile, access } from "node:fs/promises";
5
- import { join } from "node:path";
4
+ import { readFile, access, realpath } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
6
  import { homedir } from "node:os";
7
- import { descendants } from "./processes.js";
7
+ import { command, descendants } from "./processes.js";
8
+ import { workspaceTrustKey } from "./workspace-key.js";
8
9
  /**
9
10
  * Where Claude Code records its sessions. `CLAUDE_CONFIG_DIR` relocates the whole
10
11
  * configuration directory, sessions included, so discovery must follow it —
@@ -15,10 +16,11 @@ import { descendants } from "./processes.js";
15
16
  export function claudeSessionsDir(env) {
16
17
  return join(claudeConfigDir(env), "sessions");
17
18
  }
18
- /** The configuration directory itself, which `claudeSessionsDir` and the
19
- * workspace-trust read below both hang off. Split out so the two cannot drift:
20
- * a trust decision read from a different directory than the one the launch
21
- * records its session in would describe a profile nobody is launching. */
19
+ /** The configuration directory itself, which `claudeSessionsDir` and
20
+ * `claudeTrustFile` both follow. Resolved from the same CLAUDE_CONFIG_DIR so
21
+ * the two cannot drift: a trust decision read from a different profile than
22
+ * the one the launch records its session in would describe a profile nobody
23
+ * is launching. */
22
24
  export function claudeConfigDir(env) {
23
25
  const configured = env.CLAUDE_CONFIG_DIR;
24
26
  if (typeof configured === "string" && configured.length)
@@ -26,10 +28,49 @@ export function claudeConfigDir(env) {
26
28
  return join(env.HOME ?? homedir(), ".claude");
27
29
  }
28
30
  /**
29
- * Whether Claude Code has recorded an accepted workspace-trust dialog for one
30
- * directory, in the profile a launch would use. Trust is per-directory and is
31
- * NOT inherited from a parent: a trusted `/Source/arm` says nothing about
32
- * `/Source/arm/centrumx`, and the child still stops at the dialog.
31
+ * The file Claude Code keeps workspace trust in. Not inside the configuration
32
+ * directory by default: with no CLAUDE_CONFIG_DIR it is `~/.claude.json`,
33
+ * beside `~/.claude`, and only a relocated profile keeps it inside.
34
+ */
35
+ export function claudeTrustFile(env) {
36
+ const configured = env.CLAUDE_CONFIG_DIR;
37
+ return typeof configured === "string" && configured.length
38
+ ? join(configured, ".claude.json")
39
+ : join(env.HOME ?? homedir(), ".claude.json");
40
+ }
41
+ /**
42
+ * Every key whose accepted dialog lets a launch in `cwd` past Claude's trust
43
+ * dialog. The dialog gate walks up from the launch directory and stops at the
44
+ * git repo's top level, or walks to `/` outside a repository — so a trusted
45
+ * parent counts inside its own repo and never across a repo boundary. The
46
+ * repo's own trust key (the main checkout, for a linked worktree) is what
47
+ * Muster writes, so it counts too. See `workspaceTrustKey`.
48
+ */
49
+ async function trustKeys(cwd) {
50
+ const resolved = await realpath(cwd).catch(() => cwd);
51
+ let top;
52
+ try {
53
+ const shown = (await command("git", ["-C", resolved, "rev-parse", "--show-toplevel"], 2000)).trim();
54
+ if (shown)
55
+ top = await realpath(shown).catch(() => shown);
56
+ }
57
+ catch { }
58
+ const keys = new Set([cwd]);
59
+ for (let dir = resolved;; dir = dirname(dir)) {
60
+ keys.add(dir);
61
+ if (dir === top || dirname(dir) === dir)
62
+ break;
63
+ }
64
+ keys.add(await workspaceTrustKey(resolved));
65
+ return [...keys];
66
+ }
67
+ /**
68
+ * Whether Claude Code would let a session in `cwd` past its workspace-trust
69
+ * dialog, in the profile a launch would use: an accepted dialog on the
70
+ * directory, on an ancestor inside the same git repo, or on the repo's trust
71
+ * key. Checking the exact directory alone called every worktree and every
72
+ * subdirectory of a trusted repo untrusted, and blamed trust for a launch that
73
+ * had simply timed out (#68).
33
74
  *
34
75
  * This only ever READS. Muster does not accept the dialog on the operator's
35
76
  * behalf — `setWorkspaceTrust` writes that flag into an identity copy Muster
@@ -44,18 +85,20 @@ export function claudeConfigDir(env) {
44
85
  export async function claudeWorkspaceTrusted(env, cwd) {
45
86
  let raw;
46
87
  try {
47
- raw = await readFile(join(claudeConfigDir(env), ".claude.json"), "utf8");
88
+ raw = await readFile(claudeTrustFile(env), "utf8");
48
89
  }
49
90
  catch {
50
91
  return false;
51
92
  }
93
+ let projects;
52
94
  try {
53
- const data = JSON.parse(raw);
54
- return data.projects?.[cwd]?.hasTrustDialogAccepted === true;
95
+ projects =
96
+ JSON.parse(raw).projects ?? {};
55
97
  }
56
98
  catch {
57
99
  return false;
58
100
  }
101
+ return (await trustKeys(cwd)).some((key) => projects[key]?.hasTrustDialogAccepted === true);
59
102
  }
60
103
  /**
61
104
  * The message a Claude launch dies with when no session ever registered.
@@ -74,12 +117,12 @@ export async function claudeWorkspaceTrusted(env, cwd) {
74
117
  * it runs headless and never sees the dialog, so it succeeds where a session
75
118
  * hangs.
76
119
  */
77
- export function claudeRegistryDiagnostic(trusted, cwd, configDir, opts = {}) {
120
+ export function claudeRegistryDiagnostic(trusted, cwd, trustFile, opts = {}) {
78
121
  if (!trusted)
79
122
  return (`no Claude session registry found: workspace trust has not been ` +
80
- `accepted for ${cwd} in ${configDir} — accept it once with ` +
81
- `\`cd ${cwd} && claude\`, then retry (trust is per-directory and is ` +
82
- `not inherited from a parent directory)`);
123
+ `accepted for ${cwd} in ${trustFile} — accept it once with ` +
124
+ `\`cd ${cwd} && claude\`, then retry (a trusted parent directory ` +
125
+ `counts only inside the same git repository)`);
83
126
  if (opts.bypassWarningPending)
84
127
  return (`no Claude session registry found (${cwd} is trusted for this profile): ` +
85
128
  `this launch runs in bypass permissions, and Claude stops on its ` +
@@ -96,10 +96,13 @@ export async function descendants(root, deadline = Date.now() + 1000, group) {
96
96
  /** Whether anything still runs in a group we started. */
97
97
  export async function groupAlive(group, deadline = Date.now() + 1000) {
98
98
  try {
99
- const rows = (await command("ps", ["-axo", "pid=,pgid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
99
+ const rows = (await command("ps", ["-axo", "pid=,pgid=,stat="], Math.max(1, Math.min(1000, deadline - Date.now()))))
100
100
  .split("\n")
101
- .map((l) => l.trim().split(/\s+/).map(Number));
102
- return rows.some(([pid, pgid]) => pid && pid > 1 && pgid === group);
101
+ .map((l) => l.trim().split(/\s+/));
102
+ // A zombie is not alive, as `processRef` already holds: a group left with
103
+ // only an unreaped zombie in it has ended, and counting it kept `wait`
104
+ // waiting for as long as its parent chose not to reap.
105
+ return rows.some(([pid, pgid, stat]) => Number(pid) > 1 && Number(pgid) === group && !stat?.startsWith("Z"));
103
106
  }
104
107
  catch {
105
108
  return false;
@@ -1,4 +1,4 @@
1
- import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { appendFile, copyFile, mkdir, readFile, writeFile, } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import TOML from "@iarna/toml";
4
4
  import { identityPath } from "./identity-store.js";
@@ -127,6 +127,15 @@ export async function setWorkspaceTrust(copyPath, cwd) {
127
127
  data.projects = projects;
128
128
  await writeFile(file, JSON.stringify(data), { mode: 0o600 });
129
129
  }
130
+ function parses(text) {
131
+ try {
132
+ TOML.parse(text);
133
+ return true;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
130
139
  /**
131
140
  * Set Codex's directory-trust flag for one directory, inside a copy Muster just
132
141
  * created. Codex has its own gate, unrelated to Claude's: without a
@@ -145,8 +154,10 @@ export async function setWorkspaceTrust(copyPath, cwd) {
145
154
  * parses and is accepted, and the prompt still appears: the check reads
146
155
  * persisted config, so the entry has to be in the file.
147
156
  *
148
- * The user's real `~/.codex/config.toml` is never touched, for the same reason
149
- * `setWorkspaceTrust` leaves the live `~/.claude.json` alone.
157
+ * Also called on the user's own `~/.codex/config.toml`, for an
158
+ * `--options auto-approve-path` launch with no CODEX_HOME — the file that
159
+ * launch's Codex reads. That is why a new entry is appended rather than the
160
+ * file re-serialised.
150
161
  */
151
162
  export async function setCodexWorkspaceTrust(copyPath, cwd) {
152
163
  const file = join(copyPath, "config.toml");
@@ -177,6 +188,22 @@ export async function setCodexWorkspaceTrust(copyPath, cwd) {
177
188
  const existing = (projects[cwd] ?? {});
178
189
  if (existing.trust_level === "trusted")
179
190
  return;
191
+ // No entry yet: append one table, exactly as Codex itself does when you
192
+ // answer its dialog. This file may be the user's own `~/.codex/config.toml`
193
+ // (a launch with no CODEX_HOME), and re-serialising it would drop every
194
+ // comment and reorder every key in it. Not when the result would not parse
195
+ // — `projects` written as an inline table, under any quoting of its key,
196
+ // which a second `[projects.…]` header would redefine.
197
+ if (!(cwd in projects)) {
198
+ const lead = raw && !raw.endsWith("\n") ? "\n" : "";
199
+ const entry = `${lead}\n[projects.${JSON.stringify(cwd)}]\ntrust_level = "trusted"\n`;
200
+ if (parses((raw ?? "") + entry)) {
201
+ // ~/.codex itself may not exist on a machine that has never run Codex.
202
+ await mkdir(copyPath, { recursive: true, mode: 0o700 });
203
+ await appendFile(file, entry, { mode: 0o600 });
204
+ return;
205
+ }
206
+ }
180
207
  projects[cwd] = { ...existing, trust_level: "trusted" };
181
208
  data.projects = projects;
182
209
  // Re-serialised rather than appended, so an entry the template already
package/dist/muster.js CHANGED
@@ -5,7 +5,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
7
7
  import { z } from "zod";
8
- import { Muster } from "./run.js";
8
+ import { Muster, UnknownRun, durableId, failedLaunchId } from "./run.js";
9
+ import { waitSeconds } from "./duration.js";
9
10
  import { delay } from "./identity/processes.js";
10
11
  import { formatHuman, humanColorEnabled, statusText } from "./format.js";
11
12
  import { checkPlugins, npmRegistry } from "./doctor.js";
@@ -17,6 +18,7 @@ import { toolGate } from "./mcp.js";
17
18
  import { ALLOW_LAUNCH, MCP_SUBCOMMANDS, mcpCommand, notEnabled, selfInvocation, } from "./mcp-install.js";
18
19
  import { normalizeOpenFlag, usesTerminalFlag } from "./guard.js";
19
20
  import { commandHelp, topLevelHelp } from "./help.js";
21
+ import { CLI_OPTIONS, asksForHelp } from "./cli-options.js";
20
22
  import { promptForMissing, terminalReader } from "./prompt.js";
21
23
  const listSchema = z
22
24
  .object({ kind: z.enum(["session", "task"]).optional() })
@@ -67,9 +69,10 @@ async function main() {
67
69
  }
68
70
  // Recognised before parsing, not during it: the parser is strict and has no
69
71
  // `help` option, so `muster setup-identity --help` used to be answered with
70
- // "Unknown option '--help'". Only in second position, so a `--help` that is
71
- // some flag's value still reaches the command it was written for.
72
- if ((argv[1] === "--help" || argv[1] === "-h") && commandHelp(argv[0])) {
72
+ // "Unknown option '--help'". Anywhere among the command's own flags, so
73
+ // `muster run codex --help` is help too; see `asksForHelp` for the `--help`
74
+ // that is some flag's value, which still reaches the command.
75
+ if (commandHelp(argv[0] ?? "") && asksForHelp(argv.slice(1))) {
73
76
  process.stdout.write(commandHelp(argv[0]));
74
77
  return;
75
78
  }
@@ -201,41 +204,13 @@ async function main() {
201
204
  args: normalizeOpenFlag(argv),
202
205
  allowPositionals: true,
203
206
  strict: true,
204
- options: {
205
- prompt: { type: "string" },
206
- cwd: { type: "string" },
207
- kind: { type: "string" },
208
- host: { type: "string" },
209
- open: { type: "boolean" },
210
- terminal: { type: "string" },
211
- format: { type: "string" },
212
- permissions: { type: "string" },
213
- sandbox: { type: "string" },
214
- level: { type: "string" },
215
- "read-only": { type: "boolean" },
216
- "write-here": { type: "boolean" },
217
- unrestricted: { type: "boolean" },
218
- mcp: { type: "string", multiple: true },
219
- options: { type: "string", multiple: true },
220
- "no-mcp": { type: "boolean" },
221
- plugin: { type: "string", multiple: true },
222
- model: { type: "string" },
223
- "idle-timeout": { type: "string" },
224
- ttl: { type: "string" },
225
- title: { type: "string" },
226
- socket: { type: "string" },
227
- home: { type: "string" },
228
- "stop-now": { type: "boolean" },
229
- "no-plugin": { type: "boolean" },
230
- "request-key": { type: "string" },
231
- project: { type: "string" },
232
- identity: { type: "string" },
233
- agent: { type: "string" },
234
- "token-env": { type: "string" },
235
- interactive: { type: "boolean" },
236
- },
207
+ options: CLI_OPTIONS,
237
208
  });
238
- const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, "read-only": readOnly, "write-here": writeHere, unrestricted, ...values } = parsed;
209
+ const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, "read-only": readOnly, "write-here": writeHere, unrestricted, "on-exit": onExit, timeout, ...values } = parsed;
210
+ if (onExit !== undefined && command !== "run")
211
+ throw new Error("--on-exit applies only to run");
212
+ if (timeout !== undefined && command !== "wait")
213
+ throw new Error("--timeout applies only to wait");
239
214
  // The plain spellings of the three sealed levels. Each is a level, so the
240
215
  // existing exclusivity with --permissions/--sandbox applies unchanged once
241
216
  // it is folded into `values.level`.
@@ -267,7 +242,9 @@ async function main() {
267
242
  ["--home", homeFlag],
268
243
  ["--stop-now", stopNow],
269
244
  ])
270
- if (value !== undefined && command !== "reap-check")
245
+ if (value !== undefined &&
246
+ command !== "reap-check" &&
247
+ !(name === "--home" && command === "on-exit-watch"))
271
248
  throw new Error(`${name} applies only to reap-check`);
272
249
  if (requestKey !== undefined && command !== "run")
273
250
  throw new Error("--request-key applies only to run");
@@ -297,13 +274,41 @@ async function main() {
297
274
  process.stderr.write("[muster] --terminal is deprecated and will be removed in v2; pass the app to --open instead, or set terminal in config.toml\n");
298
275
  const runtime = positionals[0];
299
276
  const args = positionals.slice(1);
300
- const record = await muster.run({
301
- runtime,
302
- ...values,
303
- ...(requestKey === undefined ? {} : { requestKey }),
304
- ...(idleTimeout === undefined ? {} : { idleTimeout }),
305
- args,
306
- });
277
+ let record;
278
+ try {
279
+ record = await muster.run({
280
+ runtime,
281
+ ...values,
282
+ ...(requestKey === undefined ? {} : { requestKey }),
283
+ ...(idleTimeout === undefined ? {} : { idleTimeout }),
284
+ args,
285
+ });
286
+ }
287
+ catch (e) {
288
+ // A launch that failed still ended, and the caller is waiting on its
289
+ // command to say so: armed on the failed entry, the watcher finds it
290
+ // already over and runs it with MUSTER_STATE=failed and MUSTER_ERROR.
291
+ // The launch's own error is still what this command fails with.
292
+ const launchId = failedLaunchId(e);
293
+ if (onExit !== undefined && launchId)
294
+ await muster
295
+ .armOnExit(launchId, onExit, process.cwd())
296
+ .catch((armError) => process.stderr.write(`[muster] --on-exit not run for the failed launch: ${armError.message}\n`));
297
+ throw e;
298
+ }
299
+ // Armed after the launch and before the record is printed, so a caller
300
+ // that reads the record knows the watcher exists. A launch that is up
301
+ // but could not be armed still prints its record — the session is real
302
+ // and needs stopping — and then fails, because the caller asked for a
303
+ // signal it will not get.
304
+ if (onExit !== undefined)
305
+ try {
306
+ await muster.armOnExit(durableId(record), onExit, process.cwd());
307
+ }
308
+ catch (e) {
309
+ print(record);
310
+ throw e;
311
+ }
307
312
  print(record);
308
313
  if (record.kind === "session" && record.host === "pty") {
309
314
  process.stderr.write("[muster] pty: not watchable or attachable; this process owns the session. Ctrl-C stops it.\n");
@@ -409,6 +414,41 @@ async function main() {
409
414
  const { id } = idSchema.parse({ id: positionals[0] });
410
415
  print(await muster.title(id, positionals[1]));
411
416
  }
417
+ else if (command === "wait") {
418
+ if (positionals.length !== 1 || Object.keys(values).length)
419
+ throw new Error("wait requires exactly one id");
420
+ const { id } = idSchema.parse({ id: positionals[0] });
421
+ try {
422
+ const { done, record } = await muster.wait(id, {
423
+ ...(timeout === undefined
424
+ ? {}
425
+ : { timeoutMs: waitSeconds(timeout) * 1000 }),
426
+ });
427
+ print(record);
428
+ // 124 is timeout(1)'s status, which a shell caller already tests for.
429
+ if (!done)
430
+ process.exitCode = 124;
431
+ }
432
+ catch (e) {
433
+ if (!(e instanceof UnknownRun))
434
+ throw e;
435
+ process.stderr.write(`[muster] ${e.message}\n`);
436
+ process.exitCode = 2;
437
+ }
438
+ }
439
+ else if (command === "on-exit-watch") {
440
+ // Hidden, like reap-check: started detached by `run --on-exit`, never
441
+ // typed. Its own exit status is unread; the command's goes to the log.
442
+ if (positionals.length !== 1 || !homeFlag)
443
+ throw new Error("on-exit-watch requires --home and one launch id");
444
+ const watcher = await Muster.create({ home: homeFlag });
445
+ try {
446
+ await watcher.runOnExit(positionals[0]);
447
+ }
448
+ finally {
449
+ await watcher.close();
450
+ }
451
+ }
412
452
  else if (command === "reap-check") {
413
453
  // Hidden: armed by muster itself as a tmux job, never typed by a person.
414
454
  // NOTE: `Muster.create()` above already ran against the REAL home before
@@ -435,6 +475,13 @@ async function main() {
435
475
  }
436
476
  }
437
477
  main().catch((e) => {
438
- process.stderr.write(`[muster] ${e.message}\n`);
478
+ const command = process.argv[2] ?? "";
479
+ // parseArgs's own text explains how to pass a positional that starts with a
480
+ // dash, which is never what a mistyped flag meant; the flag list is.
481
+ const hint = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" &&
482
+ commandHelp(command)
483
+ ? `\n[muster] see \`muster ${command} --help\` for its options`
484
+ : "";
485
+ process.stderr.write(`[muster] ${e.message}${hint}\n`);
439
486
  process.exitCode = 1;
440
487
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Running an `--on-exit` command: what it is told, and where its output goes.
3
+ * Kept out of run.ts so the environment contract can be tested without a
4
+ * launch, since that contract is what every caller's script depends on.
5
+ */
6
+ import { constants } from "node:fs";
7
+ import { access, mkdir, open, stat } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+ import { spawn } from "node:child_process";
10
+ /**
11
+ * The variables the command receives. Strings only, and an empty string where
12
+ * the fact was not observed — a session's exit status, a session's output —
13
+ * because a shell cannot tell an unset variable from a mistyped one.
14
+ */
15
+ export function onExitEnv(entry, record) {
16
+ return {
17
+ MUSTER_ID: String(record.id),
18
+ MUSTER_RUNTIME: entry.runtime,
19
+ MUSTER_KIND: entry.kind,
20
+ MUSTER_STATE: String(record.state),
21
+ MUSTER_EXIT_CODE: record.exit_code === null || record.exit_code === undefined
22
+ ? ""
23
+ : String(record.exit_code),
24
+ MUSTER_SIGNAL: record.signal ? String(record.signal) : "",
25
+ // A path rather than the text: a task's output can be megabytes, and the
26
+ // environment has a size limit that would fail the command outright.
27
+ MUSTER_OUTPUT: entry.kind === "task" && entry.outputPath ? entry.outputPath : "",
28
+ // Why a launch failed before it was ever up (`MUSTER_STATE=failed`).
29
+ MUSTER_ERROR: record.error ? String(record.error) : "",
30
+ MUSTER_RECORD: JSON.stringify(record),
31
+ };
32
+ }
33
+ /**
34
+ * Why a directory cannot be a command's cwd, or undefined when it can: it must
35
+ * be a directory and searchable, because a spawn into one that is not fails
36
+ * after the claim is spent. Still a check, not a lock — the directory can
37
+ * change between here and the spawn, and that race is left to the spawn's own
38
+ * error, which the result records.
39
+ */
40
+ async function enterable(dir) {
41
+ try {
42
+ if (!(await stat(dir)).isDirectory())
43
+ return "not a directory";
44
+ await access(dir, constants.X_OK);
45
+ return undefined;
46
+ }
47
+ catch (e) {
48
+ return e.message;
49
+ }
50
+ }
51
+ /**
52
+ * Where the command will run and where its output will go, settled before the
53
+ * command is claimed, because a claim is spent whether or not it then starts.
54
+ * Neither failure is allowed to cost the signal: a directory removed since the
55
+ * launch, or one that can no longer be entered, falls back to `fallbackCwd`
56
+ * (and that to `/`), and a log that cannot be opened leaves the output
57
+ * discarded. Each fallback is reported, for the launch log.
58
+ */
59
+ export async function prepareOnExit(opts) {
60
+ const warnings = [];
61
+ let cwd = opts.cwd;
62
+ const unusable = await enterable(cwd);
63
+ if (unusable) {
64
+ warnings.push(`cwd ${cwd} unusable (${unusable}); ran in ${opts.fallbackCwd}`);
65
+ cwd = opts.fallbackCwd;
66
+ const fallback = await enterable(cwd);
67
+ if (fallback) {
68
+ warnings.push(`fallback ${cwd} unusable (${fallback}); ran in /`);
69
+ cwd = "/";
70
+ }
71
+ }
72
+ try {
73
+ await mkdir(dirname(opts.logPath), { recursive: true, mode: 0o700 });
74
+ return { cwd, log: await open(opts.logPath, "a", 0o600), warnings };
75
+ }
76
+ catch (e) {
77
+ warnings.push(`log ${opts.logPath} unusable (${e.message}); output discarded`);
78
+ return { cwd, warnings };
79
+ }
80
+ }
81
+ /**
82
+ * Runs `command` through `/bin/sh -c`, resolving to how it ended, or to why it
83
+ * never started. `onSpawn` is called once the shell is running, so whatever it
84
+ * does cannot keep the command from starting.
85
+ */
86
+ export async function runOnExitCommand(opts) {
87
+ const out = opts.log?.fd ?? "ignore";
88
+ return new Promise((resolve) => {
89
+ const child = spawn("/bin/sh", ["-c", opts.command], {
90
+ cwd: opts.cwd,
91
+ env: { ...opts.env, ...onExitEnv(opts.entry, opts.record) },
92
+ stdio: ["ignore", out, out],
93
+ });
94
+ child.once("spawn", () => opts.onSpawn?.());
95
+ child.once("error", (e) => resolve({ exit_code: null, signal: null, error: e.message }));
96
+ child.once("exit", (code, signal) => resolve({ exit_code: code, signal: signal ?? null }));
97
+ });
98
+ }