@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 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
- npm install -g @brutalsystems/muster
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, then the built-in default; `off` disables at any level.
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", DEFAULT_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
- denyWrite: readonly ? ["/"] : [],
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; 30m by default, off to disable",
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",
@@ -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 `output` tools exactly as the MCP server
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'. Defaults to 30m. Refused for tasks and for pty or macos-terminal hosts, which do not outlive their parent.",
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
- let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {