@brutalsystems/muster 0.7.5 → 0.7.7

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
@@ -37,6 +37,15 @@ Updating on disk is not enough; restart the session too.
37
37
 
38
38
  ## Build and run locally
39
39
 
40
+ `npm run check` is the local gate: it builds, runs the suite, then packs and
41
+ verifies the tarball — the same `scripts/check-tarball.mjs` the CI packing job
42
+ runs, so the two cannot disagree about what "verified" means. `npm test` alone
43
+ does not pack anything, so tarball drift is green locally and red on push.
44
+
45
+ It is not everything CI does: CI additionally runs the suite across the Node
46
+ version matrix, and the contract suite runs only on release. Passing `check` is
47
+ necessary rather than sufficient.
48
+
40
49
  Requires Node 22.12+ (tested with 24.16), TypeScript 5, Codex 0.155.1, Claude
41
50
  Code 2.1.267, and OpenCode 1.18.31 or newer. Newer OpenCode releases must retain
42
51
  the CLI and loopback HTTP API used by Muster. Terminal drivers use tmux or
@@ -72,7 +81,8 @@ emit JSON; `output` prints the captured task output. `stop` also accepts an
72
81
  unambiguous peer name or canonical address, refusing ambiguity with candidates.
73
82
  Use the durable ID to stop a session whose runtime has renamed it.
74
83
 
75
- For readable terminal output, add `--format human` to `run`, `list`, or `stop`:
84
+ For readable terminal output, add `--format human` to `run`, `list`, `stop`, or
85
+ `doctor`:
76
86
 
77
87
  ```sh
78
88
  muster run codex --prompt 'Review this project' --format human
@@ -321,9 +331,35 @@ npm = "@brutalsystems/tincan-opencode"
321
331
  path = "~/.config/opencode/plugin/local-thing.ts"
322
332
  ```
323
333
 
324
- Prefer `npm` where the plugin is published. A copied file goes stale in
325
- silence — the package updates, the copy does not, and the old code keeps
326
- running against whatever it was built for. A specifier has no copy to go stale.
334
+ Prefer `npm` where the plugin is published — but not because it avoids a stale
335
+ copy. Both routes keep one. A copied file goes stale in silence: the package
336
+ updates, the copy does not, and the old code keeps running against whatever it
337
+ was built for. A specifier keeps its copy somewhere less obvious. OpenCode
338
+ resolves the specifier once, writes a generated `package.json` pinning the
339
+ resolved version alongside a lockfile, and caches that under the range it was
340
+ asked for:
341
+
342
+ ```sh
343
+ ~/.cache/opencode/packages/@brutalsystems/tincan-opencode@latest
344
+ ```
345
+
346
+ Publishing a new version does not change that directory and nothing warns, so
347
+ the stale plugin keeps running and keeps reporting. Deleting it forces
348
+ re-resolution on the next launch:
349
+
350
+ ```sh
351
+ rm -rf ~/.cache/opencode/packages/@brutalsystems/tincan-opencode@latest
352
+ ```
353
+
354
+ Muster passes the specifier through untouched, so pin a version where the
355
+ staleness should be deliberate and visible in config rather than implicit in a
356
+ cache. OpenCode keys a pinned specifier to its own directory, leaving the
357
+ `@latest` copy alone:
358
+
359
+ ```toml
360
+ [plugins.tincan]
361
+ npm = "@brutalsystems/tincan-opencode@0.7.1"
362
+ ```
327
363
 
328
364
  A specifier needs the package to declare an entry point OpenCode's loader
329
365
  actually reads. It looks for `exports["./server"]` and falls back to `main`; it
@@ -332,16 +368,45 @@ its `package.json` is read, the session runs — and the plugin never executes,
332
368
  with nothing logged. If a specifier-named plugin appears to do nothing, check
333
369
  the package's entry points before looking anywhere else.
334
370
 
335
- Either way, confirm what a session actually loaded. It records the version, so
336
- compare that against what is installed:
371
+ Either way, confirm what is actually loaded. `muster doctor` checks every
372
+ path-configured plugin against the package it came from:
373
+
374
+ ```sh
375
+ muster doctor --format human
376
+ ```
377
+
378
+ ```
379
+ tincan @brutalsystems/tincan-opencode
380
+ Status: stale — copy predates 0.7.2, published 2026-09-21
381
+ Path: /Users/you/.config/opencode/plugin/tincan.ts
382
+
383
+ 1 stale plugin. Replace path with npm = "@brutalsystems/tincan-opencode" in [plugins.tincan], or re-copy the file.
384
+ ```
385
+
386
+ The copied file carries no version of its own, so the check compares its mtime
387
+ against the registry's publish time for the current release. That means a file
388
+ re-copied recently reads as current even when its contents are old: doctor
389
+ under-reports and never cries wolf. It exits 1 when anything is stale, and an
390
+ unreachable registry is reported as such rather than failing the command.
391
+
392
+ Muster knows the packages for the plugins it ships with. Name anything else,
393
+ or override the default, with `published`:
394
+
395
+ ```toml
396
+ [plugins.mine]
397
+ path = "~/src/mine.ts"
398
+ published = "@acme/mine-opencode"
399
+ ```
400
+
401
+ Plugins named by `npm` are not checked: they keep no copy of their own, and
402
+ clearing OpenCode's cache above is that remedy instead. For what a running
403
+ session loaded rather than what is on disk, the records still carry the version:
337
404
 
338
405
  ```sh
339
406
  grep plugin_version ~/.tincan/peers/opencode/ses_*.json
340
407
  tincan --version
341
408
  ```
342
409
 
343
- Nothing else surfaces the difference.
344
-
345
410
  Registering Muster itself is the one case to think twice about. A definition
346
411
  whose command runs `muster mcp` gives every session that selects it `run`,
347
412
  `list`, `stop` and `output` — launch authority, held by an agent rather than by
@@ -391,10 +456,11 @@ built-in list is fixed, so a tool added by a future OpenCode release would not
391
456
  be denied by it; launches that select no MCP server keep the wildcard. With no plugins
392
457
  selected it also uses `--pure` and pins an empty plugin list, so inherited
393
458
  plugins do not enter the child process. Selecting a plugin with `--plugin`
394
- necessarily relaxes that: OpenCode merges project-local `.opencode/plugin`
395
- discovery into any non-empty plugin list, so the child also loads whatever
396
- plugins the target repository ships. Select plugins only for repositories
397
- trusted with that.
459
+ necessarily relaxes that: OpenCode merges project-local plugin discovery into
460
+ any non-empty plugin list, so the child also loads whatever plugins the target
461
+ repository ships. It globs both spellings — `.opencode/plugin` and
462
+ `.opencode/plugins` — so auditing only one of them misses half of what a
463
+ repository can load. Select plugins only for repositories trusted with that.
398
464
  Claude connection specifications are stored with owner-only permissions under
399
465
  `~/.muster/mcp`, removed on stop, task completion, or when `list` observes an ended
400
466
  session. MCP server access is separate from filesystem sandbox permissions: a
package/dist/doctor.js ADDED
@@ -0,0 +1,77 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { pluginPath, publishedPackage } from "./plugins.js";
3
+ export async function checkPlugins(config, lookup) {
4
+ const plugins = [];
5
+ for (const [name, definition] of Object.entries(config.plugins)) {
6
+ // An npm-configured plugin has no copy of its own to go stale; OpenCode's
7
+ // package cache is the thing that pins it, and clearing that is a
8
+ // different remedy. README covers it.
9
+ if ("npm" in definition)
10
+ continue;
11
+ const path = pluginPath(definition.path);
12
+ const health = {
13
+ name,
14
+ path,
15
+ package: publishedPackage(name, definition),
16
+ status: "unchecked",
17
+ };
18
+ let mtime;
19
+ try {
20
+ mtime = (await stat(path)).mtime;
21
+ }
22
+ catch {
23
+ plugins.push({ ...health, status: "missing" });
24
+ continue;
25
+ }
26
+ health.local_mtime = mtime.toISOString();
27
+ if (!health.package) {
28
+ plugins.push(health);
29
+ continue;
30
+ }
31
+ let latest;
32
+ try {
33
+ latest = await lookup(health.package);
34
+ }
35
+ catch {
36
+ // An unreachable registry proves nothing about the copy. Reporting it as
37
+ // stale would cry wolf on a plane; reporting it as current would hide a
38
+ // real one.
39
+ plugins.push({ ...health, status: "unreachable" });
40
+ continue;
41
+ }
42
+ if (!latest) {
43
+ plugins.push({ ...health, status: "unpublished" });
44
+ continue;
45
+ }
46
+ plugins.push({
47
+ ...health,
48
+ published_version: latest.version,
49
+ published_at: latest.published,
50
+ status: mtime < new Date(latest.published) ? "stale" : "current",
51
+ });
52
+ }
53
+ return {
54
+ kind: "doctor",
55
+ plugins,
56
+ stale: plugins.filter((p) => p.status === "stale").length,
57
+ };
58
+ }
59
+ /**
60
+ * The full packument rather than the abbreviated one: `time` carries the
61
+ * publish dates and the abbreviated form omits it.
62
+ */
63
+ export function npmRegistry(timeoutMs = 3000) {
64
+ return async (pkg) => {
65
+ const response = await fetch("https://registry.npmjs.org/" + pkg.replace("/", "%2F"), { signal: AbortSignal.timeout(timeoutMs) });
66
+ if (response.status === 404)
67
+ return null;
68
+ if (!response.ok)
69
+ throw new Error(`registry returned ${response.status} for ${pkg}`);
70
+ const body = (await response.json());
71
+ const version = body["dist-tags"]?.latest;
72
+ const published = version ? body.time?.[version] : undefined;
73
+ if (!version || !published)
74
+ throw new Error(`registry gave no current release for ${pkg}`);
75
+ return { version, published };
76
+ };
77
+ }
package/dist/format.js CHANGED
@@ -65,7 +65,35 @@ function recordHuman(record, color) {
65
65
  lines.push(field("Stop", paint(`muster stop ${argument(clean(id))}`, 36)));
66
66
  return lines.join("\n");
67
67
  }
68
+ function doctorHuman(report, color) {
69
+ const paint = (value, code) => color ? `\u001b[${code}m${value}\u001b[0m` : value;
70
+ const field = (label, value) => ` ${paint(label + ":", 90)} ${value}`;
71
+ if (!report.plugins.length)
72
+ return "No path-configured plugins to check.\n";
73
+ const lines = [];
74
+ for (const plugin of report.plugins) {
75
+ const released = plugin.published_version ?? "?";
76
+ const when = (plugin.published_at ?? "").slice(0, 10);
77
+ const detail = {
78
+ stale: `copy predates ${released}, published ${when}`,
79
+ current: `copy is newer than ${released}`,
80
+ unpublished: "no such package on the registry",
81
+ unchecked: 'no package known; add published = "@scope/name"',
82
+ unreachable: "registry lookup failed; nothing proven either way",
83
+ missing: "no file at that path",
84
+ };
85
+ const code = plugin.status === "stale" ? 31 : plugin.status === "current" ? 32 : 33;
86
+ lines.push(`${paint(clean(plugin.name), 1)} ${clean(plugin.package ?? "-")}`, field("Status", `${paint(plugin.status, code)} \u2014 ${detail[plugin.status] ?? ""}`), field("Path", clean(plugin.path)));
87
+ }
88
+ const stale = report.plugins.filter((p) => p.status === "stale");
89
+ lines.push("", stale.length
90
+ ? `${stale.length} stale plugin${stale.length > 1 ? "s" : ""}. Replace path with npm = "${stale[0].package}" in [plugins.${stale[0].name}], or re-copy the file.`
91
+ : "No stale plugins.");
92
+ return lines.join("\n") + "\n";
93
+ }
68
94
  export function formatHuman(value, color = false) {
95
+ if (!Array.isArray(value) && value.kind === "doctor")
96
+ return doctorHuman(value, color);
69
97
  if (Array.isArray(value))
70
98
  return value.length
71
99
  ? value.map((record) => recordHuman(record, color)).join("\n\n") + "\n"
@@ -6,7 +6,6 @@ import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { command, descendants } from "./processes.js";
8
8
  export async function resolveCodex(root, env, deadline, isDirectCli) {
9
- const family = new Set(await descendants(root, deadline));
10
9
  let dir = join(env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex"), "thread-writer-locks");
11
10
  try {
12
11
  dir = await realpath(dir);
@@ -35,6 +34,9 @@ export async function resolveCodex(root, env, deadline, isDirectCli) {
35
34
  catch (e) {
36
35
  out = e.stdout ?? "";
37
36
  }
37
+ // Snapshot the family after lsof, not before: a lock holder that spawns
38
+ // during discovery is absent from an earlier snapshot, costing a whole pass.
39
+ const family = new Set(await descendants(root, deadline));
38
40
  let pid = 0;
39
41
  const matches = new Map();
40
42
  for (const line of out.split("\n")) {
package/dist/muster.js CHANGED
@@ -9,6 +9,8 @@ import { Muster } from "./run.js";
9
9
  import { runSchema } from "./guard.js";
10
10
  import { delay } from "./identity/processes.js";
11
11
  import { formatHuman, humanColorEnabled } from "./format.js";
12
+ import { checkPlugins, npmRegistry } from "./doctor.js";
13
+ import { loadConfig } from "./config.js";
12
14
  const listSchema = z
13
15
  .object({ kind: z.enum(["session", "task"]).optional() })
14
16
  .strict();
@@ -19,6 +21,7 @@ const help = `Usage:
19
21
  muster list [--kind session|task] [--format json|human]
20
22
  muster stop <id> [--format json|human]
21
23
  muster output <id>
24
+ muster doctor [--format json|human]
22
25
  muster mcp
23
26
  muster --version
24
27
 
@@ -36,6 +39,12 @@ which also admits any plugin the target repository ships.
36
39
  the child, so launches do not depend on the user's own OpenCode config.
37
40
  run, list and stop default to JSON. output prints raw task text.
38
41
 
42
+ doctor checks path-configured plugins against the packages they came from: a
43
+ copied file goes stale silently while the package moves on. It reads the copy's
44
+ mtime, so a re-copied old file reads as current. Exits 1 when any plugin is
45
+ stale. Plugins named by npm are not checked — OpenCode caches those instead,
46
+ and the README covers clearing that cache.
47
+
39
48
  pty launches print their record and keep this process running. Ctrl-C stops the
40
49
  owned pty session. tmux launches survive this process exiting.
41
50
  MCP: install deliberately in the one authorized session, never at user scope.
@@ -246,6 +255,14 @@ async function main() {
246
255
  throw new Error("list takes no positional arguments");
247
256
  print(await muster.list(listSchema.parse(values).kind));
248
257
  }
258
+ else if (command === "doctor") {
259
+ if (positionals.length || Object.keys(values).length)
260
+ throw new Error("doctor takes no arguments");
261
+ const report = await checkPlugins(await loadConfig(), npmRegistry());
262
+ print(report);
263
+ if (report.stale)
264
+ process.exitCode = 1;
265
+ }
249
266
  else if (command === "stop" || command === "output") {
250
267
  if (positionals.length !== 1 || Object.keys(values).length)
251
268
  throw new Error(`${command} requires exactly one id`);
package/dist/plugins.js CHANGED
@@ -9,26 +9,55 @@ export const pluginName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
9
9
  * file URL, while a specifier must be passed through untouched so OpenCode can
10
10
  * resolve and install it.
11
11
  *
12
- * The specifier form exists because a copied-on-disk plugin goes stale
13
- * silently: an old copy keeps running against whatever it was built for, and
14
- * an unloaded or outdated plugin is indistinguishable from no plugin at all.
12
+ * Neither form escapes staleness. A copied file goes stale on disk; a specifier
13
+ * goes stale in OpenCode's package cache, which resolves the range once and
14
+ * keeps serving that copy. A version can be pinned in the specifier where the
15
+ * staleness should be deliberate, since it is passed through untouched. Either
16
+ * way, an outdated plugin is indistinguishable from no plugin at all.
15
17
  */
16
- export const pluginSchema = z
17
- .union([
18
- z.object({ path: z.string().min(1) }).strict(),
18
+ export const pluginSchema = z.union([
19
+ z
20
+ .object({
21
+ path: z.string().min(1),
22
+ // Names the published package a copied file came from, so `muster doctor`
23
+ // can check it. Meaningless alongside `npm`, which strict() rejects.
24
+ published: z.string().min(1).optional(),
25
+ })
26
+ .strict(),
19
27
  z.object({ npm: z.string().min(1) }).strict(),
20
- ])
21
- .refine((p) => "path" in p || "npm" in p, "a plugin needs either path or npm");
28
+ ],
29
+ // The union rejects a plugin carrying neither key before a refinement could
30
+ // run, so the message belongs here: attached to the union it never fired,
31
+ // and the operator saw "Invalid input" instead.
32
+ { errorMap: () => ({ message: "a plugin needs either path or npm" }) });
22
33
  /**
23
34
  * Operator definitions are written as ordinary paths, so `~` and relative
24
35
  * paths resolve here rather than in the launch path, where a wrong answer
25
36
  * would be harder to see.
26
37
  */
27
- function pluginUrl(path) {
38
+ export function pluginPath(path) {
28
39
  const expanded = path.startsWith("~/")
29
40
  ? join(homedir(), path.slice(2))
30
41
  : path;
31
- return pathToFileURL(resolve(expanded)).href;
42
+ return resolve(expanded);
43
+ }
44
+ function pluginUrl(path) {
45
+ return pathToFileURL(pluginPath(path)).href;
46
+ }
47
+ /**
48
+ * Packages Muster already knows are published, so a path-configured copy can be
49
+ * checked without the operator naming it. Keyed by the plugin's config name; a
50
+ * section named anything else needs an explicit `published`.
51
+ */
52
+ const publishedPlugins = {
53
+ tincan: "@brutalsystems/tincan-opencode",
54
+ birddog: "@brutalsystems/birddog-opencode",
55
+ };
56
+ /** The package a path-configured plugin came from, or null if unknowable. */
57
+ export function publishedPackage(name, plugin) {
58
+ if ("npm" in plugin)
59
+ return null;
60
+ return plugin.published ?? publishedPlugins[name] ?? null;
32
61
  }
33
62
  export function selectPlugins(req, config) {
34
63
  // Personal defaults apply to sessions, not unattended one-shot tasks —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,6 +12,7 @@
12
12
  "scripts": {
13
13
  "build": "tsc && node scripts/prepare-bin.mjs",
14
14
  "test": "vitest run",
15
+ "check": "npm test && node scripts/check-tarball.mjs",
15
16
  "test:contract": "MUSTER_CONTRACT=1 vitest run test/contract.test.ts",
16
17
  "postinstall": "node scripts/prepare-pty.mjs",
17
18
  "pretest": "npm run build",