@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 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; `open` removes the sandbox and needs
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, then the built-in default; `off` disables at any level.
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", DEFAULT_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
- "--sandbox",
418
- policy.sandbox === "full-access"
419
- ? "danger-full-access"
420
- : policy.sandbox,
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
- denyWrite: readonly ? ["/"] : [],
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; 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,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
- export async function resolveOpenCode(rootPid, serverUrl, cwd, createdAfter, deadline) {
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]) => isRecord(v) && !PASS_THROUGH.includes(k)
83
- ? [
84
- k,
85
- Object.fromEntries(Object.entries(v).filter(([field]) => METADATA_RECORD_KEEP.includes(field))),
86
- ]
87
- : [k, v]));
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 `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
@@ -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);
@@ -41,7 +41,7 @@ async function finish(code, signal, error) {
41
41
  exit_code: code,
42
42
  signal,
43
43
  error,
44
- });
44
+ }, spec.logMode);
45
45
  }
46
46
  finally {
47
47
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {