@brutalsystems/muster 1.1.1 → 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/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 +63 -5
- package/package.json +1 -1
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
|
}
|
|
@@ -501,7 +509,7 @@ export class Muster {
|
|
|
501
509
|
* readiness.
|
|
502
510
|
*/
|
|
503
511
|
async startProcess(c) {
|
|
504
|
-
const { req, argv, runtimeEnv, policy, driver, lifecycle, deadline } = c;
|
|
512
|
+
const { req, argv, runtimeEnv, policy, driver, lifecycle, title, deadline, } = c;
|
|
505
513
|
let entry = c.entry;
|
|
506
514
|
if (this.closing)
|
|
507
515
|
throw new Error("Launch cancelled: Muster is closing");
|
|
@@ -517,6 +525,16 @@ export class Muster {
|
|
|
517
525
|
cwd: req.cwd,
|
|
518
526
|
env: runtimeEnv,
|
|
519
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
|
+
: {}),
|
|
520
538
|
deadline,
|
|
521
539
|
});
|
|
522
540
|
const root = await processRef(launched.pid);
|
|
@@ -594,7 +612,7 @@ export class Muster {
|
|
|
594
612
|
* `finally` has to close it even when this throws.
|
|
595
613
|
*/
|
|
596
614
|
async awaitReadiness(c) {
|
|
597
|
-
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;
|
|
598
616
|
let entry = c.entry;
|
|
599
617
|
let diagnostic = req.runtime === "claude"
|
|
600
618
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
@@ -675,6 +693,7 @@ export class Muster {
|
|
|
675
693
|
const named = assignNames([
|
|
676
694
|
{ runtime, uuid: identity.id, rawName: metadata.rawName },
|
|
677
695
|
])[0];
|
|
696
|
+
const shownTitle = await this.nameTitle(driver, launched.hostRef, title, `${named.display} · ${req.runtime}`, provisionalTitle(req));
|
|
678
697
|
const peer = {
|
|
679
698
|
kind: "session",
|
|
680
699
|
...permissions,
|
|
@@ -707,6 +726,7 @@ export class Muster {
|
|
|
707
726
|
? { idle_timeout: lifecycle.idleTimeout }
|
|
708
727
|
: {}),
|
|
709
728
|
...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
|
|
729
|
+
...(shownTitle ? { title: shownTitle } : {}),
|
|
710
730
|
attach_hint: driver.attachHint(launched.hostRef),
|
|
711
731
|
};
|
|
712
732
|
if (req.open) {
|
|
@@ -753,7 +773,7 @@ export class Muster {
|
|
|
753
773
|
async launch(input, requester) {
|
|
754
774
|
const { req, policy, level } = await this.authorize(input, requester);
|
|
755
775
|
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);
|
|
776
|
+
const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, title, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
|
|
757
777
|
let argv = plannedArgv;
|
|
758
778
|
let codexPolicy = [];
|
|
759
779
|
const reserved = await this.reserveLaunch({
|
|
@@ -837,6 +857,7 @@ export class Muster {
|
|
|
837
857
|
policy,
|
|
838
858
|
driver,
|
|
839
859
|
lifecycle,
|
|
860
|
+
title,
|
|
840
861
|
deadline,
|
|
841
862
|
});
|
|
842
863
|
if ("outcome" in started)
|
|
@@ -862,6 +883,7 @@ export class Muster {
|
|
|
862
883
|
permissions,
|
|
863
884
|
terminal,
|
|
864
885
|
lifecycle,
|
|
886
|
+
title,
|
|
865
887
|
startedAt,
|
|
866
888
|
deadline,
|
|
867
889
|
});
|
|
@@ -1021,6 +1043,42 @@ export class Muster {
|
|
|
1021
1043
|
throw new Error(`${result.reason}: ${result.candidates.join(", ")}`);
|
|
1022
1044
|
return peers[named.indexOf(result.peer)];
|
|
1023
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
|
+
}
|
|
1024
1082
|
async stop(id, requester = LOCAL) {
|
|
1025
1083
|
const entry = await this.find(id);
|
|
1026
1084
|
assertMayAct(entry, requester);
|