@brutalsystems/muster 3.2.1 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.js CHANGED
@@ -10,6 +10,13 @@ import { setCodexWorkspaceTrust, setWorkspaceTrust } from "./identity-copy.js";
10
10
  * neither list and is refused rather than quietly dropped.
11
11
  */
12
12
  export const MODEL_FLAGS = ["--model", "-m"];
13
+ /**
14
+ * Claude's resume flags, consumed for the same reason as the model's: Muster
15
+ * validates them and delivers them once (`resolveResume`). `--fork-session` is
16
+ * the one consumed flag that takes no value.
17
+ */
18
+ export const RESUME_FLAGS = ["--resume", "-r"];
19
+ export const FORK_FLAG = "--fork-session";
13
20
  /**
14
21
  * One row per runtime. `Record<Runtime, …>` is the point: a fourth agent is a
15
22
  * compile error until every column is filled, where the scattered
@@ -21,7 +28,7 @@ export const AGENTS = {
21
28
  enforcement: "kernel",
22
29
  runtimeOptions: {
23
30
  forwarded: ["--effort", "--name"],
24
- consumed: MODEL_FLAGS,
31
+ consumed: [...MODEL_FLAGS, ...RESUME_FLAGS, FORK_FLAG],
25
32
  },
26
33
  trustWorkspace: setWorkspaceTrust,
27
34
  beforeStop: null,
@@ -22,6 +22,8 @@ export const CLI_OPTIONS = {
22
22
  "no-mcp": { type: "boolean" },
23
23
  plugin: { type: "string", multiple: true },
24
24
  model: { type: "string" },
25
+ resume: { type: "string" },
26
+ "fork-session": { type: "boolean" },
25
27
  "idle-timeout": { type: "string" },
26
28
  ttl: { type: "string" },
27
29
  title: { type: "string" },
package/dist/guard.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { createHash } from "node:crypto";
3
- import { readFileSync, realpathSync } from "node:fs";
3
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, } from "node:fs";
4
4
  import { realpath } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { isAbsolute, join, relative, resolve } from "node:path";
7
7
  import TOML from "@iarna/toml";
8
- import { claudeConfigDir } from "./identity/claude.js";
8
+ import { claudeConfigDir, claudeSessionsDir } from "./identity/claude.js";
9
9
  import { builtinTerminals } from "./hosts/types.js";
10
10
  import { mcpName } from "./mcp.js";
11
11
  import { claudeMcpConfig } from "./claude-policy.js";
12
12
  import { pluginName } from "./plugins.js";
13
- import { AGENTS, MODEL_FLAGS } from "./agents.js";
13
+ import { AGENTS, FORK_FLAG, MODEL_FLAGS, RESUME_FLAGS } from "./agents.js";
14
14
  import { LAUNCH_OPTIONS } from "./options.js";
15
15
  import { RequesterConfigError } from "./requester-policy.js";
16
16
  import { parseDuration } from "./duration.js";
@@ -38,6 +38,10 @@ export const runSchema = z
38
38
  mcp: z.array(mcpName).optional(),
39
39
  plugin: z.array(pluginName).optional(),
40
40
  model: z.string().min(1).optional(),
41
+ /** A Claude session id to reopen instead of starting a new one. See `resolveResume`. */
42
+ resume: z.string().min(1).optional(),
43
+ /** With `resume`: continue it under a new session id, leaving the original untouched. */
44
+ forkSession: z.boolean().optional(),
41
45
  idleTimeout: z.string().min(1).optional(),
42
46
  ttl: z.string().min(1).optional(),
43
47
  /** The terminal title of a tmux session. See `resolveTitle`. */
@@ -364,6 +368,151 @@ function nameModel(req, config) {
364
368
  ? null
365
369
  : { model: configured, source: "config", origin: "config" };
366
370
  }
371
+ /** What Claude prints as a session id, and the only thing `--resume` is given:
372
+ * a bare `--resume` or a search term opens Claude's picker, which no muster
373
+ * launch can answer. */
374
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
375
+ /**
376
+ * The session a launch reopens, from `--resume`/`--fork-session` or the same
377
+ * flags after `--` — consumed like a model, so the child's argv carries them
378
+ * once, from here. Claude only: Codex and OpenCode resume through different
379
+ * commands, which muster does not drive.
380
+ */
381
+ export function resolveResume(req) {
382
+ const named = [];
383
+ let fork = req.forkSession === true;
384
+ for (let i = 0; i < req.args.length; i++) {
385
+ const arg = req.args[i], flag = normaliseFlag(arg);
386
+ if (flag === FORK_FLAG && !arg.includes("="))
387
+ fork = true;
388
+ if (!RESUME_FLAGS.includes(flag))
389
+ continue;
390
+ // Case-sensitive like the rest of the pass-through: `-R` is not `-r`.
391
+ if (!arg.startsWith("--") && arg.split("=")[0] !== flag)
392
+ continue;
393
+ const value = arg.includes("=")
394
+ ? arg.slice(arg.indexOf("=") + 1)
395
+ : req.args[i + 1];
396
+ if (!value || value.startsWith("-"))
397
+ throw new Error(`Missing value for ${arg}`);
398
+ named.push(value);
399
+ }
400
+ if (req.resume !== undefined)
401
+ named.unshift(req.resume);
402
+ if (named.length > 1)
403
+ throw new Error(`Session to resume named twice: ${named.join(" and ")}. Name it once.`);
404
+ const id = named[0];
405
+ if (id === undefined) {
406
+ if (fork)
407
+ throw new Error("--fork-session applies only with --resume");
408
+ return null;
409
+ }
410
+ if (req.runtime !== "claude")
411
+ throw new Error(`--resume is supported for claude only, not ${req.runtime}`);
412
+ if (!SESSION_ID.test(id))
413
+ throw new Error(`--resume takes a Claude session id (a UUID), not ${JSON.stringify(id)}`);
414
+ return { id: id.toLowerCase(), fork };
415
+ }
416
+ /**
417
+ * Refuses a resume Claude would fail on, or one that would put two processes
418
+ * on one conversation. Checked before anything is reserved or spawned.
419
+ *
420
+ * - The transcript must exist in the config dir the launch opens. Claude
421
+ * reports a missing one only inside its terminal, where a muster launch
422
+ * would wait out its whole identity deadline before saying so.
423
+ * - The session must not be running. Two Claude processes appending to one
424
+ * transcript interleave it, and both would register the same session id, so
425
+ * tincan and the ledger could not say which one a message reaches. A fork
426
+ * only reads the original, so it may resume a live one.
427
+ */
428
+ export function assertResumable(resume, env, cwd, alive = pidAlive) {
429
+ const configDir = claudeConfigDir(env);
430
+ const projects = join(configDir, "projects");
431
+ let transcript;
432
+ try {
433
+ transcript = readdirSync(projects)
434
+ .map((dir) => join(projects, dir, resume.id + ".jsonl"))
435
+ .find((file) => existsSync(file));
436
+ }
437
+ catch { }
438
+ if (!transcript)
439
+ throw new Error(`No Claude conversation ${resume.id} in ${configDir}; --resume reopens a session recorded in the profile this launch runs in`);
440
+ // Claude 2.1.302 resumes a conversation from any directory, so this is
441
+ // muster's to refuse (#10): the history would describe one tree while the
442
+ // agent works in another. Unknown when the transcript names no cwd.
443
+ const recorded = transcriptCwd(transcript);
444
+ if (recorded !== undefined && realPath(recorded) !== realPath(cwd))
445
+ throw new Error(`Claude session ${resume.id} was recorded in ${recorded}, not ${cwd}; resume it with --cwd ${recorded}`);
446
+ if (resume.fork)
447
+ return;
448
+ const sessions = claudeSessionsDir(env);
449
+ let files = [];
450
+ try {
451
+ files = readdirSync(sessions).filter((f) => /^\d+\.json$/.test(f));
452
+ }
453
+ catch { }
454
+ for (const file of files) {
455
+ let rec;
456
+ try {
457
+ rec = JSON.parse(readFileSync(join(sessions, file), "utf8"));
458
+ }
459
+ catch {
460
+ continue;
461
+ }
462
+ const pid = Number(file.slice(0, -5));
463
+ if (typeof rec.sessionId === "string" &&
464
+ rec.sessionId.toLowerCase() === resume.id &&
465
+ alive(pid))
466
+ throw new Error(`Claude session ${resume.id} is running (pid ${pid}); stop it before resuming it, or pass --fork-session to continue it under a new id`);
467
+ }
468
+ }
469
+ /** The first `cwd` a transcript records, read from its head: a transcript can
470
+ * run to many megabytes, and the cwd is on its first message. */
471
+ function transcriptCwd(file) {
472
+ let head = "";
473
+ try {
474
+ const fd = openSync(file, "r");
475
+ try {
476
+ const buf = Buffer.alloc(1 << 20);
477
+ head = buf.toString("utf8", 0, readSync(fd, buf, 0, buf.length, 0));
478
+ }
479
+ finally {
480
+ closeSync(fd);
481
+ }
482
+ }
483
+ catch {
484
+ return undefined;
485
+ }
486
+ for (const line of head.split("\n")) {
487
+ if (!line.includes('"cwd"'))
488
+ continue;
489
+ try {
490
+ const { cwd } = JSON.parse(line);
491
+ if (typeof cwd === "string" && cwd)
492
+ return cwd;
493
+ }
494
+ catch { }
495
+ }
496
+ return undefined;
497
+ }
498
+ function realPath(path) {
499
+ try {
500
+ return realpathSync(path);
501
+ }
502
+ catch {
503
+ return resolve(path);
504
+ }
505
+ }
506
+ function pidAlive(pid) {
507
+ try {
508
+ process.kill(pid, 0);
509
+ return true;
510
+ }
511
+ catch (e) {
512
+ // EPERM: it exists and belongs to someone else, which is still running.
513
+ return e.code === "EPERM";
514
+ }
515
+ }
367
516
  /**
368
517
  * Flag, then config; `off` disables at any level. Defaults to off.
369
518
  *
@@ -457,6 +606,12 @@ export function runtimeArgs(req) {
457
606
  const keep = !caseChanged && forwarded.includes(flag);
458
607
  if (caseChanged || (!keep && !consumed.includes(flag)))
459
608
  throw new Error(`Runtime option refused: ${arg}`);
609
+ // The one consumed switch: it takes no value, so there is none to step over.
610
+ if (flag === FORK_FLAG) {
611
+ if (arg.includes("="))
612
+ throw new Error(`${FORK_FLAG} takes no value`);
613
+ continue;
614
+ }
460
615
  if (arg.includes("=")) {
461
616
  if (keep) {
462
617
  refuseWidening(flag, arg.slice(arg.indexOf("=") + 1));
@@ -582,7 +737,13 @@ musterHome = defaultMusterHome(env)) {
582
737
  // first, but `launchArgs` is exported and an input carrying both a model
583
738
  // conflict and a refused option must report the model conflict.
584
739
  const resolved = resolveModel(req, config);
585
- const extra = runtimeArgs(req);
740
+ const resume = resolveResume(req);
741
+ const extra = [
742
+ ...runtimeArgs(req),
743
+ ...(resume
744
+ ? ["--resume", resume.id, ...(resume.fork ? [FORK_FLAG] : [])]
745
+ : []),
746
+ ];
586
747
  // OpenCode never reaches here — `run.ts` branches it to the OpenCode launch
587
748
  // builders, which carry the model in their config overlay.
588
749
  const model = resolved ? ["--model", resolved.model] : [];
package/dist/help.js CHANGED
@@ -81,6 +81,14 @@ export const COMMANDS = [
81
81
  "--model MODEL",
82
82
  "model for the launch: a bare id for claude and codex, PROVIDER/MODEL for opencode",
83
83
  ],
84
+ [
85
+ "--resume SESSION_ID",
86
+ "claude only: reopen that session instead of starting a new one; the prompt is its next message (also accepted after --)",
87
+ ],
88
+ [
89
+ "--fork-session",
90
+ "with --resume: continue it under a new session id, leaving the original untouched",
91
+ ],
84
92
  [
85
93
  "--idle-timeout DURATION",
86
94
  "stop a tmux session after this much inactivity (off by default)",
package/dist/mcp-tools.js CHANGED
@@ -89,6 +89,14 @@ export const MCP_TOOLS = [
89
89
  minLength: 1,
90
90
  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`.",
91
91
  },
92
+ resume: {
93
+ type: "string",
94
+ description: "Claude only: reopen this existing session (its UUID) instead of starting a new one; the prompt is its next message, and the run reports the same session id. Refused while that session is running, for a remote requester, and with an identity.",
95
+ },
96
+ forkSession: {
97
+ type: "boolean",
98
+ description: "With resume: continue the conversation under a new session id, leaving the original as it was. Allowed while the original is running.",
99
+ },
92
100
  idleTimeout: {
93
101
  type: "string",
94
102
  description: "Stop this tmux session after the given inactivity, as 90s/30m/4h, or 'off'. Off by default. Refused for tasks and for pty or macos-terminal hosts, which do not outlive their parent.",
package/dist/muster.js CHANGED
@@ -206,7 +206,7 @@ async function main() {
206
206
  strict: true,
207
207
  options: CLI_OPTIONS,
208
208
  });
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;
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, "fork-session": forkSession, timeout, ...values } = parsed;
210
210
  if (onExit !== undefined && command !== "run")
211
211
  throw new Error("--on-exit applies only to run");
212
212
  if (timeout !== undefined && command !== "wait")
@@ -234,6 +234,8 @@ async function main() {
234
234
  ["--idle-timeout", idleTimeout],
235
235
  ["--ttl", values.ttl],
236
236
  ["--title", values.title],
237
+ ["--resume", values.resume],
238
+ ["--fork-session", forkSession],
237
239
  ])
238
240
  if (value !== undefined && command !== "run")
239
241
  throw new Error(`${name} applies only to run`);
@@ -281,6 +283,7 @@ async function main() {
281
283
  ...values,
282
284
  ...(requestKey === undefined ? {} : { requestKey }),
283
285
  ...(idleTimeout === undefined ? {} : { idleTimeout }),
286
+ ...(forkSession === undefined ? {} : { forkSession }),
284
287
  args,
285
288
  });
286
289
  }
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, resolveLifecycle, resolveTitle, sanitiseTitle, resolveModel, assertMusterHomeProtectable, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, resolveTerminal, runtimeArgs, } from "./guard.js";
7
+ import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveTitle, sanitiseTitle, resolveModel, assertMusterHomeProtectable, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, resolveTerminal, runtimeArgs, resolveResume, assertResumable, } 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";
@@ -802,6 +802,15 @@ export class Muster {
802
802
  ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline, req.prompt)
803
803
  : await resolveClaude(root.pid, resolveEnv, deadline);
804
804
  if (identity) {
805
+ // The answer #10 asks for: a resume that registered some other
806
+ // session is a launch that failed, never a new session reported as
807
+ // the old one. Not fatal at once, like every other diagnosis here,
808
+ // in case the registry is read mid-write.
809
+ const resume = req.runtime === "claude" ? resolveResume(req) : null;
810
+ if (resume && (identity.id === resume.id) === resume.fork)
811
+ throw new Error(resume.fork
812
+ ? `--fork-session registered the original session ${resume.id} instead of a new one`
813
+ : `Claude registered session ${identity.id} instead of resuming ${resume.id}`);
805
814
  diagnostic =
806
815
  req.runtime === "codex"
807
816
  ? "thread id reserved but no rollout"
@@ -911,6 +920,20 @@ export class Muster {
911
920
  const { req, policy, level } = await this.authorize(input, requester);
912
921
  const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
913
922
  const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, title, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
923
+ // Before anything is reserved, like the refusals in `plan`. A remote
924
+ // requester is refused because a resumed session carries the whole of
925
+ // that conversation, and the profile holds every conversation its owner
926
+ // has had: being allowed to launch is not being allowed to read those.
927
+ // An identity launch runs in a fresh copy of the config dir, which holds
928
+ // none of them, so it could only fail.
929
+ const resume = resolveResume(effective);
930
+ if (resume) {
931
+ if (requester.kind !== "local")
932
+ throw new Error("--resume is available to a local requester only");
933
+ if (identityName)
934
+ throw new Error("--resume cannot be combined with an identity: the identity's config dir holds none of this profile's conversations");
935
+ assertResumable(resume, agentBase, effective.cwd);
936
+ }
914
937
  let argv = plannedArgv;
915
938
  let codexPolicy = [];
916
939
  const reserved = await this.reserveLaunch({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.2.1",
3
+ "version": "3.3.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {