@brutalsystems/muster 0.7.20 → 0.8.0
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 +9 -0
- package/dist/config.js +10 -0
- package/dist/duration.js +19 -0
- package/dist/format.js +19 -2
- package/dist/guard.js +53 -1
- package/dist/help.js +8 -0
- package/dist/hosts/tmux.js +47 -0
- package/dist/mcp-tools.js +8 -0
- package/dist/muster.js +41 -1
- package/dist/reap.js +131 -0
- package/dist/run.js +41 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,6 +114,15 @@ enabled. Use Muster's normalized permission flags. Prompt strings are always
|
|
|
114
114
|
passed as one literal argument. Codex and Claude launches and OpenCode tasks put
|
|
115
115
|
that value after `--`; OpenCode sessions use the dedicated `--prompt` value.
|
|
116
116
|
|
|
117
|
+
A tmux session is stopped after 30 minutes with no pane activity. Set
|
|
118
|
+
`--idle-timeout 2h` to lengthen it, `--idle-timeout off` to disable it for one
|
|
119
|
+
launch, or `[session] idle_timeout` in `config.toml` to change the default for
|
|
120
|
+
every launch. `--ttl` adds a hard ceiling regardless of activity, for a session
|
|
121
|
+
that looks busy only because something is looping inside it. A session with a
|
|
122
|
+
client attached is never stopped. The limit applies to tmux sessions only: pty
|
|
123
|
+
and macos-terminal sessions die with the process that launched them, and a task
|
|
124
|
+
ends when its prompt does.
|
|
125
|
+
|
|
117
126
|
OpenCode inherits the provider and model definitions from its normal user and
|
|
118
127
|
project configuration, so configured local OpenAI-compatible providers such as
|
|
119
128
|
`coldfire-local/qwen3-30b-a3b` remain available. Muster never rewrites those
|
package/dist/config.js
CHANGED
|
@@ -52,6 +52,15 @@ export const requesterEnrolmentSchema = z
|
|
|
52
52
|
profile: z.string().min(1),
|
|
53
53
|
})
|
|
54
54
|
.strict();
|
|
55
|
+
/** Raw strings, not parsed durations: a bad value in config.toml must fail with
|
|
56
|
+
* the same message a bad flag does, and that parser lives in one place. */
|
|
57
|
+
const sessionSectionSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
idle_timeout: z.string().min(1).optional(),
|
|
60
|
+
ttl: z.string().min(1).optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict()
|
|
63
|
+
.default({});
|
|
55
64
|
export const configSchema = z
|
|
56
65
|
.object({
|
|
57
66
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
@@ -75,6 +84,7 @@ export const configSchema = z
|
|
|
75
84
|
default_plugins: z.array(pluginName).default([]),
|
|
76
85
|
plugins: z.record(pluginName, pluginSchema).default({}),
|
|
77
86
|
opencode: openCodeSectionSchema,
|
|
87
|
+
session: sessionSectionSchema,
|
|
78
88
|
allow_dangerous_flags: z.boolean().default(false),
|
|
79
89
|
requester_profiles: z
|
|
80
90
|
.record(z.string().regex(/^[a-zA-Z0-9_-]+$/), requesterProfileSchema)
|
package/dist/duration.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const UNITS = { s: 1, m: 60, h: 3600 };
|
|
2
|
+
/**
|
|
3
|
+
* `90s`, `30m`, `4h`, or `off`.
|
|
4
|
+
*
|
|
5
|
+
* A bare number is refused rather than assigned a unit: guessing seconds turns
|
|
6
|
+
* `--ttl 30` into half a minute and reaps a session the caller meant to keep
|
|
7
|
+
* for half an hour. Zero and negative are refused for the same reason — they
|
|
8
|
+
* would read as "reap immediately", which no caller types on purpose.
|
|
9
|
+
*/
|
|
10
|
+
export function parseDuration(value, label) {
|
|
11
|
+
const raw = value.trim();
|
|
12
|
+
if (raw.toLowerCase() === "off")
|
|
13
|
+
return "off";
|
|
14
|
+
const match = /^(\d+)([smh])$/.exec(raw);
|
|
15
|
+
const seconds = match ? Number(match[1]) * UNITS[match[2]] : 0;
|
|
16
|
+
if (!match || seconds <= 0)
|
|
17
|
+
throw new Error(`${label} must be a duration like 90s, 30m, 4h, or off (got ${JSON.stringify(value)})`);
|
|
18
|
+
return seconds;
|
|
19
|
+
}
|
package/dist/format.js
CHANGED
|
@@ -45,9 +45,26 @@ function recordHuman(record, color) {
|
|
|
45
45
|
if (record.sandbox)
|
|
46
46
|
lines.push(field("Sandbox", clean(record.sandbox)));
|
|
47
47
|
// Only when known: a null means the child chose its own model, and "-" in a
|
|
48
|
-
// human listing would read as a value rather than an absence.
|
|
48
|
+
// human listing would read as a value rather than an absence. The source is
|
|
49
|
+
// shown because "pinned by the caller" and "fell back to the configured
|
|
50
|
+
// default" are the distinction the field exists to make.
|
|
49
51
|
if (record.model)
|
|
50
|
-
lines.push(field("Model", clean(record.model)
|
|
52
|
+
lines.push(field("Model", `${clean(record.model)}${record.model_source === "request"
|
|
53
|
+
? " (requested)"
|
|
54
|
+
: record.model_source === "config"
|
|
55
|
+
? " (configured default)"
|
|
56
|
+
: ""}`));
|
|
57
|
+
// Rendered from seconds rather than rounded to minutes: parseDuration accepts
|
|
58
|
+
// 45s, and "1m" for a 45s limit misreports it.
|
|
59
|
+
const duration = (seconds) => seconds % 3600 === 0
|
|
60
|
+
? `${seconds / 3600}h`
|
|
61
|
+
: seconds % 60 === 0
|
|
62
|
+
? `${seconds / 60}m`
|
|
63
|
+
: `${seconds}s`;
|
|
64
|
+
if (record.idle_timeout)
|
|
65
|
+
lines.push(field("Idle timeout", duration(record.idle_timeout)));
|
|
66
|
+
if (record.ttl)
|
|
67
|
+
lines.push(field("TTL", duration(record.ttl)));
|
|
51
68
|
if (record.mcp)
|
|
52
69
|
lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
|
|
53
70
|
for (const warning of record.mcp_warnings ?? [])
|
package/dist/guard.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
5
5
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
6
6
|
import { pluginName } from "./plugins.js";
|
|
7
7
|
import { RequesterConfigError } from "./requester-policy.js";
|
|
8
|
+
import { parseDuration } from "./duration.js";
|
|
8
9
|
export const runSchema = z
|
|
9
10
|
.object({
|
|
10
11
|
runtime: z.enum(["codex", "claude", "opencode"]),
|
|
@@ -25,6 +26,8 @@ export const runSchema = z
|
|
|
25
26
|
mcp: z.array(mcpName).optional(),
|
|
26
27
|
plugin: z.array(pluginName).optional(),
|
|
27
28
|
model: z.string().min(1).optional(),
|
|
29
|
+
idleTimeout: z.string().min(1).optional(),
|
|
30
|
+
ttl: z.string().min(1).optional(),
|
|
28
31
|
project: z
|
|
29
32
|
.string()
|
|
30
33
|
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
@@ -152,6 +155,11 @@ export function paramsFingerprint(req, permissions, mcp, plugins) {
|
|
|
152
155
|
// already running under account-a, reported as success. A reused key
|
|
153
156
|
// with different parameters must be refused, not answered.
|
|
154
157
|
req.identity ?? null,
|
|
158
|
+
// A reused key whose lifecycle differs must be refused, not answered:
|
|
159
|
+
// handing back a 30m session to a caller that asked for `off` gives it
|
|
160
|
+
// a session that dies underneath it.
|
|
161
|
+
req.idleTimeout ?? null,
|
|
162
|
+
req.ttl ?? null,
|
|
155
163
|
req.args,
|
|
156
164
|
]))
|
|
157
165
|
.digest("hex");
|
|
@@ -240,6 +248,46 @@ const RUNTIME_OPTIONS = {
|
|
|
240
248
|
consumed: [],
|
|
241
249
|
},
|
|
242
250
|
};
|
|
251
|
+
/** 30 minutes. Long enough that a trust prompt or a coffee does not cost a
|
|
252
|
+
* session; short enough that an orphan dies within the hour. */
|
|
253
|
+
export const DEFAULT_IDLE_TIMEOUT = 1800;
|
|
254
|
+
/**
|
|
255
|
+
* Flag, then config, then the built-in default; `off` disables at any level.
|
|
256
|
+
*
|
|
257
|
+
* Only a tmux session can honour a lifecycle: pty and macos-terminal die with
|
|
258
|
+
* their parent and a task is not a session at all. An explicit request there is
|
|
259
|
+
* refused, but a CONFIGURED default is silently skipped — refusing that would
|
|
260
|
+
* break every pty launch the moment someone sets a default.
|
|
261
|
+
*
|
|
262
|
+
* `reapable` comes from the caller, not from `req.host`, because the host that
|
|
263
|
+
* runs is `req.host ?? config.host` with `auto` resolving to whichever driver
|
|
264
|
+
* is available. Deciding from `req.host` here would accept `--idle-timeout`
|
|
265
|
+
* under `host = "pty"` in config and then never arm it.
|
|
266
|
+
*/
|
|
267
|
+
export function resolveLifecycle(req, config, reapable) {
|
|
268
|
+
const one = (flag, configured, label, fallback) => {
|
|
269
|
+
// `off` asks for nothing, so it is accepted even where a lifecycle cannot
|
|
270
|
+
// be honoured: a wrapper that always passes it defensively must still be
|
|
271
|
+
// able to launch a pty session.
|
|
272
|
+
if (flag !== undefined && !reapable && flag.trim().toLowerCase() !== "off")
|
|
273
|
+
throw new Error(`${label} applies to tmux sessions, which outlive the process that ` +
|
|
274
|
+
`launched them; this launch is ${req.kind === "task" ? "a task" : `hosted on ${req.host ?? "the configured host"}`}`);
|
|
275
|
+
const chosen = flag !== undefined
|
|
276
|
+
? parseDuration(flag, label)
|
|
277
|
+
: configured !== undefined
|
|
278
|
+
? parseDuration(configured, label)
|
|
279
|
+
: undefined;
|
|
280
|
+
if (!reapable)
|
|
281
|
+
return null;
|
|
282
|
+
if (chosen === undefined)
|
|
283
|
+
return fallback;
|
|
284
|
+
return chosen === "off" ? null : chosen;
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", DEFAULT_IDLE_TIMEOUT),
|
|
288
|
+
ttl: one(req.ttl, config.session.ttl, "--ttl", null),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
243
291
|
export function runtimeArgs(req) {
|
|
244
292
|
const result = [];
|
|
245
293
|
const { forwarded, consumed } = RUNTIME_OPTIONS[req.runtime];
|
|
@@ -266,8 +314,12 @@ export function runtimeArgs(req) {
|
|
|
266
314
|
return result;
|
|
267
315
|
}
|
|
268
316
|
export function launchArgs(req, config, mcp = [], bridgePath) {
|
|
269
|
-
|
|
317
|
+
// Before `runtimeArgs`, so this function owns every model error on its own
|
|
318
|
+
// rather than by its caller's discipline: `Muster.launch` happens to resolve
|
|
319
|
+
// first, but `launchArgs` is exported and an input carrying both a model
|
|
320
|
+
// conflict and a refused option must report the model conflict.
|
|
270
321
|
const resolved = resolveModel(req, config);
|
|
322
|
+
const extra = runtimeArgs(req);
|
|
271
323
|
// OpenCode never reaches here — `run.ts` branches it to the OpenCode launch
|
|
272
324
|
// builders, which carry the model in their config overlay.
|
|
273
325
|
const model = resolved ? ["--model", resolved.model] : [];
|
package/dist/help.js
CHANGED
|
@@ -59,6 +59,14 @@ export const COMMANDS = [
|
|
|
59
59
|
"--model MODEL",
|
|
60
60
|
"model for the launch: a bare id for claude and codex, PROVIDER/MODEL for opencode",
|
|
61
61
|
],
|
|
62
|
+
[
|
|
63
|
+
"--idle-timeout DURATION",
|
|
64
|
+
"stop a tmux session after this much inactivity; 30m by default, off to disable",
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
"--ttl DURATION",
|
|
68
|
+
"stop a tmux session this long after launch, whatever its state",
|
|
69
|
+
],
|
|
62
70
|
[
|
|
63
71
|
"--request-key KEY",
|
|
64
72
|
"name the request; sending it again returns the same launch",
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -21,6 +21,23 @@ const FIELD = "|";
|
|
|
21
21
|
export const LAUNCH_FORMAT = `#{window_id}${FIELD}#{pane_pid}`;
|
|
22
22
|
export const LIST_FORMAT = `#{window_id}${FIELD}#{pane_pid}${FIELD}#{session_name}${FIELD}#{window_name}`;
|
|
23
23
|
export const CLEANUP_FORMAT = `#{window_id}${FIELD}#{pane_start_command}`;
|
|
24
|
+
/** `window_activity`, never `session_activity`: the latter does not advance for
|
|
25
|
+
* a detached session, and every muster session is detached. */
|
|
26
|
+
export const SESSION_STATE_FORMAT = `#{window_activity}${FIELD}#{session_created}${FIELD}#{session_attached}${FIELD}#{session_last_attached}`;
|
|
27
|
+
export function parseSessionState(row, now) {
|
|
28
|
+
const [activity, created, attached, lastAttached] = row.trim().split(FIELD);
|
|
29
|
+
if (!/^\d+$/.test(activity ?? "") || !/^\d+$/.test(created ?? ""))
|
|
30
|
+
return null;
|
|
31
|
+
// Attaching and detaching does not advance `window_activity`, so a session
|
|
32
|
+
// someone read for ten minutes and left would be reaped on the next poll.
|
|
33
|
+
// tmux reports 0 for never-attached, which must not read as 1970.
|
|
34
|
+
const seen = Math.max(Number(activity), /^\d+$/.test(lastAttached ?? "") ? Number(lastAttached) : 0);
|
|
35
|
+
return {
|
|
36
|
+
idle: now - seen,
|
|
37
|
+
age: now - Number(created),
|
|
38
|
+
attached: Number(attached ?? 0) > 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
24
41
|
export function parseWindowRow(output) {
|
|
25
42
|
const [hostRef, pid] = output.trim().split(FIELD);
|
|
26
43
|
if (!hostRef || !/^@\d+$/.test(hostRef) || !Number(pid))
|
|
@@ -69,6 +86,36 @@ export class TmuxHost {
|
|
|
69
86
|
capabilities() {
|
|
70
87
|
return { watchable: true, attachable: true, survivesParentExit: true };
|
|
71
88
|
}
|
|
89
|
+
/** Null when the window is gone — the caller stops rather than guesses. */
|
|
90
|
+
async sessionState(hostRef) {
|
|
91
|
+
try {
|
|
92
|
+
const row = await this.call([
|
|
93
|
+
"display-message",
|
|
94
|
+
"-p",
|
|
95
|
+
"-t",
|
|
96
|
+
hostRef,
|
|
97
|
+
SESSION_STATE_FORMAT,
|
|
98
|
+
]);
|
|
99
|
+
return parseSessionState(row, Math.floor(Date.now() / 1000));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Run `argv` in the background after `seconds`, scheduled by the tmux server
|
|
107
|
+
* itself. This is what lets a lifecycle outlive every muster process: nothing
|
|
108
|
+
* of ours is running between the call and the job.
|
|
109
|
+
*/
|
|
110
|
+
async schedule(seconds, argv) {
|
|
111
|
+
await this.call([
|
|
112
|
+
"run-shell",
|
|
113
|
+
"-b",
|
|
114
|
+
"-d",
|
|
115
|
+
String(Math.max(1, Math.round(seconds))),
|
|
116
|
+
argv.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(" "),
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
72
119
|
async launch(opts) {
|
|
73
120
|
const dir = await mkdtemp(join(tmpdir(), "muster-host-"));
|
|
74
121
|
const file = join(dir, "launch.json");
|
package/dist/mcp-tools.js
CHANGED
|
@@ -82,6 +82,14 @@ export const MCP_TOOLS = [
|
|
|
82
82
|
minLength: 1,
|
|
83
83
|
description: "Model for the launch. A bare model id for claude and codex; PROVIDER/MODEL for opencode, where it overrides the configured [opencode] model. Recorded on the run and reported by `list`.",
|
|
84
84
|
},
|
|
85
|
+
idleTimeout: {
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "Stop this tmux session after the given inactivity, as 90s/30m/4h, or 'off'. Defaults to 30m. Refused for tasks and for pty or macos-terminal hosts, which do not outlive their parent.",
|
|
88
|
+
},
|
|
89
|
+
ttl: {
|
|
90
|
+
type: "string",
|
|
91
|
+
description: "Stop this tmux session the given duration after launch regardless of activity, as 90s/30m/4h, or 'off'. Off by default.",
|
|
92
|
+
},
|
|
85
93
|
identity: {
|
|
86
94
|
type: "string",
|
|
87
95
|
pattern: "^[a-zA-Z0-9_-]+$",
|
package/dist/muster.js
CHANGED
|
@@ -166,6 +166,11 @@ async function main() {
|
|
|
166
166
|
"no-mcp": { type: "boolean" },
|
|
167
167
|
plugin: { type: "string", multiple: true },
|
|
168
168
|
model: { type: "string" },
|
|
169
|
+
"idle-timeout": { type: "string" },
|
|
170
|
+
ttl: { type: "string" },
|
|
171
|
+
socket: { type: "string" },
|
|
172
|
+
home: { type: "string" },
|
|
173
|
+
"stop-now": { type: "boolean" },
|
|
169
174
|
"no-plugin": { type: "boolean" },
|
|
170
175
|
"request-key": { type: "string" },
|
|
171
176
|
project: { type: "string" },
|
|
@@ -175,7 +180,23 @@ async function main() {
|
|
|
175
180
|
interactive: { type: "boolean" },
|
|
176
181
|
},
|
|
177
182
|
});
|
|
178
|
-
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
|
|
183
|
+
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, ...values } = parsed;
|
|
184
|
+
// Guarded like --request-key and --no-mcp below: a flag that applies to one
|
|
185
|
+
// command and is silently ignored elsewhere misleads, and --home silently
|
|
186
|
+
// reading the real registry is the one that would mislead most.
|
|
187
|
+
for (const [name, value] of [
|
|
188
|
+
["--idle-timeout", idleTimeout],
|
|
189
|
+
["--ttl", values.ttl],
|
|
190
|
+
])
|
|
191
|
+
if (value !== undefined && command !== "run")
|
|
192
|
+
throw new Error(`${name} applies only to run`);
|
|
193
|
+
for (const [name, value] of [
|
|
194
|
+
["--socket", socket],
|
|
195
|
+
["--home", homeFlag],
|
|
196
|
+
["--stop-now", stopNow],
|
|
197
|
+
])
|
|
198
|
+
if (value !== undefined && command !== "reap-check")
|
|
199
|
+
throw new Error(`${name} applies only to reap-check`);
|
|
179
200
|
if (requestKey !== undefined && command !== "run")
|
|
180
201
|
throw new Error("--request-key applies only to run");
|
|
181
202
|
if (noMcp !== undefined && command !== "run")
|
|
@@ -206,6 +227,7 @@ async function main() {
|
|
|
206
227
|
runtime,
|
|
207
228
|
...values,
|
|
208
229
|
...(requestKey === undefined ? {} : { requestKey }),
|
|
230
|
+
...(idleTimeout === undefined ? {} : { idleTimeout }),
|
|
209
231
|
args,
|
|
210
232
|
});
|
|
211
233
|
print(record);
|
|
@@ -305,6 +327,24 @@ async function main() {
|
|
|
305
327
|
else
|
|
306
328
|
process.stdout.write(await muster.output(id));
|
|
307
329
|
}
|
|
330
|
+
else if (command === "reap-check") {
|
|
331
|
+
// Hidden: armed by muster itself as a tmux job, never typed by a person.
|
|
332
|
+
// NOTE: `Muster.create()` above already ran against the REAL home before
|
|
333
|
+
// this dispatch. Harmless (nothing is owned, so `close()` stops nothing)
|
|
334
|
+
// but it means a broken ~/.muster/config.toml would fail every poll.
|
|
335
|
+
// Left as-is: moving that create changes every command's startup.
|
|
336
|
+
if (positionals.length !== 1 || !socket)
|
|
337
|
+
throw new Error("reap-check requires --socket and one launch id");
|
|
338
|
+
const { reapCheck } = await import("./reap.js");
|
|
339
|
+
await reapCheck({
|
|
340
|
+
home: homeFlag ?? musterHome(),
|
|
341
|
+
socket,
|
|
342
|
+
...(stopNow ? { stopNow: true } : {}),
|
|
343
|
+
launchId: positionals[0],
|
|
344
|
+
// The exact command that was armed, so a re-arm reproduces itself.
|
|
345
|
+
argv: [...process.argv],
|
|
346
|
+
});
|
|
347
|
+
}
|
|
308
348
|
else
|
|
309
349
|
throw new Error(topLevelHelp());
|
|
310
350
|
}
|
package/dist/reap.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Registry } from "./registry.js";
|
|
2
|
+
const MIN_POLL = 30, MAX_POLL = 300;
|
|
3
|
+
/**
|
|
4
|
+
* Derived from the SHORTER active expiry: a 4h idle timeout paired with a 2m
|
|
5
|
+
* ttl must not poll on the 4h scale, or the ttl overshoots by minutes.
|
|
6
|
+
*/
|
|
7
|
+
export function pollInterval(lifecycle) {
|
|
8
|
+
const limits = [lifecycle.idleTimeout, lifecycle.ttl].filter((v) => typeof v === "number");
|
|
9
|
+
if (!limits.length)
|
|
10
|
+
return MAX_POLL;
|
|
11
|
+
return Math.min(MAX_POLL, Math.max(MIN_POLL, Math.floor(Math.min(...limits) / 6)));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `stop` means stop polling and leave the session alone; `reap` means stop the
|
|
15
|
+
* session. An entry with no recorded lifecycle — one written before the field
|
|
16
|
+
* existed — decides nothing, because absent must never read as zero.
|
|
17
|
+
*/
|
|
18
|
+
export function decide(entry, state) {
|
|
19
|
+
// Only TERMINAL statuses stop the chain. `starting` is what every healthy
|
|
20
|
+
// launch is when the job is first armed, and `unknown` is a diagnosis in
|
|
21
|
+
// progress, not a death — treating either as terminal loses the reaper.
|
|
22
|
+
if (["stopped", "exited", "failed"].includes(entry.status))
|
|
23
|
+
return { action: "stop" };
|
|
24
|
+
const idleTimeout = entry.idleTimeout ?? null, ttl = entry.ttl ?? null;
|
|
25
|
+
if (idleTimeout === null && ttl === null)
|
|
26
|
+
return { action: "stop" };
|
|
27
|
+
// The window is gone: something else already ended it, and re-arming would
|
|
28
|
+
// leave a job polling a session that does not exist.
|
|
29
|
+
if (!state)
|
|
30
|
+
return { action: "stop" };
|
|
31
|
+
const seconds = pollInterval({ idleTimeout, ttl });
|
|
32
|
+
// Checked before either expiry: killing a session someone is looking at is
|
|
33
|
+
// the worst thing this feature could do.
|
|
34
|
+
if (state.attached)
|
|
35
|
+
return { action: "rearm", seconds };
|
|
36
|
+
if (idleTimeout !== null && state.idle >= idleTimeout)
|
|
37
|
+
return { action: "reap" };
|
|
38
|
+
if (ttl !== null && state.age >= ttl)
|
|
39
|
+
return { action: "reap" };
|
|
40
|
+
return { action: "rearm", seconds };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One poll, run as a detached tmux job.
|
|
44
|
+
*
|
|
45
|
+
* The stop is handed to a SEPARATE detached process rather than performed
|
|
46
|
+
* here, and that is the whole point: this job is a child of the tmux server,
|
|
47
|
+
* and stopping the last session on that server makes the server exit and take
|
|
48
|
+
* this process with it — mid-stop. What is lost is everything after the
|
|
49
|
+
* process-tree teardown: the `status: "stopped"` write, the launch-log event,
|
|
50
|
+
* and the removal of the per-launch identity copy, which holds a copied
|
|
51
|
+
* credential. The row is then swept to `exited` by the registry's own liveness
|
|
52
|
+
* check, so it even looks like a crash rather than a reap.
|
|
53
|
+
*/
|
|
54
|
+
export async function reapCheck(opts) {
|
|
55
|
+
const rearm = async (seconds) => {
|
|
56
|
+
const schedule = opts.schedule ??
|
|
57
|
+
(async (s, argv) => {
|
|
58
|
+
const { TmuxHost } = await import("./hosts/tmux.js");
|
|
59
|
+
await new TmuxHost(opts.socket).schedule(s, argv);
|
|
60
|
+
});
|
|
61
|
+
await schedule(seconds, opts.argv);
|
|
62
|
+
return { action: "rearm", seconds };
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
if (opts.stopNow) {
|
|
66
|
+
await stopSession(opts.home, opts.socket, opts.launchId);
|
|
67
|
+
return { action: "reap" };
|
|
68
|
+
}
|
|
69
|
+
const entries = await (opts.readEntries
|
|
70
|
+
? opts.readEntries()
|
|
71
|
+
: new Registry(opts.home).all());
|
|
72
|
+
const entry = entries.find((e) => e.launchId === opts.launchId);
|
|
73
|
+
if (!entry || !entry.hostRef)
|
|
74
|
+
return { action: "stop" };
|
|
75
|
+
const state = opts.state !== undefined
|
|
76
|
+
? opts.state
|
|
77
|
+
: await (async () => {
|
|
78
|
+
const { TmuxHost } = await import("./hosts/tmux.js");
|
|
79
|
+
return new TmuxHost(opts.socket).sessionState(entry.hostRef);
|
|
80
|
+
})();
|
|
81
|
+
const decision = decide(entry, state);
|
|
82
|
+
if (decision.action === "rearm")
|
|
83
|
+
return await rearm(decision.seconds);
|
|
84
|
+
if (decision.action === "reap") {
|
|
85
|
+
const [exec, script] = opts.argv;
|
|
86
|
+
if (!exec || !script)
|
|
87
|
+
return { action: "stop" };
|
|
88
|
+
const spawnFn = opts.spawn ??
|
|
89
|
+
((e, args) => {
|
|
90
|
+
// Detached and unref'd so it survives the tmux server exiting as the
|
|
91
|
+
// session it is stopping goes away.
|
|
92
|
+
void import("node:child_process").then(({ spawn }) => spawn(e, args, { detached: true, stdio: "ignore" }).unref());
|
|
93
|
+
});
|
|
94
|
+
spawnFn(exec, [...opts.argv.slice(1), "--stop-now"]);
|
|
95
|
+
}
|
|
96
|
+
return decision;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// A transient failure — a registry lock collision, a momentarily
|
|
100
|
+
// unreadable config — must not permanently disarm the session. Only a
|
|
101
|
+
// decision that means stop, stops. If even re-arming fails, there is
|
|
102
|
+
// nothing left to try.
|
|
103
|
+
try {
|
|
104
|
+
return await rearm(MAX_POLL);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { action: "stop" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function stopSession(home, socket, launchId) {
|
|
112
|
+
const { TmuxHost } = await import("./hosts/tmux.js");
|
|
113
|
+
const { Muster } = await import("./run.js");
|
|
114
|
+
// The socket is passed explicitly: Muster.create() would otherwise build its
|
|
115
|
+
// drivers from MUSTER_TMUX_SERVER, and a mismatch makes stopEntry find no
|
|
116
|
+
// matching hostRef and fall through to killing the process tree blind. The
|
|
117
|
+
// whole reason --socket exists is that the registry records a window id, not
|
|
118
|
+
// the socket it lives on.
|
|
119
|
+
const muster = await Muster.create({
|
|
120
|
+
home,
|
|
121
|
+
drivers: [new TmuxHost(socket)],
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const entry = (await new Registry(home).all()).find((e) => e.launchId === launchId);
|
|
125
|
+
if (entry)
|
|
126
|
+
await muster.stop(entry.id);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await muster.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
|
-
import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
7
|
+
import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
8
8
|
import { enforcementGrade } from "./enforcement.js";
|
|
9
9
|
import { LOCAL } from "./requester.js";
|
|
10
10
|
import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
|
|
@@ -28,6 +28,7 @@ import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
|
|
|
28
28
|
import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
|
|
29
29
|
import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
|
|
30
30
|
import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
|
|
31
|
+
import { pollInterval } from "./reap.js";
|
|
31
32
|
import { selectPlugins } from "./plugins.js";
|
|
32
33
|
function taskHandle(entry, pid) {
|
|
33
34
|
return {
|
|
@@ -251,6 +252,12 @@ export class Muster {
|
|
|
251
252
|
await assertClaudePolicy(this.hostEnv);
|
|
252
253
|
if (req.runtime === "opencode")
|
|
253
254
|
await assertOpenCodeVersion(this.hostEnv, "1.18.31", Date.now() + 4000);
|
|
255
|
+
// After the driver, not beside resolveModel: the host that runs is
|
|
256
|
+
// `req.host ?? config.host` with `auto` resolving to whatever is available,
|
|
257
|
+
// so only the selected driver knows whether a lifecycle can be honoured.
|
|
258
|
+
// Deciding from `req.host` accepts --idle-timeout under `host = "pty"` in
|
|
259
|
+
// config and then never arms it.
|
|
260
|
+
const lifecycle = resolveLifecycle(effective, this.config, req.kind === "session" && typeof driver?.schedule === "function");
|
|
254
261
|
const { entry: reservation, deduped } = await this.registry.reserve({
|
|
255
262
|
runtime: req.runtime,
|
|
256
263
|
kind: req.kind,
|
|
@@ -259,6 +266,8 @@ export class Muster {
|
|
|
259
266
|
enforcement,
|
|
260
267
|
model: model?.model ?? null,
|
|
261
268
|
modelSource: model?.source ?? null,
|
|
269
|
+
idleTimeout: lifecycle.idleTimeout,
|
|
270
|
+
ttl: lifecycle.ttl,
|
|
262
271
|
// Recorded here rather than only at the patch below, because `list`
|
|
263
272
|
// now publishes it: an entry that dies between this line and that one
|
|
264
273
|
// would otherwise report no identity, which reads as "ran on the
|
|
@@ -438,6 +447,28 @@ export class Muster {
|
|
|
438
447
|
host: driver.id,
|
|
439
448
|
hostRef: launched.hostRef,
|
|
440
449
|
});
|
|
450
|
+
// Armed only once hostRef exists: the job addresses the window, and a job
|
|
451
|
+
// armed before the window is recorded would poll a target the registry
|
|
452
|
+
// cannot name. `schedule` is optional on the host interface, so pty and
|
|
453
|
+
// macos-terminal opt out by not implementing it.
|
|
454
|
+
if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl))
|
|
455
|
+
await driver
|
|
456
|
+
.schedule(pollInterval(lifecycle), [
|
|
457
|
+
process.execPath,
|
|
458
|
+
// ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
|
|
459
|
+
// under vitest as well as from dist/run.js in the package, landing
|
|
460
|
+
// on <root>/dist either way. Same idiom as the tmux bootstrap.
|
|
461
|
+
fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
|
|
462
|
+
"reap-check",
|
|
463
|
+
"--home",
|
|
464
|
+
this.home,
|
|
465
|
+
"--socket",
|
|
466
|
+
driver.server ?? "muster",
|
|
467
|
+
entry.launchId,
|
|
468
|
+
])
|
|
469
|
+
// A lifecycle that cannot be armed must not fail the launch: the
|
|
470
|
+
// session is up and usable, and the orphan risk is what we had before.
|
|
471
|
+
.catch(() => { });
|
|
441
472
|
let diagnostic = req.runtime === "claude"
|
|
442
473
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
443
474
|
: req.runtime === "opencode"
|
|
@@ -540,6 +571,13 @@ export class Muster {
|
|
|
540
571
|
// `list` projects: a launch result and a listing must not
|
|
541
572
|
// disagree about which account this ran as.
|
|
542
573
|
...(identityName ? { identity: identityName } : {}),
|
|
574
|
+
// Same reason, for the same pair of records.
|
|
575
|
+
model: model?.model ?? null,
|
|
576
|
+
model_source: model?.source ?? null,
|
|
577
|
+
...(lifecycle.idleTimeout
|
|
578
|
+
? { idle_timeout: lifecycle.idleTimeout }
|
|
579
|
+
: {}),
|
|
580
|
+
...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
|
|
543
581
|
attach_hint: driver.attachHint(launched.hostRef),
|
|
544
582
|
};
|
|
545
583
|
if (req.open) {
|
|
@@ -887,6 +925,8 @@ export class Muster {
|
|
|
887
925
|
...(e.model !== undefined
|
|
888
926
|
? { model: e.model, model_source: e.modelSource ?? null }
|
|
889
927
|
: {}),
|
|
928
|
+
...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
|
|
929
|
+
...(e.ttl ? { ttl: e.ttl } : {}),
|
|
890
930
|
id: e.id,
|
|
891
931
|
runtime: e.peer?.runtime ?? e.runtime,
|
|
892
932
|
state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
|