@brutalsystems/muster 0.7.21 → 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 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)
@@ -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
@@ -54,6 +54,17 @@ function recordHuman(record, color) {
54
54
  : record.model_source === "config"
55
55
  ? " (configured default)"
56
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)));
57
68
  if (record.mcp)
58
69
  lines.push(field("MCP", record.mcp.length ? record.mcp.map(clean).join(", ") : "none"));
59
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];
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",
@@ -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"
@@ -543,6 +574,10 @@ export class Muster {
543
574
  // Same reason, for the same pair of records.
544
575
  model: model?.model ?? null,
545
576
  model_source: model?.source ?? null,
577
+ ...(lifecycle.idleTimeout
578
+ ? { idle_timeout: lifecycle.idleTimeout }
579
+ : {}),
580
+ ...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
546
581
  attach_hint: driver.attachHint(launched.hostRef),
547
582
  };
548
583
  if (req.open) {
@@ -890,6 +925,8 @@ export class Muster {
890
925
  ...(e.model !== undefined
891
926
  ? { model: e.model, model_source: e.modelSource ?? null }
892
927
  : {}),
928
+ ...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
929
+ ...(e.ttl ? { ttl: e.ttl } : {}),
893
930
  id: e.id,
894
931
  runtime: e.peer?.runtime ?? e.runtime,
895
932
  state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.21",
3
+ "version": "0.8.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {