@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 +4 -0
- package/dist/cli-options.js +60 -0
- package/dist/duration.js +17 -0
- package/dist/format.js +3 -0
- package/dist/help.js +25 -0
- package/dist/identity/claude.js +61 -18
- package/dist/identity/processes.js +6 -3
- package/dist/identity-copy.js +30 -3
- package/dist/muster.js +94 -47
- package/dist/on-exit.js +98 -0
- package/dist/registry.js +81 -1
- package/dist/run.js +414 -68
- package/dist/task-worker.js +7 -2
- package/package.json +1 -1
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
|
{
|
package/dist/identity/claude.js
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
* a trust decision read from a different
|
|
21
|
-
* records its session in would describe a profile nobody
|
|
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
|
-
*
|
|
30
|
-
* directory
|
|
31
|
-
*
|
|
32
|
-
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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,
|
|
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 ${
|
|
81
|
-
`\`cd ${cwd} && claude\`, then retry (
|
|
82
|
-
`
|
|
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+/)
|
|
102
|
-
|
|
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;
|
package/dist/identity-copy.js
CHANGED
|
@@ -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
|
-
*
|
|
149
|
-
* `
|
|
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'".
|
|
71
|
-
//
|
|
72
|
-
|
|
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 &&
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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.
|
|
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
|
});
|
package/dist/on-exit.js
ADDED
|
@@ -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
|
+
}
|