@brutalsystems/muster 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/config.js +6 -0
- package/dist/format.js +5 -0
- package/dist/guard.js +54 -7
- package/dist/help.js +15 -1
- package/dist/hosts/tmux.js +32 -0
- package/dist/mcp-tools.js +19 -2
- package/dist/muster.js +17 -0
- package/dist/run.js +71 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,7 +59,9 @@ models; behaviour against real ones is verified by hand and recorded in
|
|
|
59
59
|
## Quick start
|
|
60
60
|
|
|
61
61
|
```sh
|
|
62
|
-
|
|
62
|
+
# Allow the install scripts: muster's postinstall builds node-pty, and npm
|
|
63
|
+
# skips install scripts by default — silently, so the pty host fails later.
|
|
64
|
+
npm install -g --allow-scripts=@brutalsystems/muster,node-pty @brutalsystems/muster
|
|
63
65
|
|
|
64
66
|
# A session: an agent you can reach, addressed once it is genuinely alive.
|
|
65
67
|
muster run claude --prompt 'Review this project' --cwd . --level work
|
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,6 +1,6 @@
|
|
|
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
5
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { claudeConfigDir } from "./identity/claude.js";
|
|
@@ -33,6 +33,8 @@ export const runSchema = z
|
|
|
33
33
|
model: z.string().min(1).optional(),
|
|
34
34
|
idleTimeout: z.string().min(1).optional(),
|
|
35
35
|
ttl: z.string().min(1).optional(),
|
|
36
|
+
/** The terminal title of a tmux session. See `resolveTitle`. */
|
|
37
|
+
title: z.string().optional(),
|
|
36
38
|
project: z
|
|
37
39
|
.string()
|
|
38
40
|
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
@@ -241,11 +243,8 @@ export function resolveModel(req, config) {
|
|
|
241
243
|
? null
|
|
242
244
|
: { model: configured, source: "config", origin: "config" };
|
|
243
245
|
}
|
|
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
246
|
/**
|
|
248
|
-
* Flag, then config
|
|
247
|
+
* Flag, then config; `off` disables at any level. Defaults to off.
|
|
249
248
|
*
|
|
250
249
|
* Only a tmux session can honour a lifecycle: pty and macos-terminal die with
|
|
251
250
|
* their parent and a task is not a session at all. An explicit request there is
|
|
@@ -280,10 +279,41 @@ export function resolveLifecycle(req, config, reapable) {
|
|
|
280
279
|
return chosen === "off" ? null : chosen;
|
|
281
280
|
};
|
|
282
281
|
return {
|
|
283
|
-
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", "[session] idle_timeout",
|
|
282
|
+
idleTimeout: one(req.idleTimeout, config.session.idle_timeout, "--idle-timeout", "[session] idle_timeout", null),
|
|
284
283
|
ttl: one(req.ttl, config.session.ttl, "--ttl", "[session] ttl", null),
|
|
285
284
|
};
|
|
286
285
|
}
|
|
286
|
+
/** Longer than any tab shows, short enough that a pasted prompt is not a title. */
|
|
287
|
+
const TITLE_MAX = 100;
|
|
288
|
+
/**
|
|
289
|
+
* A title reaches the outer terminal inside an OSC escape that tmux writes, so a
|
|
290
|
+
* control character in it is not cosmetic: ESC or BEL ends that sequence and
|
|
291
|
+
* whatever follows is read by the terminal as a command of its own. C0, DEL and
|
|
292
|
+
* C1 all go, and what is left must still say something.
|
|
293
|
+
*/
|
|
294
|
+
export function sanitiseTitle(raw) {
|
|
295
|
+
const text = raw
|
|
296
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
|
|
297
|
+
.trim()
|
|
298
|
+
.slice(0, TITLE_MAX)
|
|
299
|
+
.trim();
|
|
300
|
+
if (!text)
|
|
301
|
+
throw new Error("--title must contain printable text");
|
|
302
|
+
return text;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The launch's explicit title, or undefined when the launch should be titled by
|
|
306
|
+
* default once its name is known. Refused where nothing will carry it, on the
|
|
307
|
+
* same terms as `resolveLifecycle`: only a tmux session has a window muster
|
|
308
|
+
* owns, and `titleable` comes from the selected driver, not from `req.host`.
|
|
309
|
+
*/
|
|
310
|
+
export function resolveTitle(req, titleable) {
|
|
311
|
+
if (req.title === undefined)
|
|
312
|
+
return undefined;
|
|
313
|
+
if (!titleable)
|
|
314
|
+
throw new Error(`--title applies to tmux sessions; this launch is ${req.kind === "task" ? "a task" : `hosted on ${req.host ?? "the configured host"}`}`);
|
|
315
|
+
return sanitiseTitle(req.title);
|
|
316
|
+
}
|
|
287
317
|
export function runtimeArgs(req) {
|
|
288
318
|
const result = [];
|
|
289
319
|
const { forwarded, consumed } = AGENTS[req.runtime].runtimeOptions;
|
|
@@ -467,7 +497,13 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
|
|
|
467
497
|
allowUnsandboxedCommands: false,
|
|
468
498
|
excludedCommands: [],
|
|
469
499
|
filesystem: {
|
|
470
|
-
|
|
500
|
+
// The cwd is the sandbox's only default write root, so denying it
|
|
501
|
+
// is the whole of read-only. Not "/": that also denies Claude Code's
|
|
502
|
+
// own temp dir, which it writes after every Bash call, so every
|
|
503
|
+
// command exited 1 (#41) — and a deny beats an allow, so the temp dir
|
|
504
|
+
// cannot be allowed back. Both spellings of a symlinked cwd, so this
|
|
505
|
+
// does not rest on Claude normalising the path.
|
|
506
|
+
denyWrite: readonly ? writeRootSpellings(req.cwd) : [],
|
|
471
507
|
// The agent's own memory lives under the config dir, outside the cwd
|
|
472
508
|
// that Claude Code defaults the write root to, so Bash writes to it
|
|
473
509
|
// failed with EPERM while the in-process file tools succeeded (#80).
|
|
@@ -504,6 +540,17 @@ export function launchArgs(req, config, mcp = [], bridgePath, env = process.env)
|
|
|
504
540
|
req.prompt,
|
|
505
541
|
];
|
|
506
542
|
}
|
|
543
|
+
/** A path as given and as resolved, once each. An unresolvable path (it does
|
|
544
|
+
* not exist yet) is denied as given — nothing can be written through a
|
|
545
|
+
* missing directory anyway. */
|
|
546
|
+
function writeRootSpellings(path) {
|
|
547
|
+
let real = path;
|
|
548
|
+
try {
|
|
549
|
+
real = realpathSync(path);
|
|
550
|
+
}
|
|
551
|
+
catch { }
|
|
552
|
+
return [...new Set([path, real])];
|
|
553
|
+
}
|
|
507
554
|
export function launchEnv(env) {
|
|
508
555
|
const blocked = /^(CLAUDECODE|CLAUDE_CODE_|CODEX_THREAD_ID$|TMUX$|TMUX_PANE$|MCP_)/;
|
|
509
556
|
return Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && !blocked.test(k)));
|
package/dist/help.js
CHANGED
|
@@ -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,31 @@ 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
|
+
}
|
|
60
85
|
/**
|
|
61
86
|
* One shell command line for `run-shell`.
|
|
62
87
|
*
|
|
@@ -181,6 +206,7 @@ export class TmuxHost {
|
|
|
181
206
|
session,
|
|
182
207
|
"status",
|
|
183
208
|
this.status,
|
|
209
|
+
...(opts.title ? titleOptions(session, opts.title) : []),
|
|
184
210
|
], opts.deadline);
|
|
185
211
|
return parseWindowRow(output);
|
|
186
212
|
}
|
|
@@ -202,6 +228,12 @@ export class TmuxHost {
|
|
|
202
228
|
throw e;
|
|
203
229
|
}
|
|
204
230
|
}
|
|
231
|
+
/** Session options accept a window id as their target, so the hostRef the
|
|
232
|
+
* registry already holds is enough. The value is passed as one argv element
|
|
233
|
+
* and tmux stores it verbatim: a leading `-` or a `#{...}` stays text. */
|
|
234
|
+
async setTitle(hostRef, text) {
|
|
235
|
+
await this.call(["set-option", "-t", hostRef, TITLE_OPTION, text]);
|
|
236
|
+
}
|
|
205
237
|
async list() {
|
|
206
238
|
try {
|
|
207
239
|
return parseWindowList(await this.call(["list-windows", "-a", "-F", LIST_FORMAT]));
|
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/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, 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;
|
|
@@ -294,6 +298,9 @@ export class Muster {
|
|
|
294
298
|
// Deciding from `req.host` accepts --idle-timeout under `host = "pty"` in
|
|
295
299
|
// config and then never arms it.
|
|
296
300
|
const lifecycle = resolveLifecycle(effective, this.config, req.kind === "session" && typeof driver?.schedule === "function");
|
|
301
|
+
// Beside the lifecycle and for the same reason: only the selected driver
|
|
302
|
+
// knows whether there will be a window to title.
|
|
303
|
+
const title = resolveTitle(effective, req.kind === "session" && typeof driver?.setTitle === "function");
|
|
297
304
|
return {
|
|
298
305
|
effective,
|
|
299
306
|
terminal,
|
|
@@ -305,6 +312,7 @@ export class Muster {
|
|
|
305
312
|
model,
|
|
306
313
|
driver,
|
|
307
314
|
lifecycle,
|
|
315
|
+
title,
|
|
308
316
|
argv,
|
|
309
317
|
};
|
|
310
318
|
}
|
|
@@ -373,7 +381,14 @@ export class Muster {
|
|
|
373
381
|
const { req, identityName, identityMeta, credential, selectedMcp, agentBase, deadline, } = c;
|
|
374
382
|
let entry = c.entry;
|
|
375
383
|
const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
|
|
376
|
-
|
|
384
|
+
// PWD names the launch directory, not the caller's. The child is spawned
|
|
385
|
+
// in req.cwd, but an inherited PWD still names wherever muster was run
|
|
386
|
+
// from, and `opencode run` prefers PWD over its real cwd: its model's
|
|
387
|
+
// shell then ran in the caller's directory instead of --cwd.
|
|
388
|
+
let runtimeEnv = {
|
|
389
|
+
...mcpEnvironment(prepared.servers, agentBase, this.sourceEnv),
|
|
390
|
+
PWD: req.cwd,
|
|
391
|
+
};
|
|
377
392
|
entry = await this.registry.update(entry.launchId, prepared.summary);
|
|
378
393
|
if (identityName && identityMeta) {
|
|
379
394
|
// The launch id exists now, so the copy can be made and named after it.
|
|
@@ -494,7 +509,7 @@ export class Muster {
|
|
|
494
509
|
* readiness.
|
|
495
510
|
*/
|
|
496
511
|
async startProcess(c) {
|
|
497
|
-
const { req, argv, runtimeEnv, policy, driver, lifecycle, deadline } = c;
|
|
512
|
+
const { req, argv, runtimeEnv, policy, driver, lifecycle, title, deadline, } = c;
|
|
498
513
|
let entry = c.entry;
|
|
499
514
|
if (this.closing)
|
|
500
515
|
throw new Error("Launch cancelled: Muster is closing");
|
|
@@ -510,6 +525,16 @@ export class Muster {
|
|
|
510
525
|
cwd: req.cwd,
|
|
511
526
|
env: runtimeEnv,
|
|
512
527
|
label: req.prompt.slice(0, 48),
|
|
528
|
+
// Provisional until readiness names the session, unless the caller named
|
|
529
|
+
// it: the window must never show a prompt fragment as its title.
|
|
530
|
+
...(driver.setTitle
|
|
531
|
+
? {
|
|
532
|
+
title: {
|
|
533
|
+
text: title ?? provisionalTitle(req),
|
|
534
|
+
fromAgent: this.config.session.title_from_agent,
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
: {}),
|
|
513
538
|
deadline,
|
|
514
539
|
});
|
|
515
540
|
const root = await processRef(launched.pid);
|
|
@@ -587,7 +612,7 @@ export class Muster {
|
|
|
587
612
|
* `finally` has to close it even when this throws.
|
|
588
613
|
*/
|
|
589
614
|
async awaitReadiness(c) {
|
|
590
|
-
const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, startedAt, deadline, } = c;
|
|
615
|
+
const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, title, startedAt, deadline, } = c;
|
|
591
616
|
let entry = c.entry;
|
|
592
617
|
let diagnostic = req.runtime === "claude"
|
|
593
618
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
@@ -668,6 +693,7 @@ export class Muster {
|
|
|
668
693
|
const named = assignNames([
|
|
669
694
|
{ runtime, uuid: identity.id, rawName: metadata.rawName },
|
|
670
695
|
])[0];
|
|
696
|
+
const shownTitle = await this.nameTitle(driver, launched.hostRef, title, `${named.display} · ${req.runtime}`, provisionalTitle(req));
|
|
671
697
|
const peer = {
|
|
672
698
|
kind: "session",
|
|
673
699
|
...permissions,
|
|
@@ -700,6 +726,7 @@ export class Muster {
|
|
|
700
726
|
? { idle_timeout: lifecycle.idleTimeout }
|
|
701
727
|
: {}),
|
|
702
728
|
...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
|
|
729
|
+
...(shownTitle ? { title: shownTitle } : {}),
|
|
703
730
|
attach_hint: driver.attachHint(launched.hostRef),
|
|
704
731
|
};
|
|
705
732
|
if (req.open) {
|
|
@@ -746,7 +773,7 @@ export class Muster {
|
|
|
746
773
|
async launch(input, requester) {
|
|
747
774
|
const { req, policy, level } = await this.authorize(input, requester);
|
|
748
775
|
const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
|
|
749
|
-
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
776
|
+
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, title, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
750
777
|
let argv = plannedArgv;
|
|
751
778
|
let codexPolicy = [];
|
|
752
779
|
const reserved = await this.reserveLaunch({
|
|
@@ -830,6 +857,7 @@ export class Muster {
|
|
|
830
857
|
policy,
|
|
831
858
|
driver,
|
|
832
859
|
lifecycle,
|
|
860
|
+
title,
|
|
833
861
|
deadline,
|
|
834
862
|
});
|
|
835
863
|
if ("outcome" in started)
|
|
@@ -855,6 +883,7 @@ export class Muster {
|
|
|
855
883
|
permissions,
|
|
856
884
|
terminal,
|
|
857
885
|
lifecycle,
|
|
886
|
+
title,
|
|
858
887
|
startedAt,
|
|
859
888
|
deadline,
|
|
860
889
|
});
|
|
@@ -1014,6 +1043,42 @@ export class Muster {
|
|
|
1014
1043
|
throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
|
|
1015
1044
|
return peers[named.indexOf(result.peer)];
|
|
1016
1045
|
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The title a ready session ends up showing. A caller's own title was set at
|
|
1048
|
+
* launch and stays; otherwise the provisional one is replaced with the name
|
|
1049
|
+
* `list` and `stop` accept, which only exists once the agent has registered.
|
|
1050
|
+
* A retitle that fails does not fail the launch — the session is up — but the
|
|
1051
|
+
* record then reports the provisional title, because that is what is shown.
|
|
1052
|
+
*/
|
|
1053
|
+
async nameTitle(driver, hostRef, explicit, named, provisional) {
|
|
1054
|
+
if (!driver?.setTitle)
|
|
1055
|
+
return undefined;
|
|
1056
|
+
if (explicit !== undefined)
|
|
1057
|
+
return explicit;
|
|
1058
|
+
const text = sanitiseTitle(named);
|
|
1059
|
+
return driver.setTitle(hostRef, text).then(() => text, () => provisional);
|
|
1060
|
+
}
|
|
1061
|
+
/** Retitle a running tmux session from outside its agent. */
|
|
1062
|
+
async title(id, raw, requester = LOCAL) {
|
|
1063
|
+
const entry = await this.find(id);
|
|
1064
|
+
assertMayAct(entry, requester);
|
|
1065
|
+
const driver = this.drivers.find((d) => d.id === entry.host);
|
|
1066
|
+
const refusal = entry.kind !== "session"
|
|
1067
|
+
? "this is a task"
|
|
1068
|
+
: entry.status !== "running"
|
|
1069
|
+
? `this session is ${entry.status}`
|
|
1070
|
+
: !driver?.setTitle || !entry.hostRef || !entry.peer
|
|
1071
|
+
? `this session is hosted on ${entry.host ?? "no host"}`
|
|
1072
|
+
: undefined;
|
|
1073
|
+
if (refusal)
|
|
1074
|
+
throw new Error(`title applies to a running tmux session; ${refusal}`);
|
|
1075
|
+
const text = sanitiseTitle(raw);
|
|
1076
|
+
await driver.setTitle(entry.hostRef, text);
|
|
1077
|
+
await this.registry.update(entry.launchId, {
|
|
1078
|
+
peer: { ...entry.peer, title: text },
|
|
1079
|
+
});
|
|
1080
|
+
return { id: entry.id, title: text };
|
|
1081
|
+
}
|
|
1017
1082
|
async stop(id, requester = LOCAL) {
|
|
1018
1083
|
const entry = await this.find(id);
|
|
1019
1084
|
assertMayAct(entry, requester);
|