@brutalsystems/muster 1.1.1 → 1.2.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/README.md +8 -8
- package/dist/config.js +6 -0
- package/dist/format.js +5 -0
- package/dist/guard.js +170 -12
- package/dist/help.js +16 -2
- package/dist/hosts/tmux.js +70 -0
- package/dist/identity/opencode.js +48 -2
- package/dist/log.js +18 -6
- package/dist/mcp-tools.js +19 -2
- package/dist/muster.js +17 -0
- package/dist/opencode-http.js +9 -0
- package/dist/providers.js +24 -0
- package/dist/run.js +74 -8
- package/dist/task-worker.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,14 +76,14 @@ muster stop <id>
|
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
`--level` names a containment pair. `read` can look but not change; `work` can
|
|
79
|
-
edit its working directory
|
|
80
|
-
`allow_dangerous_flags` in config.
|
|
81
|
-
|
|
82
|
-
| Level | The agent asks before acting | What it may touch
|
|
83
|
-
| ------ | ---------------------------- |
|
|
84
|
-
| `read` | always | nothing on disk
|
|
85
|
-
| `work` | no | its cwd
|
|
86
|
-
| `open` | no | everything
|
|
79
|
+
edit its working directory, except muster's own home (`~/.muster`); `open`
|
|
80
|
+
removes the sandbox and needs `allow_dangerous_flags` in config.
|
|
81
|
+
|
|
82
|
+
| Level | The agent asks before acting | What it may touch |
|
|
83
|
+
| ------ | ---------------------------- | --------------------------- |
|
|
84
|
+
| `read` | always | nothing on disk |
|
|
85
|
+
| `work` | no | its cwd, except `~/.muster` |
|
|
86
|
+
| `open` | no | everything |
|
|
87
87
|
|
|
88
88
|
Claude asks to trust a directory the first time. That is a one-time review you
|
|
89
89
|
do yourself — or `--options auto-approve-path`, which records it for you after
|
package/dist/config.js
CHANGED
|
@@ -58,6 +58,12 @@ const sessionSectionSchema = z
|
|
|
58
58
|
.object({
|
|
59
59
|
idle_timeout: z.string().min(1).optional(),
|
|
60
60
|
ttl: z.string().min(1).optional(),
|
|
61
|
+
/**
|
|
62
|
+
* Show the agent's own terminal title instead of muster's. Off by default:
|
|
63
|
+
* Claude Code retitles constantly with a status glyph and topic, which
|
|
64
|
+
* replaces the name `list` and `stop` accept.
|
|
65
|
+
*/
|
|
66
|
+
title_from_agent: z.boolean().default(false),
|
|
61
67
|
})
|
|
62
68
|
.strict()
|
|
63
69
|
.default({});
|
package/dist/format.js
CHANGED
|
@@ -38,6 +38,9 @@ function recordHuman(record, color) {
|
|
|
38
38
|
const id = record.id ?? record.thread_id ?? record.session_id;
|
|
39
39
|
if (record.stopped === true)
|
|
40
40
|
return `${paint("Stopped", 90)} ${clean(id)}.`;
|
|
41
|
+
// `muster title` answers with the id and the title alone, no kind.
|
|
42
|
+
if (record.kind === undefined && record.title !== undefined)
|
|
43
|
+
return `Titled ${clean(id)}: ${clean(record.title)}.`;
|
|
41
44
|
const lines = [
|
|
42
45
|
`${clean(record.runtime)} · ${clean(record.kind)} · ${paint(clean(record.state), stateColor)}`,
|
|
43
46
|
field("ID", clean(id)),
|
|
@@ -46,6 +49,8 @@ function recordHuman(record, color) {
|
|
|
46
49
|
lines.push(field("Address", clean(record.canonical_id)));
|
|
47
50
|
if (record.runtime === "opencode" && record.server_url)
|
|
48
51
|
lines.push(field("Control URL", clean(record.server_url)));
|
|
52
|
+
if (record.title)
|
|
53
|
+
lines.push(field("Title", clean(record.title)));
|
|
49
54
|
lines.push(field("Directory", clean(record.cwd)));
|
|
50
55
|
if (record.identity)
|
|
51
56
|
lines.push(field("Identity", clean(record.identity)));
|
package/dist/guard.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { realpath } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
5
6
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import TOML from "@iarna/toml";
|
|
6
8
|
import { claudeConfigDir } from "./identity/claude.js";
|
|
7
9
|
import { mcpName } from "./mcp.js";
|
|
8
10
|
import { claudeMcpConfig } from "./claude-policy.js";
|
|
@@ -11,6 +13,7 @@ import { AGENTS, MODEL_FLAGS } from "./agents.js";
|
|
|
11
13
|
import { LAUNCH_OPTIONS } from "./options.js";
|
|
12
14
|
import { RequesterConfigError } from "./requester-policy.js";
|
|
13
15
|
import { parseDuration } from "./duration.js";
|
|
16
|
+
import { assertModelDeclared } from "./providers.js";
|
|
14
17
|
export const runSchema = z
|
|
15
18
|
.object({
|
|
16
19
|
runtime: z.enum(["codex", "claude", "opencode"]),
|
|
@@ -33,6 +36,8 @@ export const runSchema = z
|
|
|
33
36
|
model: z.string().min(1).optional(),
|
|
34
37
|
idleTimeout: z.string().min(1).optional(),
|
|
35
38
|
ttl: z.string().min(1).optional(),
|
|
39
|
+
/** The terminal title of a tmux session. See `resolveTitle`. */
|
|
40
|
+
title: z.string().optional(),
|
|
36
41
|
project: z
|
|
37
42
|
.string()
|
|
38
43
|
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
@@ -132,6 +137,82 @@ export async function assertAllowedWorkspace(cwd, config) {
|
|
|
132
137
|
throw new Error(`cwd is outside every configured allowed root: ${cwd}. ` +
|
|
133
138
|
`Add a root or a project to config.toml, or launch somewhere already permitted.`);
|
|
134
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Where muster keeps its own state when a caller does not say: the same
|
|
142
|
+
* default `Muster.create` uses, so `launchArgs` called on its own protects the
|
|
143
|
+
* home a real launch would.
|
|
144
|
+
*/
|
|
145
|
+
export function defaultMusterHome(env) {
|
|
146
|
+
return join(env.HOME ?? homedir(), ".muster");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* True when a launch at this pair can write under its cwd: every
|
|
150
|
+
* workspace-write launch. Read-only writes nothing, and full-access (`open`)
|
|
151
|
+
* is unrestricted by definition and gated by `allow_dangerous_flags` instead.
|
|
152
|
+
*/
|
|
153
|
+
function writesWorkspace(policy) {
|
|
154
|
+
return policy.sandbox === "workspace-write";
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Refuses a launch that would hand a runtime write access to muster's own home
|
|
158
|
+
* when that runtime cannot be told to leave the home alone (#44).
|
|
159
|
+
*
|
|
160
|
+
* The home holds the machine's policy: `allow_dangerous_flags`, the requester
|
|
161
|
+
* grants, and the MCP server commands muster runs for every later launch. An
|
|
162
|
+
* agent that can write it can widen the policy that constrains it.
|
|
163
|
+
*
|
|
164
|
+
* Claude and Codex take a path deny in what muster generates for them, so they
|
|
165
|
+
* are protected there and never refused here. OpenCode's permissions are tool
|
|
166
|
+
* policy: an `edit` rule can name a path, but a shell command cannot be
|
|
167
|
+
* confined to one, and `bash` is what `work` grants it. So for OpenCode the
|
|
168
|
+
* one guarantee is a cwd that does not contain the home — the home is then an
|
|
169
|
+
* external directory, which `work` already denies. "Overlaps" means the cwd is
|
|
170
|
+
* the home, inside it, or an ancestor of it (such as `~`), compared as real
|
|
171
|
+
* paths so a symlink on either side cannot hide it.
|
|
172
|
+
*/
|
|
173
|
+
export function assertMusterHomeProtectable(runtime, cwd, policy, home) {
|
|
174
|
+
if (runtime !== "opencode" || !writesWorkspace(policy))
|
|
175
|
+
return;
|
|
176
|
+
// `deny` with workspace-write already denies OpenCode's edit and shell
|
|
177
|
+
// tools, so it can write nothing anywhere.
|
|
178
|
+
if (policy.permissions === "deny")
|
|
179
|
+
return;
|
|
180
|
+
// Both spellings of each side. Real paths alone miss a home reached through
|
|
181
|
+
// a symlink: `~/.muster` linking elsewhere is not inside `~` once resolved,
|
|
182
|
+
// yet writing through `~/.muster` lands in it.
|
|
183
|
+
const cwds = writeRootSpellings(resolve(cwd));
|
|
184
|
+
const homes = writeRootSpellings(resolve(home));
|
|
185
|
+
if (cwds.some((c) => homes.some((h) => contains(h, c) || contains(c, h))))
|
|
186
|
+
throw new Error(`refusing an opencode ${policy.sandbox} launch in ${cwd}: it contains or ` +
|
|
187
|
+
`is inside muster's own home (${home}), which holds the config every ` +
|
|
188
|
+
`later launch obeys. OpenCode's permissions are tool policy and cannot ` +
|
|
189
|
+
`keep its shell out of one folder, so launch it in a folder that does ` +
|
|
190
|
+
`not contain muster's home, or at --level read.`);
|
|
191
|
+
}
|
|
192
|
+
/** Whether `inner` is `outer` or below it, by path segments, not characters. */
|
|
193
|
+
function contains(outer, inner) {
|
|
194
|
+
const rel = relative(outer, inner);
|
|
195
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The Codex arguments that make muster's home read-only inside a writable
|
|
199
|
+
* workspace. A permissions profile is the only Codex config that can carve a
|
|
200
|
+
* read-only path out of a writable root, and a `--sandbox` flag overrides a
|
|
201
|
+
* profile, so a workspace-write launch selects this profile instead of passing
|
|
202
|
+
* `--sandbox workspace-write`. `:workspace` is the built-in profile that flag
|
|
203
|
+
* names, so everything else about the sandbox is the same.
|
|
204
|
+
*/
|
|
205
|
+
function codexWorkspaceProfile(home) {
|
|
206
|
+
const filesystem = Object.fromEntries(writeRootSpellings(home).map((p) => [p, "read"]));
|
|
207
|
+
return [
|
|
208
|
+
"-c",
|
|
209
|
+
'default_permissions="muster_work"',
|
|
210
|
+
"-c",
|
|
211
|
+
'permissions.muster_work.extends=":workspace"',
|
|
212
|
+
"-c",
|
|
213
|
+
`permissions.muster_work.filesystem=${TOML.stringify.value(filesystem)}`,
|
|
214
|
+
];
|
|
215
|
+
}
|
|
135
216
|
/**
|
|
136
217
|
* What a request key is bound to, so the same key arriving with different
|
|
137
218
|
* parameters can be refused rather than silently answered with the wrong
|
|
@@ -220,6 +301,15 @@ function modelInArgs(args) {
|
|
|
220
301
|
* by luck — which is the whole point of recording it at all.
|
|
221
302
|
*/
|
|
222
303
|
export function resolveModel(req, config) {
|
|
304
|
+
const resolved = nameModel(req, config);
|
|
305
|
+
// Every route to a model — --model, args, [opencode] model — passes here,
|
|
306
|
+
// and this runs before anything is reserved, so an undeclared one leaves no
|
|
307
|
+
// record behind.
|
|
308
|
+
if (resolved !== null && req.runtime === "opencode")
|
|
309
|
+
assertModelDeclared(resolved.model, config);
|
|
310
|
+
return resolved;
|
|
311
|
+
}
|
|
312
|
+
function nameModel(req, config) {
|
|
223
313
|
const inArgs = modelInArgs(req.args);
|
|
224
314
|
if (req.model !== undefined && inArgs !== undefined)
|
|
225
315
|
throw new Error(`Model named twice: --model ${req.model} and ${inArgs} in runtime options. Name it once.`);
|
|
@@ -241,11 +331,8 @@ export function resolveModel(req, config) {
|
|
|
241
331
|
? null
|
|
242
332
|
: { model: configured, source: "config", origin: "config" };
|
|
243
333
|
}
|
|
244
|
-
/** 30 minutes. Long enough that a trust prompt or a coffee does not cost a
|
|
245
|
-
* session; short enough that an orphan dies within the hour. */
|
|
246
|
-
export const DEFAULT_IDLE_TIMEOUT = 1800;
|
|
247
334
|
/**
|
|
248
|
-
* Flag, then config
|
|
335
|
+
* Flag, then config; `off` disables at any level. Defaults to off.
|
|
249
336
|
*
|
|
250
337
|
* Only a tmux session can honour a lifecycle: pty and macos-terminal die with
|
|
251
338
|
* their parent and a task is not a session at all. An explicit request there is
|
|
@@ -280,10 +367,41 @@ export function resolveLifecycle(req, config, reapable) {
|
|
|
280
367
|
return chosen === "off" ? null : chosen;
|
|
281
368
|
};
|
|
282
369
|
return {
|
|
283
|
-
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", "[session] idle_timeout",
|
|
370
|
+
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", "[session] idle_timeout", null),
|
|
284
371
|
ttl: one(req.ttl, config.session.ttl, "--ttl", "[session] ttl", null),
|
|
285
372
|
};
|
|
286
373
|
}
|
|
374
|
+
/** Longer than any tab shows, short enough that a pasted prompt is not a title. */
|
|
375
|
+
const TITLE_MAX = 100;
|
|
376
|
+
/**
|
|
377
|
+
* A title reaches the outer terminal inside an OSC escape that tmux writes, so a
|
|
378
|
+
* control character in it is not cosmetic: ESC or BEL ends that sequence and
|
|
379
|
+
* whatever follows is read by the terminal as a command of its own. C0, DEL and
|
|
380
|
+
* C1 all go, and what is left must still say something.
|
|
381
|
+
*/
|
|
382
|
+
export function sanitiseTitle(raw) {
|
|
383
|
+
const text = raw
|
|
384
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
|
|
385
|
+
.trim()
|
|
386
|
+
.slice(0, TITLE_MAX)
|
|
387
|
+
.trim();
|
|
388
|
+
if (!text)
|
|
389
|
+
throw new Error("--title must contain printable text");
|
|
390
|
+
return text;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The launch's explicit title, or undefined when the launch should be titled by
|
|
394
|
+
* default once its name is known. Refused where nothing will carry it, on the
|
|
395
|
+
* same terms as `resolveLifecycle`: only a tmux session has a window muster
|
|
396
|
+
* owns, and `titleable` comes from the selected driver, not from `req.host`.
|
|
397
|
+
*/
|
|
398
|
+
export function resolveTitle(req, titleable) {
|
|
399
|
+
if (req.title === undefined)
|
|
400
|
+
return undefined;
|
|
401
|
+
if (!titleable)
|
|
402
|
+
throw new Error(`--title applies to tmux sessions; this launch is ${req.kind === "task" ? "a task" : `hosted on ${req.host ?? "the configured host"}`}`);
|
|
403
|
+
return sanitiseTitle(req.title);
|
|
404
|
+
}
|
|
287
405
|
export function runtimeArgs(req) {
|
|
288
406
|
const result = [];
|
|
289
407
|
const { forwarded, consumed } = AGENTS[req.runtime].runtimeOptions;
|
|
@@ -392,7 +510,9 @@ export function resolveTerminal(req, config) {
|
|
|
392
510
|
return req.terminal;
|
|
393
511
|
return config.terminal === "auto" ? "terminal" : config.terminal;
|
|
394
512
|
}
|
|
395
|
-
export function launchArgs(req, config, mcp = [], bridgePath, env = process.env
|
|
513
|
+
export function launchArgs(req, config, mcp = [], bridgePath, env = process.env,
|
|
514
|
+
/** Muster's own home, write-protected at workspace-write (#44). */
|
|
515
|
+
musterHome = defaultMusterHome(env)) {
|
|
396
516
|
// Before `runtimeArgs`, so this function owns every model error on its own
|
|
397
517
|
// rather than by its caller's discipline: `Muster.launch` happens to resolve
|
|
398
518
|
// first, but `launchArgs` is exported and an input carrying both a model
|
|
@@ -414,20 +534,32 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
|
|
|
414
534
|
: policy.permissions === "bypass"
|
|
415
535
|
? ["--dangerously-bypass-approvals-and-sandbox"]
|
|
416
536
|
: [
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
537
|
+
// Workspace-write is spelled as the profile below instead: the
|
|
538
|
+
// flag would override it.
|
|
539
|
+
...(writesWorkspace(policy)
|
|
540
|
+
? []
|
|
541
|
+
: [
|
|
542
|
+
"--sandbox",
|
|
543
|
+
policy.sandbox === "full-access"
|
|
544
|
+
? "danger-full-access"
|
|
545
|
+
: policy.sandbox,
|
|
546
|
+
]),
|
|
421
547
|
...(req.kind === "session"
|
|
422
548
|
? ["--ask-for-approval", "never"]
|
|
423
549
|
: ["-c", 'approval_policy="never"']),
|
|
424
550
|
]),
|
|
551
|
+
...(writesWorkspace(policy) ? codexWorkspaceProfile(musterHome) : []),
|
|
425
552
|
...model,
|
|
426
553
|
...extra,
|
|
427
554
|
"--",
|
|
428
555
|
req.prompt,
|
|
429
556
|
];
|
|
430
557
|
const readonly = policy.sandbox === "read-only";
|
|
558
|
+
// Muster's own home, under both spellings, wherever the launch can write its
|
|
559
|
+
// cwd: the cwd write grant covers the home when the cwd contains it (#44).
|
|
560
|
+
const protectedHome = writesWorkspace(policy)
|
|
561
|
+
? writeRootSpellings(musterHome)
|
|
562
|
+
: [];
|
|
431
563
|
const mode = policy.permissions === "auto"
|
|
432
564
|
? "auto"
|
|
433
565
|
: policy.permissions === "bypass"
|
|
@@ -456,6 +588,12 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
|
|
|
456
588
|
allow: mcp.flatMap((s) => s.config.tools.map((t) => `mcp__${s.name}__${t}`)),
|
|
457
589
|
deny: [
|
|
458
590
|
...(readonly ? ["Edit", "Write", "NotebookEdit"] : []),
|
|
591
|
+
// The in-process file tools run outside the OS sandbox, so the
|
|
592
|
+
// denyWrite below does not reach them. `//` is Claude's spelling of
|
|
593
|
+
// an absolute path, and an `Edit(path)` rule covers every
|
|
594
|
+
// file-editing tool: Claude 2.1.293 warns that `Write(path)` is never
|
|
595
|
+
// matched by its file permission checks.
|
|
596
|
+
...protectedHome.map((dir) => `Edit(/${dir}/**)`),
|
|
459
597
|
...mcp.flatMap((s) => s.excludedTools.map((t) => `mcp__${s.name}__${t}`)),
|
|
460
598
|
],
|
|
461
599
|
additionalDirectories: [],
|
|
@@ -467,7 +605,16 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
|
|
|
467
605
|
allowUnsandboxedCommands: false,
|
|
468
606
|
excludedCommands: [],
|
|
469
607
|
filesystem: {
|
|
470
|
-
|
|
608
|
+
// The cwd is the sandbox's only default write root, so denying it
|
|
609
|
+
// is the whole of read-only. Not "/": that also denies Claude Code's
|
|
610
|
+
// own temp dir, which it writes after every Bash call, so every
|
|
611
|
+
// command exited 1 (#41) — and a deny beats an allow, so the temp dir
|
|
612
|
+
// cannot be allowed back. Both spellings of a symlinked cwd, so this
|
|
613
|
+
// does not rest on Claude normalising the path.
|
|
614
|
+
// At workspace-write, muster's own home is denied instead (#44): it
|
|
615
|
+
// holds the policy every later launch obeys, and a cwd that contains
|
|
616
|
+
// it (such as `~`) would otherwise make it writable.
|
|
617
|
+
denyWrite: readonly ? writeRootSpellings(req.cwd) : protectedHome,
|
|
471
618
|
// The agent's own memory lives under the config dir, outside the cwd
|
|
472
619
|
// that Claude Code defaults the write root to, so Bash writes to it
|
|
473
620
|
// failed with EPERM while the in-process file tools succeeded (#80).
|
|
@@ -504,6 +651,17 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
|
|
|
504
651
|
req.prompt,
|
|
505
652
|
];
|
|
506
653
|
}
|
|
654
|
+
/** A path as given and as resolved, once each. An unresolvable path (it does
|
|
655
|
+
* not exist yet) is denied as given — nothing can be written through a
|
|
656
|
+
* missing directory anyway. */
|
|
657
|
+
function writeRootSpellings(path) {
|
|
658
|
+
let real = path;
|
|
659
|
+
try {
|
|
660
|
+
real = realpathSync(path);
|
|
661
|
+
}
|
|
662
|
+
catch { }
|
|
663
|
+
return [...new Set([path, real])];
|
|
664
|
+
}
|
|
507
665
|
export function launchEnv(env) {
|
|
508
666
|
const blocked = /^(CLAUDECODE|CLAUDE_CODE_|CODEX_THREAD_ID$|TMUX$|TMUX_PANE$|MCP_)/;
|
|
509
667
|
return Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && !blocked.test(k)));
|
package/dist/help.js
CHANGED
|
@@ -53,7 +53,7 @@ export const COMMANDS = [
|
|
|
53
53
|
],
|
|
54
54
|
[
|
|
55
55
|
"--level LEVEL",
|
|
56
|
-
"read, work, or open: a legal permissions and sandbox pair",
|
|
56
|
+
"read, work, or open: a legal permissions and sandbox pair; work writes the launch folder except muster's own home",
|
|
57
57
|
],
|
|
58
58
|
["--permissions MODE", "auto, deny, or bypass"],
|
|
59
59
|
["--sandbox MODE", "read-only, workspace-write, or full-access"],
|
|
@@ -71,12 +71,16 @@ export const COMMANDS = [
|
|
|
71
71
|
],
|
|
72
72
|
[
|
|
73
73
|
"--idle-timeout DURATION",
|
|
74
|
-
"stop a tmux session after this much inactivity
|
|
74
|
+
"stop a tmux session after this much inactivity (off by default)",
|
|
75
75
|
],
|
|
76
76
|
[
|
|
77
77
|
"--ttl DURATION",
|
|
78
78
|
"stop a tmux session this long after launch, whatever its state",
|
|
79
79
|
],
|
|
80
|
+
[
|
|
81
|
+
"--title TEXT",
|
|
82
|
+
"the terminal title of a tmux session; defaults to its name and runtime",
|
|
83
|
+
],
|
|
80
84
|
[
|
|
81
85
|
"--request-key KEY",
|
|
82
86
|
"name the request; sending it again returns the same launch",
|
|
@@ -121,6 +125,16 @@ export const COMMANDS = [
|
|
|
121
125
|
notes: ["Prints raw text; --format does not apply."],
|
|
122
126
|
examples: ["muster output 6fae755b-8f5d-4425-8ed7-69e84bfac8c5"],
|
|
123
127
|
},
|
|
128
|
+
{
|
|
129
|
+
name: "title",
|
|
130
|
+
summary: "retitle a running tmux session",
|
|
131
|
+
synopsis: "muster title <id> TEXT [--format json|human]",
|
|
132
|
+
options: [["--format FORMAT", "json (default) or human"]],
|
|
133
|
+
notes: [
|
|
134
|
+
"Sets the title any attached terminal shows, without involving the agent. Refused for tasks, pty sessions and ended sessions.",
|
|
135
|
+
],
|
|
136
|
+
examples: ["muster title simple-cms-46 'reviewing #48'"],
|
|
137
|
+
},
|
|
124
138
|
{
|
|
125
139
|
name: "identities",
|
|
126
140
|
summary: "show configured identities",
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -57,6 +57,68 @@ export function parseWindowList(output) {
|
|
|
57
57
|
label: label.join(FIELD),
|
|
58
58
|
}));
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Where muster keeps a session's title. A user option, not the pane title
|
|
62
|
+
* (`#T`): an agent's OSC 0/2 rewrites `#T` whatever `allow-rename` says — that
|
|
63
|
+
* option governs only the window name — so a title muster set there would last
|
|
64
|
+
* until Claude Code's first status update. The pane title is left to the agent,
|
|
65
|
+
* which is what `fromAgent` shows instead.
|
|
66
|
+
*/
|
|
67
|
+
export const TITLE_OPTION = "@muster_title";
|
|
68
|
+
/** `;`-joined `set-option` commands for `new-session`'s command list, so the
|
|
69
|
+
* title is in place before any client can attach and see a blank one. */
|
|
70
|
+
export function titleOptions(session, title) {
|
|
71
|
+
const set = (name, value) => [
|
|
72
|
+
";",
|
|
73
|
+
"set-option",
|
|
74
|
+
"-t",
|
|
75
|
+
session,
|
|
76
|
+
name,
|
|
77
|
+
value,
|
|
78
|
+
];
|
|
79
|
+
return [
|
|
80
|
+
...set(TITLE_OPTION, title.text),
|
|
81
|
+
...set("set-titles-string", title.fromAgent ? "#T" : `#{${TITLE_OPTION}}`),
|
|
82
|
+
...set("set-titles", "on"),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* What muster's server needs in place of a config file. It runs with `-f
|
|
87
|
+
* /dev/null` so a developer's ~/.tmux.conf cannot change how muster's sessions
|
|
88
|
+
* behave, which leaves tmux's own defaults: the scroll wheel never reaches the
|
|
89
|
+
* pane's history, the history is 2000 lines, and Shift+Enter arrives as plain
|
|
90
|
+
* Enter, so it submits Claude Code's prompt instead of starting a new line
|
|
91
|
+
* (#57).
|
|
92
|
+
*
|
|
93
|
+
* These go at the front of `new-session`'s command list, not after it:
|
|
94
|
+
* `history-limit` is read when a pane is created, so a value set once the
|
|
95
|
+
* window exists never reaches it. Every launch sets them again, which is what
|
|
96
|
+
* brings a server started by an older muster up to date.
|
|
97
|
+
*
|
|
98
|
+
* `extkeys` tells tmux the outer terminal understands extended keys, so it
|
|
99
|
+
* asks the terminal for them; without it `extended-keys` has nothing to pass
|
|
100
|
+
* on. It is set at a fixed index rather than appended (`-a`), so a long-lived
|
|
101
|
+
* server does not gain another copy with every launch. `xterm*` matches the
|
|
102
|
+
* TERM Ghostty, iTerm2 and Terminal.app report; a terminal that does not
|
|
103
|
+
* support the request ignores it.
|
|
104
|
+
*
|
|
105
|
+
* `-q` because these name options newer than some tmux still installed
|
|
106
|
+
* (`extended-keys-format` arrived in 3.5, `terminal-features` in 3.2), and an
|
|
107
|
+
* unknown option is an error that would abort the whole command list and with
|
|
108
|
+
* it the launch. An older tmux launches as before, without the option.
|
|
109
|
+
*/
|
|
110
|
+
export const SERVER_OPTIONS = [
|
|
111
|
+
["-g", "mouse", "on"],
|
|
112
|
+
["-g", "history-limit", "50000"],
|
|
113
|
+
["-s", "extended-keys", "always"],
|
|
114
|
+
["-s", "extended-keys-format", "csi-u"],
|
|
115
|
+
["-s", "terminal-features[90]", "xterm*:extkeys"],
|
|
116
|
+
];
|
|
117
|
+
/** `SERVER_OPTIONS` as `;`-terminated `set-option` commands, to lead a
|
|
118
|
+
* command list. */
|
|
119
|
+
export function serverOptions() {
|
|
120
|
+
return SERVER_OPTIONS.flatMap((o) => ["set-option", "-q", ...o, ";"]);
|
|
121
|
+
}
|
|
60
122
|
/**
|
|
61
123
|
* One shell command line for `run-shell`.
|
|
62
124
|
*
|
|
@@ -171,6 +233,7 @@ export class TmuxHost {
|
|
|
171
233
|
// command list. Targeted by session name rather than relying on "current",
|
|
172
234
|
// which is not meaningful for a detached session.
|
|
173
235
|
const output = await this.call([
|
|
236
|
+
...serverOptions(),
|
|
174
237
|
"new-session",
|
|
175
238
|
"-s",
|
|
176
239
|
session,
|
|
@@ -181,6 +244,7 @@ export class TmuxHost {
|
|
|
181
244
|
session,
|
|
182
245
|
"status",
|
|
183
246
|
this.status,
|
|
247
|
+
...(opts.title ? titleOptions(session, opts.title) : []),
|
|
184
248
|
], opts.deadline);
|
|
185
249
|
return parseWindowRow(output);
|
|
186
250
|
}
|
|
@@ -202,6 +266,12 @@ export class TmuxHost {
|
|
|
202
266
|
throw e;
|
|
203
267
|
}
|
|
204
268
|
}
|
|
269
|
+
/** Session options accept a window id as their target, so the hostRef the
|
|
270
|
+
* registry already holds is enough. The value is passed as one argv element
|
|
271
|
+
* and tmux stores it verbatim: a leading `-` or a `#{...}` stays text. */
|
|
272
|
+
async setTitle(hostRef, text) {
|
|
273
|
+
await this.call(["set-option", "-t", hostRef, TITLE_OPTION, text]);
|
|
274
|
+
}
|
|
205
275
|
async list() {
|
|
206
276
|
try {
|
|
207
277
|
return parseWindowList(await this.call(["list-windows", "-a", "-F", LIST_FORMAT]));
|
|
@@ -29,7 +29,48 @@ export async function openCodeEndpointOwnedBy(rootPid, serverUrl, deadline) {
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/** The text of the session's first user message, or undefined before it has one. */
|
|
33
|
+
function firstUserText(messages) {
|
|
34
|
+
const first = messages.find((message) => message.info.role === "user");
|
|
35
|
+
if (!first)
|
|
36
|
+
return undefined;
|
|
37
|
+
return first.parts
|
|
38
|
+
.filter((part) => part.type === "text" && part.text !== undefined)
|
|
39
|
+
.map((part) => part.text)
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
async function carriesPrompt(client, id, prompt, deadline) {
|
|
43
|
+
try {
|
|
44
|
+
const text = firstUserText(await client.messages(id, deadline));
|
|
45
|
+
return text !== undefined && text.trim() === prompt.trim();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// One unreadable candidate — deleted, or another process's half-written
|
|
49
|
+
// session — is not a match, and must not stall discovery of the rest.
|
|
50
|
+
if (error instanceof OpenCodeProtocolError)
|
|
51
|
+
return false;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Finds the session this launch's OpenCode created.
|
|
57
|
+
*
|
|
58
|
+
* `/session` reads the project database every OpenCode process on the machine
|
|
59
|
+
* shares, so directory and creation window alone can match a stranger's
|
|
60
|
+
* session. Two signals tie a candidate to this launch:
|
|
61
|
+
*
|
|
62
|
+
* - presence in `/session/status`, which is the endpoint process's in-memory
|
|
63
|
+
* map of its own non-idle sessions; or
|
|
64
|
+
* - for a candidate absent from that map, a first user message equal to the
|
|
65
|
+
* prompt this launch submitted.
|
|
66
|
+
*
|
|
67
|
+
* The second exists because OpenCode deletes a session from the status map the
|
|
68
|
+
* moment it goes idle (#2): a first turn that finishes before discovery left
|
|
69
|
+
* the session invisible, and the launch timed out. Without `prompt`, an absent
|
|
70
|
+
* candidate is never claimed. Every candidate that passes either signal counts
|
|
71
|
+
* toward the ambiguity check.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, deadline, prompt) {
|
|
33
74
|
const resolvedCwd = await realpath(cwd);
|
|
34
75
|
const client = new OpenCodeHttp(serverUrl);
|
|
35
76
|
let ownershipProven = false;
|
|
@@ -55,13 +96,18 @@ export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, dea
|
|
|
55
96
|
const statuses = candidates.length
|
|
56
97
|
? await client.statuses(deadline)
|
|
57
98
|
: {};
|
|
99
|
+
const matches = [];
|
|
100
|
+
for (const session of candidates)
|
|
101
|
+
if (Object.prototype.hasOwnProperty.call(statuses, session.id) ||
|
|
102
|
+
(prompt !== undefined &&
|
|
103
|
+
(await carriesPrompt(client, session.id, prompt, deadline))))
|
|
104
|
+
matches.push(session);
|
|
58
105
|
if (!(await processRef(rootPid)))
|
|
59
106
|
return;
|
|
60
107
|
if (!(await openCodeEndpointOwnedBy(rootPid, serverUrl, deadline))) {
|
|
61
108
|
ownershipProven = false;
|
|
62
109
|
continue;
|
|
63
110
|
}
|
|
64
|
-
const matches = candidates.filter((session) => Object.prototype.hasOwnProperty.call(statuses, session.id));
|
|
65
111
|
if (matches.length > 1)
|
|
66
112
|
throw new Error("Multiple OpenCode sessions matched the launch endpoint, cwd, and creation window");
|
|
67
113
|
const match = matches[0];
|
package/dist/log.js
CHANGED
|
@@ -38,6 +38,16 @@ export const METADATA_RECORD_KEEP = [
|
|
|
38
38
|
"mcp_warnings",
|
|
39
39
|
"plugins",
|
|
40
40
|
];
|
|
41
|
+
/**
|
|
42
|
+
* What an `error` becomes in metadata mode. An error is free text, and some of
|
|
43
|
+
* it is prompt-derived: a tmux error quotes the host's own output, and the
|
|
44
|
+
* window name is a slice of the prompt (#26). Free text cannot be allowlisted
|
|
45
|
+
* field by field the way a record is, so the only safe projection of the
|
|
46
|
+
* string is a constant. The key is kept rather than dropped so a metadata line
|
|
47
|
+
* still says a launch failed with an error, which an absent key would not;
|
|
48
|
+
* the text stays in the registry entry, which is not this log.
|
|
49
|
+
*/
|
|
50
|
+
export const ERROR_WITHHELD = "withheld (metadata-only log)";
|
|
41
51
|
/**
|
|
42
52
|
* `requester` is the one nested object passed through whole: the spec keeps the
|
|
43
53
|
* tuple and its label, and a label is supplied by the receiver's own config,
|
|
@@ -79,12 +89,14 @@ export class LaunchLog {
|
|
|
79
89
|
? data
|
|
80
90
|
: Object.fromEntries(Object.entries(data)
|
|
81
91
|
.filter(([k]) => !METADATA_DROP.includes(k))
|
|
82
|
-
.map(([k, v]) =>
|
|
83
|
-
? [
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
.map(([k, v]) => k === "error" && v !== undefined
|
|
93
|
+
? [k, ERROR_WITHHELD]
|
|
94
|
+
: isRecord(v) && !PASS_THROUGH.includes(k)
|
|
95
|
+
? [
|
|
96
|
+
k,
|
|
97
|
+
Object.fromEntries(Object.entries(v).filter(([field]) => METADATA_RECORD_KEEP.includes(field))),
|
|
98
|
+
]
|
|
99
|
+
: [k, v]));
|
|
88
100
|
const file = await open(path, "a", 0o600);
|
|
89
101
|
try {
|
|
90
102
|
await file.writeFile(JSON.stringify({ at: new Date().toISOString(), ...body }) + "\n");
|
package/dist/mcp-tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `run`, `list`, `stop` and `
|
|
2
|
+
* The `run`, `list`, `stop`, `output` and `title` tools exactly as the MCP server
|
|
3
3
|
* advertises them.
|
|
4
4
|
*
|
|
5
5
|
* Extracted from `src/muster.ts` so it can be ASSERTED against `runSchema`
|
|
@@ -92,12 +92,16 @@ export const MCP_TOOLS = [
|
|
|
92
92
|
},
|
|
93
93
|
idleTimeout: {
|
|
94
94
|
type: "string",
|
|
95
|
-
description: "Stop this tmux session after the given inactivity, as 90s/30m/4h, or 'off'.
|
|
95
|
+
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.",
|
|
96
96
|
},
|
|
97
97
|
ttl: {
|
|
98
98
|
type: "string",
|
|
99
99
|
description: "Stop this tmux session the given duration after launch regardless of activity, as 90s/30m/4h, or 'off'. Off by default.",
|
|
100
100
|
},
|
|
101
|
+
title: {
|
|
102
|
+
type: "string",
|
|
103
|
+
description: "Terminal title for this tmux session, shown by any terminal attached to it. Defaults to the session's name and runtime once it is reachable; the agent's own title is shown instead only with [session] title_from_agent. Control characters are stripped and it is capped at 100 characters. Refused for tasks and for pty or macos-terminal hosts.",
|
|
104
|
+
},
|
|
101
105
|
identity: {
|
|
102
106
|
type: "string",
|
|
103
107
|
pattern: "^[a-zA-Z0-9_-]+$",
|
|
@@ -138,6 +142,19 @@ export const MCP_TOOLS = [
|
|
|
138
142
|
additionalProperties: false,
|
|
139
143
|
},
|
|
140
144
|
},
|
|
145
|
+
{
|
|
146
|
+
name: "title",
|
|
147
|
+
description: "Retitle a running tmux session by durable id or unambiguous peer name, from outside its agent.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
id: { type: "string", minLength: 1 },
|
|
152
|
+
title: { type: "string", minLength: 1 },
|
|
153
|
+
},
|
|
154
|
+
required: ["id", "title"],
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
141
158
|
];
|
|
142
159
|
/**
|
|
143
160
|
* runSchema keys deliberately absent from the advertised `run` inputSchema.
|
package/dist/muster.js
CHANGED
|
@@ -20,6 +20,9 @@ const listSchema = z
|
|
|
20
20
|
.object({ kind: z.enum(["session", "task"]).optional() })
|
|
21
21
|
.strict();
|
|
22
22
|
const idSchema = z.object({ id: z.string().min(1) }).strict();
|
|
23
|
+
const titleSchema = z
|
|
24
|
+
.object({ id: z.string().min(1), title: z.string().min(1) })
|
|
25
|
+
.strict();
|
|
23
26
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
24
27
|
const text = (value) => ({
|
|
25
28
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -125,6 +128,10 @@ async function main() {
|
|
|
125
128
|
return text(await muster.stop(idSchema.parse(args).id));
|
|
126
129
|
case "output":
|
|
127
130
|
return text(await muster.output(idSchema.parse(args).id));
|
|
131
|
+
case "title": {
|
|
132
|
+
const { id, title } = titleSchema.parse(args);
|
|
133
|
+
return text(await muster.title(id, title));
|
|
134
|
+
}
|
|
128
135
|
default:
|
|
129
136
|
throw new Error(`Unknown tool ${params.name}`);
|
|
130
137
|
}
|
|
@@ -175,6 +182,7 @@ async function main() {
|
|
|
175
182
|
model: { type: "string" },
|
|
176
183
|
"idle-timeout": { type: "string" },
|
|
177
184
|
ttl: { type: "string" },
|
|
185
|
+
title: { type: "string" },
|
|
178
186
|
socket: { type: "string" },
|
|
179
187
|
home: { type: "string" },
|
|
180
188
|
"stop-now": { type: "boolean" },
|
|
@@ -194,6 +202,7 @@ async function main() {
|
|
|
194
202
|
for (const [name, value] of [
|
|
195
203
|
["--idle-timeout", idleTimeout],
|
|
196
204
|
["--ttl", values.ttl],
|
|
205
|
+
["--title", values.title],
|
|
197
206
|
])
|
|
198
207
|
if (value !== undefined && command !== "run")
|
|
199
208
|
throw new Error(`${name} applies only to run`);
|
|
@@ -336,6 +345,14 @@ async function main() {
|
|
|
336
345
|
else
|
|
337
346
|
process.stdout.write(await muster.output(id));
|
|
338
347
|
}
|
|
348
|
+
else if (command === "title") {
|
|
349
|
+
// Positional rather than --title: the flag names a launch's title, and
|
|
350
|
+
// reusing it here would make `muster title --title x <id>` a spelling.
|
|
351
|
+
if (positionals.length !== 2 || Object.keys(values).length)
|
|
352
|
+
throw new Error("title requires an id and the title text");
|
|
353
|
+
const { id } = idSchema.parse({ id: positionals[0] });
|
|
354
|
+
print(await muster.title(id, positionals[1]));
|
|
355
|
+
}
|
|
339
356
|
else if (command === "reap-check") {
|
|
340
357
|
// Hidden: armed by muster itself as a tmux job, never typed by a person.
|
|
341
358
|
// NOTE: `Muster.create()` above already ran against the REAL home before
|
package/dist/opencode-http.js
CHANGED
|
@@ -14,6 +14,12 @@ const sessionSchema = z.object({
|
|
|
14
14
|
updated: z.number(),
|
|
15
15
|
}),
|
|
16
16
|
});
|
|
17
|
+
// Only what identity needs: who wrote each message and its text parts. Every
|
|
18
|
+
// other field, and every non-text part, passes through unread.
|
|
19
|
+
const messageSchema = z.object({
|
|
20
|
+
info: z.object({ role: z.string() }).passthrough(),
|
|
21
|
+
parts: z.array(z.object({ type: z.string(), text: z.string().optional() }).passthrough()),
|
|
22
|
+
});
|
|
17
23
|
const statusSchema = z.discriminatedUnion("type", [
|
|
18
24
|
z.object({ type: z.literal("idle") }),
|
|
19
25
|
z.object({ type: z.literal("busy") }),
|
|
@@ -125,6 +131,9 @@ export class OpenCodeHttp {
|
|
|
125
131
|
session(id, deadline) {
|
|
126
132
|
return this.requestJson("GET", `/session/${encodeURIComponent(id)}`, deadline, sessionSchema);
|
|
127
133
|
}
|
|
134
|
+
messages(id, deadline) {
|
|
135
|
+
return this.requestJson("GET", `/session/${encodeURIComponent(id)}/message`, deadline, z.array(messageSchema));
|
|
136
|
+
}
|
|
128
137
|
statuses(deadline) {
|
|
129
138
|
return this.requestJson("GET", "/session/status", deadline, z.record(statusSchema));
|
|
130
139
|
}
|
package/dist/providers.js
CHANGED
|
@@ -37,6 +37,30 @@ function models(definition) {
|
|
|
37
37
|
? Object.fromEntries(definition.models.map((id) => [id, {}]))
|
|
38
38
|
: definition.models;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Refuses an OpenCode model whose provider muster declares but whose id that
|
|
42
|
+
* provider does not list (#52). OpenCode answers that launch with a bare
|
|
43
|
+
* "Unexpected server error", and nothing in it points at the missing entry.
|
|
44
|
+
*
|
|
45
|
+
* Only providers muster itself declares are checked. A built-in provider
|
|
46
|
+
* (anthropic, openai) or one from the user's own OpenCode configuration never
|
|
47
|
+
* appears here, and is OpenCode's to resolve: refusing it would break launches
|
|
48
|
+
* that work today. A model with no slash is likewise left for OpenCode.
|
|
49
|
+
*/
|
|
50
|
+
export function assertModelDeclared(model, config) {
|
|
51
|
+
const slash = model.indexOf("/");
|
|
52
|
+
if (slash <= 0)
|
|
53
|
+
return;
|
|
54
|
+
const provider = model.slice(0, slash);
|
|
55
|
+
const id = model.slice(slash + 1);
|
|
56
|
+
const definition = Object.hasOwn(config.opencode.provider, provider)
|
|
57
|
+
? config.opencode.provider[provider]
|
|
58
|
+
: undefined;
|
|
59
|
+
if (definition === undefined || Object.hasOwn(models(definition), id))
|
|
60
|
+
return;
|
|
61
|
+
throw new Error(`model ${id} is not declared under [opencode.provider.${provider}.models]; ` +
|
|
62
|
+
`add [opencode.provider.${provider}.models.${JSON.stringify(id)}] to ~/.muster/config.toml`);
|
|
63
|
+
}
|
|
40
64
|
function apiKey(name, definition, source) {
|
|
41
65
|
if (definition.api_key !== undefined)
|
|
42
66
|
return definition.api_key;
|
package/dist/run.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { stat, readFile, writeFile, mkdir, rm, } from "node:fs/promises";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
3
|
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, resolveModel, 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, } 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";
|
|
@@ -69,6 +69,10 @@ function taskHandle(entry, pid) {
|
|
|
69
69
|
function configHomeVar(runtime) {
|
|
70
70
|
return AGENTS[runtime].configHomeVar ?? undefined;
|
|
71
71
|
}
|
|
72
|
+
/** What a window shows before readiness names the session: never the prompt. */
|
|
73
|
+
function provisionalTitle(req) {
|
|
74
|
+
return sanitiseTitle(`${basename(req.cwd)} · ${req.runtime}`);
|
|
75
|
+
}
|
|
72
76
|
export class Muster {
|
|
73
77
|
config;
|
|
74
78
|
home;
|
|
@@ -260,6 +264,10 @@ export class Muster {
|
|
|
260
264
|
// Refused here, with the other checks that cost nothing to fail: an option
|
|
261
265
|
// the runtime cannot honour must not leave a registry entry behind.
|
|
262
266
|
assertOptionsSupported(req.runtime, req.options, permissions);
|
|
267
|
+
// A runtime that cannot be told to leave muster's home alone is not
|
|
268
|
+
// handed a cwd that contains it (#44). Refused here, before anything is
|
|
269
|
+
// reserved, like the other checks that cost nothing to fail.
|
|
270
|
+
assertMusterHomeProtectable(req.runtime, req.cwd, permissions, this.home);
|
|
263
271
|
const enforcement = enforcementGrade(req.runtime, permissions);
|
|
264
272
|
assertEnforcementMeetsMinimum(req.runtime, enforcement, policy);
|
|
265
273
|
// Composed here, before any process of any kind is started — the version
|
|
@@ -277,7 +285,7 @@ export class Muster {
|
|
|
277
285
|
if (req.runtime === "opencode")
|
|
278
286
|
runtimeArgs(effective);
|
|
279
287
|
else
|
|
280
|
-
argv = launchArgs(effective, this.config, [], undefined, this.sourceEnv);
|
|
288
|
+
argv = launchArgs(effective, this.config, [], undefined, this.sourceEnv, this.home);
|
|
281
289
|
const driver = req.kind === "session"
|
|
282
290
|
? await selectHost(req.open ? "tmux" : requestedHost, this.drivers)
|
|
283
291
|
: undefined;
|
|
@@ -294,6 +302,9 @@ export class Muster {
|
|
|
294
302
|
// Deciding from `req.host` accepts --idle-timeout under `host = "pty"` in
|
|
295
303
|
// config and then never arms it.
|
|
296
304
|
const lifecycle = resolveLifecycle(effective, this.config, req.kind === "session" && typeof driver?.schedule === "function");
|
|
305
|
+
// Beside the lifecycle and for the same reason: only the selected driver
|
|
306
|
+
// knows whether there will be a window to title.
|
|
307
|
+
const title = resolveTitle(effective, req.kind === "session" && typeof driver?.setTitle === "function");
|
|
297
308
|
return {
|
|
298
309
|
effective,
|
|
299
310
|
terminal,
|
|
@@ -305,6 +316,7 @@ export class Muster {
|
|
|
305
316
|
model,
|
|
306
317
|
driver,
|
|
307
318
|
lifecycle,
|
|
319
|
+
title,
|
|
308
320
|
argv,
|
|
309
321
|
};
|
|
310
322
|
}
|
|
@@ -467,7 +479,10 @@ export class Muster {
|
|
|
467
479
|
// `runtimeEnv`, not the host's: an identity launch relocates
|
|
468
480
|
// CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
|
|
469
481
|
// the dir the child will actually open, not the one Muster runs under.
|
|
470
|
-
argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv
|
|
482
|
+
argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv,
|
|
483
|
+
// Muster's own home, not one derived from `runtimeEnv`: an identity
|
|
484
|
+
// launch may relocate the agent's HOME, but not where muster lives.
|
|
485
|
+
this.home);
|
|
471
486
|
if (req.runtime === "codex") {
|
|
472
487
|
codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
473
488
|
argv.splice(1, 0, ...codexPolicy);
|
|
@@ -501,7 +516,7 @@ export class Muster {
|
|
|
501
516
|
* readiness.
|
|
502
517
|
*/
|
|
503
518
|
async startProcess(c) {
|
|
504
|
-
const { req, argv, runtimeEnv, policy, driver, lifecycle, deadline } = c;
|
|
519
|
+
const { req, argv, runtimeEnv, policy, driver, lifecycle, title, deadline, } = c;
|
|
505
520
|
let entry = c.entry;
|
|
506
521
|
if (this.closing)
|
|
507
522
|
throw new Error("Launch cancelled: Muster is closing");
|
|
@@ -517,6 +532,16 @@ export class Muster {
|
|
|
517
532
|
cwd: req.cwd,
|
|
518
533
|
env: runtimeEnv,
|
|
519
534
|
label: req.prompt.slice(0, 48),
|
|
535
|
+
// Provisional until readiness names the session, unless the caller named
|
|
536
|
+
// it: the window must never show a prompt fragment as its title.
|
|
537
|
+
...(driver.setTitle
|
|
538
|
+
? {
|
|
539
|
+
title: {
|
|
540
|
+
text: title ?? provisionalTitle(req),
|
|
541
|
+
fromAgent: this.config.session.title_from_agent,
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
: {}),
|
|
520
545
|
deadline,
|
|
521
546
|
});
|
|
522
547
|
const root = await processRef(launched.pid);
|
|
@@ -594,7 +619,7 @@ export class Muster {
|
|
|
594
619
|
* `finally` has to close it even when this throws.
|
|
595
620
|
*/
|
|
596
621
|
async awaitReadiness(c) {
|
|
597
|
-
const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, startedAt, deadline, } = c;
|
|
622
|
+
const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, title, startedAt, deadline, } = c;
|
|
598
623
|
let entry = c.entry;
|
|
599
624
|
let diagnostic = req.runtime === "claude"
|
|
600
625
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
@@ -644,7 +669,7 @@ export class Muster {
|
|
|
644
669
|
}
|
|
645
670
|
})
|
|
646
671
|
: req.runtime === "opencode"
|
|
647
|
-
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
|
|
672
|
+
? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline, req.prompt)
|
|
648
673
|
: await resolveClaude(root.pid, resolveEnv, deadline);
|
|
649
674
|
if (identity) {
|
|
650
675
|
diagnostic =
|
|
@@ -675,6 +700,7 @@ export class Muster {
|
|
|
675
700
|
const named = assignNames([
|
|
676
701
|
{ runtime, uuid: identity.id, rawName: metadata.rawName },
|
|
677
702
|
])[0];
|
|
703
|
+
const shownTitle = await this.nameTitle(driver, launched.hostRef, title, `${named.display} · ${req.runtime}`, provisionalTitle(req));
|
|
678
704
|
const peer = {
|
|
679
705
|
kind: "session",
|
|
680
706
|
...permissions,
|
|
@@ -707,6 +733,7 @@ export class Muster {
|
|
|
707
733
|
? { idle_timeout: lifecycle.idleTimeout }
|
|
708
734
|
: {}),
|
|
709
735
|
...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
|
|
736
|
+
...(shownTitle ? { title: shownTitle } : {}),
|
|
710
737
|
attach_hint: driver.attachHint(launched.hostRef),
|
|
711
738
|
};
|
|
712
739
|
if (req.open) {
|
|
@@ -753,7 +780,7 @@ export class Muster {
|
|
|
753
780
|
async launch(input, requester) {
|
|
754
781
|
const { req, policy, level } = await this.authorize(input, requester);
|
|
755
782
|
const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
|
|
756
|
-
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
783
|
+
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, title, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
757
784
|
let argv = plannedArgv;
|
|
758
785
|
let codexPolicy = [];
|
|
759
786
|
const reserved = await this.reserveLaunch({
|
|
@@ -837,6 +864,7 @@ export class Muster {
|
|
|
837
864
|
policy,
|
|
838
865
|
driver,
|
|
839
866
|
lifecycle,
|
|
867
|
+
title,
|
|
840
868
|
deadline,
|
|
841
869
|
});
|
|
842
870
|
if ("outcome" in started)
|
|
@@ -862,6 +890,7 @@ export class Muster {
|
|
|
862
890
|
permissions,
|
|
863
891
|
terminal,
|
|
864
892
|
lifecycle,
|
|
893
|
+
title,
|
|
865
894
|
startedAt,
|
|
866
895
|
deadline,
|
|
867
896
|
});
|
|
@@ -920,6 +949,7 @@ export class Muster {
|
|
|
920
949
|
exitPath,
|
|
921
950
|
startPath,
|
|
922
951
|
logHome: this.home,
|
|
952
|
+
logMode,
|
|
923
953
|
launchId: entry.launchId,
|
|
924
954
|
mcpConfigPath: entry.mcpConfigPath,
|
|
925
955
|
}), { mode: 0o600 });
|
|
@@ -1021,6 +1051,42 @@ export class Muster {
|
|
|
1021
1051
|
throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
|
|
1022
1052
|
return peers[named.indexOf(result.peer)];
|
|
1023
1053
|
}
|
|
1054
|
+
/**
|
|
1055
|
+
* The title a ready session ends up showing. A caller's own title was set at
|
|
1056
|
+
* launch and stays; otherwise the provisional one is replaced with the name
|
|
1057
|
+
* `list` and `stop` accept, which only exists once the agent has registered.
|
|
1058
|
+
* A retitle that fails does not fail the launch — the session is up — but the
|
|
1059
|
+
* record then reports the provisional title, because that is what is shown.
|
|
1060
|
+
*/
|
|
1061
|
+
async nameTitle(driver, hostRef, explicit, named, provisional) {
|
|
1062
|
+
if (!driver?.setTitle)
|
|
1063
|
+
return undefined;
|
|
1064
|
+
if (explicit !== undefined)
|
|
1065
|
+
return explicit;
|
|
1066
|
+
const text = sanitiseTitle(named);
|
|
1067
|
+
return driver.setTitle(hostRef, text).then(() => text, () => provisional);
|
|
1068
|
+
}
|
|
1069
|
+
/** Retitle a running tmux session from outside its agent. */
|
|
1070
|
+
async title(id, raw, requester = LOCAL) {
|
|
1071
|
+
const entry = await this.find(id);
|
|
1072
|
+
assertMayAct(entry, requester);
|
|
1073
|
+
const driver = this.drivers.find((d) => d.id === entry.host);
|
|
1074
|
+
const refusal = entry.kind !== "session"
|
|
1075
|
+
? "this is a task"
|
|
1076
|
+
: entry.status !== "running"
|
|
1077
|
+
? `this session is ${entry.status}`
|
|
1078
|
+
: !driver?.setTitle || !entry.hostRef || !entry.peer
|
|
1079
|
+
? `this session is hosted on ${entry.host ?? "no host"}`
|
|
1080
|
+
: undefined;
|
|
1081
|
+
if (refusal)
|
|
1082
|
+
throw new Error(`title applies to a running tmux session; ${refusal}`);
|
|
1083
|
+
const text = sanitiseTitle(raw);
|
|
1084
|
+
await driver.setTitle(entry.hostRef, text);
|
|
1085
|
+
await this.registry.update(entry.launchId, {
|
|
1086
|
+
peer: { ...entry.peer, title: text },
|
|
1087
|
+
});
|
|
1088
|
+
return { id: entry.id, title: text };
|
|
1089
|
+
}
|
|
1024
1090
|
async stop(id, requester = LOCAL) {
|
|
1025
1091
|
const entry = await this.find(id);
|
|
1026
1092
|
assertMayAct(entry, requester);
|
package/dist/task-worker.js
CHANGED