@brutalsystems/muster 3.2.1 → 3.3.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/dist/agents.js +8 -1
- package/dist/cli-options.js +2 -0
- package/dist/guard.js +120 -4
- package/dist/help.js +8 -0
- package/dist/mcp-tools.js +8 -0
- package/dist/muster.js +4 -1
- package/dist/run.js +15 -1
- package/package.json +1 -1
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,
|
package/dist/cli-options.js
CHANGED
|
@@ -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 { existsSync, readdirSync, readFileSync, 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,106 @@ 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, alive = pidAlive) {
|
|
429
|
+
const configDir = claudeConfigDir(env);
|
|
430
|
+
const projects = join(configDir, "projects");
|
|
431
|
+
let found = false;
|
|
432
|
+
try {
|
|
433
|
+
found = readdirSync(projects).some((dir) => existsSync(join(projects, dir, resume.id + ".jsonl")));
|
|
434
|
+
}
|
|
435
|
+
catch { }
|
|
436
|
+
if (!found)
|
|
437
|
+
throw new Error(`No Claude conversation ${resume.id} in ${configDir}; --resume reopens a session recorded in the profile this launch runs in`);
|
|
438
|
+
if (resume.fork)
|
|
439
|
+
return;
|
|
440
|
+
const sessions = claudeSessionsDir(env);
|
|
441
|
+
let files = [];
|
|
442
|
+
try {
|
|
443
|
+
files = readdirSync(sessions).filter((f) => /^\d+\.json$/.test(f));
|
|
444
|
+
}
|
|
445
|
+
catch { }
|
|
446
|
+
for (const file of files) {
|
|
447
|
+
let rec;
|
|
448
|
+
try {
|
|
449
|
+
rec = JSON.parse(readFileSync(join(sessions, file), "utf8"));
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const pid = Number(file.slice(0, -5));
|
|
455
|
+
if (typeof rec.sessionId === "string" &&
|
|
456
|
+
rec.sessionId.toLowerCase() === resume.id &&
|
|
457
|
+
alive(pid))
|
|
458
|
+
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`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function pidAlive(pid) {
|
|
462
|
+
try {
|
|
463
|
+
process.kill(pid, 0);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
// EPERM: it exists and belongs to someone else, which is still running.
|
|
468
|
+
return e.code === "EPERM";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
367
471
|
/**
|
|
368
472
|
* Flag, then config; `off` disables at any level. Defaults to off.
|
|
369
473
|
*
|
|
@@ -457,6 +561,12 @@ export function runtimeArgs(req) {
|
|
|
457
561
|
const keep = !caseChanged && forwarded.includes(flag);
|
|
458
562
|
if (caseChanged || (!keep && !consumed.includes(flag)))
|
|
459
563
|
throw new Error(`Runtime option refused: ${arg}`);
|
|
564
|
+
// The one consumed switch: it takes no value, so there is none to step over.
|
|
565
|
+
if (flag === FORK_FLAG) {
|
|
566
|
+
if (arg.includes("="))
|
|
567
|
+
throw new Error(`${FORK_FLAG} takes no value`);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
460
570
|
if (arg.includes("=")) {
|
|
461
571
|
if (keep) {
|
|
462
572
|
refuseWidening(flag, arg.slice(arg.indexOf("=") + 1));
|
|
@@ -582,7 +692,13 @@ musterHome = defaultMusterHome(env)) {
|
|
|
582
692
|
// first, but `launchArgs` is exported and an input carrying both a model
|
|
583
693
|
// conflict and a refused option must report the model conflict.
|
|
584
694
|
const resolved = resolveModel(req, config);
|
|
585
|
-
const
|
|
695
|
+
const resume = resolveResume(req);
|
|
696
|
+
const extra = [
|
|
697
|
+
...runtimeArgs(req),
|
|
698
|
+
...(resume
|
|
699
|
+
? ["--resume", resume.id, ...(resume.fork ? [FORK_FLAG] : [])]
|
|
700
|
+
: []),
|
|
701
|
+
];
|
|
586
702
|
// OpenCode never reaches here — `run.ts` branches it to the OpenCode launch
|
|
587
703
|
// builders, which carry the model in their config overlay.
|
|
588
704
|
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";
|
|
@@ -911,6 +911,20 @@ export class Muster {
|
|
|
911
911
|
const { req, policy, level } = await this.authorize(input, requester);
|
|
912
912
|
const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
|
|
913
913
|
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, title, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
914
|
+
// Before anything is reserved, like the refusals in `plan`. A remote
|
|
915
|
+
// requester is refused because a resumed session carries the whole of
|
|
916
|
+
// that conversation, and the profile holds every conversation its owner
|
|
917
|
+
// has had: being allowed to launch is not being allowed to read those.
|
|
918
|
+
// An identity launch runs in a fresh copy of the config dir, which holds
|
|
919
|
+
// none of them, so it could only fail.
|
|
920
|
+
const resume = resolveResume(effective);
|
|
921
|
+
if (resume) {
|
|
922
|
+
if (requester.kind !== "local")
|
|
923
|
+
throw new Error("--resume is available to a local requester only");
|
|
924
|
+
if (identityName)
|
|
925
|
+
throw new Error("--resume cannot be combined with an identity: the identity's config dir holds none of this profile's conversations");
|
|
926
|
+
assertResumable(resume, agentBase);
|
|
927
|
+
}
|
|
914
928
|
let argv = plannedArgv;
|
|
915
929
|
let codexPolicy = [];
|
|
916
930
|
const reserved = await this.reserveLaunch({
|