@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 +64 -8
- package/dist/doctor.js +77 -0
- package/dist/format.js +28 -0
- package/dist/identity/codex.js +3 -1
- package/dist/muster.js +17 -0
- package/dist/plugins.js +39 -10
- package/package.json +1 -1
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`,
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
336
|
-
|
|
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"
|
package/dist/identity/codex.js
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
38
|
+
export function pluginPath(path) {
|
|
28
39
|
const expanded = path.startsWith("~/")
|
|
29
40
|
? join(homedir(), path.slice(2))
|
|
30
41
|
: path;
|
|
31
|
-
return
|
|
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 —
|