@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 +8 -1
- package/dist/cli-options.js +2 -0
- package/dist/guard.js +165 -4
- package/dist/help.js +8 -0
- package/dist/mcp-tools.js +8 -0
- package/dist/muster.js +4 -1
- package/dist/run.js +24 -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 { 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
|
|
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({
|