@brutalsystems/muster 0.7.5 → 0.7.6

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
@@ -72,7 +72,8 @@ emit JSON; `output` prints the captured task output. `stop` also accepts an
72
72
  unambiguous peer name or canonical address, refusing ambiguity with candidates.
73
73
  Use the durable ID to stop a session whose runtime has renamed it.
74
74
 
75
- For readable terminal output, add `--format human` to `run`, `list`, or `stop`:
75
+ For readable terminal output, add `--format human` to `run`, `list`, `stop`, or
76
+ `doctor`:
76
77
 
77
78
  ```sh
78
79
  muster run codex --prompt 'Review this project' --format human
@@ -321,9 +322,35 @@ npm = "@brutalsystems/tincan-opencode"
321
322
  path = "~/.config/opencode/plugin/local-thing.ts"
322
323
  ```
323
324
 
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.
325
+ Prefer `npm` where the plugin is published — but not because it avoids a stale
326
+ copy. Both routes keep one. A copied file goes stale in silence: the package
327
+ updates, the copy does not, and the old code keeps running against whatever it
328
+ was built for. A specifier keeps its copy somewhere less obvious. OpenCode
329
+ resolves the specifier once, writes a generated `package.json` pinning the
330
+ resolved version alongside a lockfile, and caches that under the range it was
331
+ asked for:
332
+
333
+ ```sh
334
+ ~/.cache/opencode/packages/@brutalsystems/tincan-opencode@latest
335
+ ```
336
+
337
+ Publishing a new version does not change that directory and nothing warns, so
338
+ the stale plugin keeps running and keeps reporting. Deleting it forces
339
+ re-resolution on the next launch:
340
+
341
+ ```sh
342
+ rm -rf ~/.cache/opencode/packages/@brutalsystems/tincan-opencode@latest
343
+ ```
344
+
345
+ Muster passes the specifier through untouched, so pin a version where the
346
+ staleness should be deliberate and visible in config rather than implicit in a
347
+ cache. OpenCode keys a pinned specifier to its own directory, leaving the
348
+ `@latest` copy alone:
349
+
350
+ ```toml
351
+ [plugins.tincan]
352
+ npm = "@brutalsystems/tincan-opencode@0.7.1"
353
+ ```
327
354
 
328
355
  A specifier needs the package to declare an entry point OpenCode's loader
329
356
  actually reads. It looks for `exports["./server"]` and falls back to `main`; it
@@ -332,16 +359,45 @@ its `package.json` is read, the session runs — and the plugin never executes,
332
359
  with nothing logged. If a specifier-named plugin appears to do nothing, check
333
360
  the package's entry points before looking anywhere else.
334
361
 
335
- Either way, confirm what a session actually loaded. It records the version, so
336
- compare that against what is installed:
362
+ Either way, confirm what is actually loaded. `muster doctor` checks every
363
+ path-configured plugin against the package it came from:
364
+
365
+ ```sh
366
+ muster doctor --format human
367
+ ```
368
+
369
+ ```
370
+ tincan @brutalsystems/tincan-opencode
371
+ Status: stale — copy predates 0.7.2, published 2026-09-21
372
+ Path: /Users/you/.config/opencode/plugin/tincan.ts
373
+
374
+ 1 stale plugin. Replace path with npm = "@brutalsystems/tincan-opencode" in [plugins.tincan], or re-copy the file.
375
+ ```
376
+
377
+ The copied file carries no version of its own, so the check compares its mtime
378
+ against the registry's publish time for the current release. That means a file
379
+ re-copied recently reads as current even when its contents are old: doctor
380
+ under-reports and never cries wolf. It exits 1 when anything is stale, and an
381
+ unreachable registry is reported as such rather than failing the command.
382
+
383
+ Muster knows the packages for the plugins it ships with. Name anything else,
384
+ or override the default, with `published`:
385
+
386
+ ```toml
387
+ [plugins.mine]
388
+ path = "~/src/mine.ts"
389
+ published = "@acme/mine-opencode"
390
+ ```
391
+
392
+ Plugins named by `npm` are not checked: they keep no copy of their own, and
393
+ clearing OpenCode's cache above is that remedy instead. For what a running
394
+ session loaded rather than what is on disk, the records still carry the version:
337
395
 
338
396
  ```sh
339
397
  grep plugin_version ~/.tincan/peers/opencode/ses_*.json
340
398
  tincan --version
341
399
  ```
342
400
 
343
- Nothing else surfaces the difference.
344
-
345
401
  Registering Muster itself is the one case to think twice about. A definition
346
402
  whose command runs `muster mcp` gives every session that selects it `run`,
347
403
  `list`, `stop` and `output` — launch authority, held by an agent rather than by
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.6",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {