@brutalsystems/muster 0.7.2 → 0.7.4
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/CONTRACT_PROVENANCE.md +99 -0
- package/README.md +65 -3
- package/dist/codex-policy.js +30 -8
- package/dist/plugins.js +23 -8
- package/dist/run.js +1 -1
- package/package.json +1 -1
package/CONTRACT_PROVENANCE.md
CHANGED
|
@@ -79,6 +79,105 @@ the same codename remediation via a history rewrite (`git filter-repo
|
|
|
79
79
|
whose content or ancestry it touched. This is a historical acquisition
|
|
80
80
|
record, not a claim that the commit is still reachable.
|
|
81
81
|
|
|
82
|
+
## OpenCode v2 prompt endpoint: closed as routed around, not diagnosed
|
|
83
|
+
|
|
84
|
+
Status as of 2026-09-20. Recorded because the underlying behaviour is not
|
|
85
|
+
fixed, only avoided, and because the investigation produced three confident
|
|
86
|
+
wrong answers before a right one. Deliberately not filed upstream.
|
|
87
|
+
|
|
88
|
+
**Reproduced, with neither Muster nor Tin Can involved.** POST to the v2
|
|
89
|
+
`/api/session/{id}/prompt` of a TUI-hosted OpenCode 1.18.31 session returns 200
|
|
90
|
+
with an `admittedSeq`; a drain fires about 68ms later; the turn dies with
|
|
91
|
+
`ModelUnavailableError` naming the session's own model. The TUI path resolves
|
|
92
|
+
that same model string, in the same process, seconds either side:
|
|
93
|
+
|
|
94
|
+
20:42:30 stream providerID=fireworks-ai modelID=...qwen3p8-max (TUI turn)
|
|
95
|
+
20:42:54 ERROR Failed to drain Session
|
|
96
|
+
ModelUnavailableError: fireworks-ai/...qwen3p8-max (delivered)
|
|
97
|
+
|
|
98
|
+
Observed across 14 distinct TUI-hosted sessions, 20 failures, two unrelated
|
|
99
|
+
providers, no successes. An `opencode serve` session drains and resolves its
|
|
100
|
+
model normally, failing only on an unrelated HTTP 401 — a strictly later stage.
|
|
101
|
+
The v1 `/session/{id}/prompt_async` route runs turns on the same TUI sessions.
|
|
102
|
+
|
|
103
|
+
**Not established.** That the drain path "cannot resolve the model" is a
|
|
104
|
+
reading of an error string, not a claim from source. Nobody has read OpenCode's
|
|
105
|
+
drain path the way its scheduling path was read. Two loose ends would weaken a
|
|
106
|
+
report: a session that suffered a v2 failure appeared to stop running anything
|
|
107
|
+
afterwards, including v1 calls that had worked moments earlier — one
|
|
108
|
+
observation, never isolated, and if v2 failure corrupts session state then the
|
|
109
|
+
20 failures are fewer independent events than the count suggests. And the
|
|
110
|
+
`serve` control differed in credentials as well as host type.
|
|
111
|
+
|
|
112
|
+
**Theories that were asserted and disproved**, kept because each looked
|
|
113
|
+
convincing: that OpenCode never scheduled the turn (the drain fires, visibly);
|
|
114
|
+
that the model was cold (TUI success and drain failure alternate 17-24s apart
|
|
115
|
+
on one model); that Muster-supplied inline providers were invisible to the
|
|
116
|
+
drain (a globally authenticated provider fails identically). Each was killed by
|
|
117
|
+
a control that could have been run first.
|
|
118
|
+
|
|
119
|
+
**Why it is closed.** Tin Can 0.6.0 delivers over v1, which works, so nothing
|
|
120
|
+
in Muster or Tin Can depends on the v2 path. A report resting on an inferred
|
|
121
|
+
mechanism is one a maintainer would reasonably bounce, and the cost of
|
|
122
|
+
diagnosing it properly is not worth paying for a route neither project uses.
|
|
123
|
+
If someone wants to reopen it, the missing step is reading the drain's
|
|
124
|
+
model-resolution path in OpenCode 1.18.31.
|
|
125
|
+
|
|
126
|
+
## Contract baseline moved to 0.6.4 — 2026-09-20
|
|
127
|
+
|
|
128
|
+
Re-pinned from `0.6.0`. The suite was run against a real installed
|
|
129
|
+
`@brutalsystems/tincan@0.6.4` and passed all four cases; 0.6.4's
|
|
130
|
+
`canonical-id.json` was compared case by case with Muster's frozen copy and
|
|
131
|
+
every pre-existing case is present with an unchanged expectation, with the same
|
|
132
|
+
four additive opencode-specific cases introduced in 0.5.7. Naming is untouched.
|
|
133
|
+
|
|
134
|
+
The intervening releases are about how the OpenCode plugin is delivered rather
|
|
135
|
+
than about the address contract: 0.6.1 published the plugin as its own package,
|
|
136
|
+
0.6.2 corrected its entry points so OpenCode's loader can actually execute it,
|
|
137
|
+
and 0.6.4 fixed the publish ordering described below.
|
|
138
|
+
|
|
139
|
+
**Do not pin `0.6.3`.** Its publish run put the server on the registry and then
|
|
140
|
+
failed a post-publish verification with `ETARGET` — `npm view` had already
|
|
141
|
+
answered for the version while `npm pack` still 404'd — and the plugin publish
|
|
142
|
+
behind that check was skipped. So `@brutalsystems/tincan@0.6.3` exists and
|
|
143
|
+
`@brutalsystems/tincan-opencode@0.6.3` does not. The tag was deliberately not
|
|
144
|
+
moved, because the 0.6.3 server build is published with provenance pointing at
|
|
145
|
+
that commit. 0.6.4 publishes both halves before any post-publish check.
|
|
146
|
+
|
|
147
|
+
Muster's own pipeline already tolerates that lag: `Confirm published` polls for
|
|
148
|
+
five minutes, and `Verify the published artifact` retries and then warns rather
|
|
149
|
+
than failing when the tarball is not yet downloadable, failing only on a real
|
|
150
|
+
file-list difference.
|
|
151
|
+
|
|
152
|
+
## Contract baseline moved to 0.6.0 — 2026-09-20
|
|
153
|
+
|
|
154
|
+
Tin Can 0.6.0 changes the endpoint it delivers to on the opencode leg, from the
|
|
155
|
+
v2 `/api/session/{id}/prompt` to the v1 `/session/{id}/prompt_async`, and the
|
|
156
|
+
change resolves the failure described in the 0.5.9 entry below. It is a minor
|
|
157
|
+
rather than a patch because `urgent` was removed as a capability: the v1 route
|
|
158
|
+
has no equivalent of v2's steer/queue distinction, so rather than accept it and
|
|
159
|
+
ignore it, Tin Can now reports urgent as unsupported on every runtime. Muster
|
|
160
|
+
never referenced that capability, so nothing here changes.
|
|
161
|
+
|
|
162
|
+
Muster's contract suite was run against a real installed
|
|
163
|
+
`@brutalsystems/tincan@0.6.0` and passed all four cases. Naming is untouched:
|
|
164
|
+
0.6.0's `canonical-id.json` was compared case by case with Muster's frozen copy
|
|
165
|
+
and every pre-existing case is present with an unchanged expectation, with the
|
|
166
|
+
same four additive opencode-specific cases introduced in 0.5.7.
|
|
167
|
+
|
|
168
|
+
Verified end to end rather than by the suite alone: a Muster-launched OpenCode
|
|
169
|
+
session in a terminal received a Tin Can message, ran the turn, and replied to
|
|
170
|
+
the sending Claude Code session. The OpenCode log for that session shows a
|
|
171
|
+
started loop and a resolved model with no `Failed to drain` line, which is the
|
|
172
|
+
signature the 0.5.9 entry documents as the failure.
|
|
173
|
+
|
|
174
|
+
One operational trap worth recording, because it makes a working setup look
|
|
175
|
+
broken: the OpenCode plugin is a **copy on disk** under
|
|
176
|
+
`~/.config/opencode/plugin/`. Updating the npm package does not update it, and
|
|
177
|
+
a stale plugin keeps posting to the old v2 route. The peer registry records
|
|
178
|
+
`plugin_version`; confirm it matches the installed package before trusting a
|
|
179
|
+
delivery result.
|
|
180
|
+
|
|
82
181
|
## Contract baseline moved to 0.5.9 — 2026-09-20
|
|
83
182
|
|
|
84
183
|
Re-pinned from `0.5.7`, skipping a separate `0.5.8` pass, because the `notice`
|
package/README.md
CHANGED
|
@@ -309,6 +309,50 @@ muster run opencode --mcp tincan --permissions auto --sandbox workspace-write \
|
|
|
309
309
|
muster run codex --no-mcp --prompt 'Work without external tools'
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
+
A plugin is named either by `path` or by an `npm` specifier, never both. A path
|
|
313
|
+
becomes a file URL; a specifier is passed through untouched for OpenCode to
|
|
314
|
+
resolve and install:
|
|
315
|
+
|
|
316
|
+
```toml
|
|
317
|
+
[plugins.tincan]
|
|
318
|
+
npm = "@brutalsystems/tincan-opencode"
|
|
319
|
+
|
|
320
|
+
[plugins.local-thing]
|
|
321
|
+
path = "~/.config/opencode/plugin/local-thing.ts"
|
|
322
|
+
```
|
|
323
|
+
|
|
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.
|
|
327
|
+
|
|
328
|
+
A specifier needs the package to declare an entry point OpenCode's loader
|
|
329
|
+
actually reads. It looks for `exports["./server"]` and falls back to `main`; it
|
|
330
|
+
does not read `exports["."]`. A package declaring only the latter is fetched,
|
|
331
|
+
its `package.json` is read, the session runs — and the plugin never executes,
|
|
332
|
+
with nothing logged. If a specifier-named plugin appears to do nothing, check
|
|
333
|
+
the package's entry points before looking anywhere else.
|
|
334
|
+
|
|
335
|
+
Either way, confirm what a session actually loaded. It records the version, so
|
|
336
|
+
compare that against what is installed:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
grep plugin_version ~/.tincan/peers/opencode/ses_*.json
|
|
340
|
+
tincan --version
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Nothing else surfaces the difference.
|
|
344
|
+
|
|
345
|
+
Registering Muster itself is the one case to think twice about. A definition
|
|
346
|
+
whose command runs `muster mcp` gives every session that selects it `run`,
|
|
347
|
+
`list`, `stop` and `output` — launch authority, held by an agent rather than by
|
|
348
|
+
you. Put it in `default_mcp` and every session muster launches can launch more,
|
|
349
|
+
which is the propagation the child restrictions above exist to prevent;
|
|
350
|
+
`max_concurrent` caps simultaneous launches but does not stop a tree forming.
|
|
351
|
+
Nothing in Muster refuses this, so the restraint has to be yours. If you want
|
|
352
|
+
an orchestrating agent, give exactly that one session an explicit
|
|
353
|
+
`--mcp muster` and leave `default_mcp` alone. See "MCP installation" below for
|
|
354
|
+
the same stance applied to a runtime's own shared configuration.
|
|
355
|
+
|
|
312
356
|
`--mcp NAME` is repeatable and **replaces** personal defaults. `--no-mcp` clears
|
|
313
357
|
them and cannot be combined with `--mcp`. MCP `run` takes `mcp: ["tincan"]`;
|
|
314
358
|
`mcp: []` disables all servers. Omitted selection uses `default_mcp` for sessions.
|
|
@@ -380,6 +424,9 @@ claude --mcp-config '{"mcpServers":{"muster":{"command":"node","args":["/absolut
|
|
|
380
424
|
|
|
381
425
|
Do not add Muster to `~/.codex/config.toml`, Claude's user-scope MCP registry,
|
|
382
426
|
or another shared configuration that grants launch authority to every agent.
|
|
427
|
+
The same applies to Muster's own `[mcp_servers]`: a definition that runs
|
|
428
|
+
`muster mcp`, selected by `default_mcp`, grants that authority to every session
|
|
429
|
+
Muster launches.
|
|
383
430
|
|
|
384
431
|
## Tin Can compatibility
|
|
385
432
|
|
|
@@ -390,6 +437,21 @@ headers and the original MIT license in `TINCAN_LICENSE`.
|
|
|
390
437
|
Addresses belong to running sessions and can expire or collide. Store
|
|
391
438
|
`thread_id` / `session_id`, and re-resolve through Tin Can's `peers` before
|
|
392
439
|
sending instead of caching a launch address. Canonical IDs are not unique keys.
|
|
440
|
+
|
|
441
|
+
One OpenCode behaviour is worth knowing if you deliver to a session yourself
|
|
442
|
+
rather than through Tin Can. A message posted to the v2
|
|
443
|
+
`/api/session/{id}/prompt` of a **TUI-hosted** session is admitted with a 200
|
|
444
|
+
and an `admittedSeq`, schedules a turn, and then dies with
|
|
445
|
+
`ModelUnavailableError` naming the session's own model — while the TUI resolves
|
|
446
|
+
that same model in the same process. A session hosted by `opencode serve` does
|
|
447
|
+
not do this, and the v1 `/session/{id}/prompt_async` route does not either.
|
|
448
|
+
Tin Can 0.6.0 delivers over v1, so nothing here depends on the v2 path.
|
|
449
|
+
|
|
450
|
+
The failure is silent from the caller's side: a success response, nothing
|
|
451
|
+
written into the session, and the only trace a line in OpenCode's own log. The
|
|
452
|
+
investigation, what it did and did not establish, and why it was not filed
|
|
453
|
+
upstream are recorded in
|
|
454
|
+
[CONTRACT_PROVENANCE.md](./CONTRACT_PROVENANCE.md).
|
|
393
455
|
The frozen cases intentionally preserve Tin Can's known naming defects.
|
|
394
456
|
|
|
395
457
|
Codex `idle` means reachable and not known to be busy, not guaranteed free.
|
|
@@ -413,18 +475,18 @@ The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
|
|
|
413
475
|
real file locks in the fake Codex executable. It uses isolated runtime homes and
|
|
414
476
|
no model APIs. It skips the four explicitly invoked compatibility cases.
|
|
415
477
|
|
|
416
|
-
The verified contract baseline is **Tin Can 0.
|
|
478
|
+
The verified contract baseline is **Tin Can 0.6.4**. The suite spawns an installed binary as an MCP
|
|
417
479
|
subprocess, compares durable IDs, and verifies delivery to fake runtimes through
|
|
418
480
|
both terminal hosts. A missing binary or missing durable field fails the test.
|
|
419
481
|
For reproducible verification, install that exact release outside Muster:
|
|
420
482
|
|
|
421
483
|
```sh
|
|
422
484
|
contract_dir=$(mktemp -d)
|
|
423
|
-
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.
|
|
485
|
+
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.6.4
|
|
424
486
|
MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
|
|
425
487
|
```
|
|
426
488
|
|
|
427
|
-
Verification uses the published 0.
|
|
489
|
+
Verification uses the published 0.6.4 release installed in an isolated
|
|
428
490
|
temporary directory. No Tin Can source edits are made.
|
|
429
491
|
Fixture version metadata alone does not indicate address-format drift: compare
|
|
430
492
|
the case arrays. Those arrays cover naming only, not peer-list membership or
|
package/dist/codex-policy.js
CHANGED
|
@@ -3,7 +3,16 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import TOML from "@iarna/toml";
|
|
4
4
|
import { codexMcpConfig } from "./mcp.js";
|
|
5
5
|
const exec = promisify(execFile);
|
|
6
|
-
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = []
|
|
6
|
+
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, selected = [],
|
|
7
|
+
/**
|
|
8
|
+
* True when the launch itself will pass `--ignore-user-config`, as task
|
|
9
|
+
* launches do. Enumeration has to agree with the launch: a server disabled
|
|
10
|
+
* here that the launch never loads is not merely redundant, it is harmful.
|
|
11
|
+
* `-c mcp_servers.<name>.enabled=false` CREATES the key when nothing else
|
|
12
|
+
* defines it, leaving an entry with no command and no url, and Codex then
|
|
13
|
+
* refuses the whole config with "invalid transport".
|
|
14
|
+
*/
|
|
15
|
+
ignoreUserConfig = false) {
|
|
7
16
|
const args = [
|
|
8
17
|
"--disable",
|
|
9
18
|
"hooks",
|
|
@@ -37,12 +46,18 @@ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, se
|
|
|
37
46
|
}
|
|
38
47
|
const inherited = await list(args);
|
|
39
48
|
const usedNames = new Set(inherited.map((s) => s.name));
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
// Nothing to disable when the launch ignores the config the servers come
|
|
50
|
+
// from: they are already absent. Disabling one anyway would CREATE the key
|
|
51
|
+
// with neither command nor url, and Codex refuses the whole config with
|
|
52
|
+
// "invalid transport". The names are still reserved below, so a requested
|
|
53
|
+
// server never collides with one the user happens to have defined.
|
|
54
|
+
if (!ignoreUserConfig)
|
|
55
|
+
for (const server of inherited) {
|
|
56
|
+
if (typeof server.name !== "string" ||
|
|
57
|
+
!/^[a-zA-Z0-9_-]+$/.test(server.name))
|
|
58
|
+
throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
|
|
59
|
+
args.push("-c", `mcp_servers.${server.name}.enabled=false`);
|
|
60
|
+
}
|
|
46
61
|
const expected = [];
|
|
47
62
|
for (const server of selected) {
|
|
48
63
|
let name = server.name;
|
|
@@ -60,7 +75,14 @@ export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000, se
|
|
|
60
75
|
.map((s) => s.name)
|
|
61
76
|
.sort();
|
|
62
77
|
expected.sort();
|
|
63
|
-
|
|
78
|
+
// `codex mcp list` has no --ignore-user-config of its own, so when the launch
|
|
79
|
+
// ignores the user config the listing still reports its servers. They cannot
|
|
80
|
+
// reach the child — the flag is the guarantee — so the check is that every
|
|
81
|
+
// requested server is present, rather than that nothing else is.
|
|
82
|
+
const isolated = ignoreUserConfig
|
|
83
|
+
? expected.every((name) => enabled.includes(name))
|
|
84
|
+
: JSON.stringify(enabled) === JSON.stringify(expected);
|
|
85
|
+
if (!isolated)
|
|
64
86
|
throw new Error("Inherited Codex MCP servers remain enabled or requested servers are missing; refusing launch");
|
|
65
87
|
return args;
|
|
66
88
|
}
|
package/dist/plugins.js
CHANGED
|
@@ -3,15 +3,26 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
export const pluginName = z.string().regex(/^[a-zA-Z0-9_-]+$/);
|
|
6
|
+
/**
|
|
7
|
+
* A plugin is named either by a path on disk or by an npm specifier, never
|
|
8
|
+
* both. OpenCode accepts either in its `plugin` array; a path has to become a
|
|
9
|
+
* file URL, while a specifier must be passed through untouched so OpenCode can
|
|
10
|
+
* resolve and install it.
|
|
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.
|
|
15
|
+
*/
|
|
6
16
|
export const pluginSchema = z
|
|
7
|
-
.
|
|
8
|
-
path: z.string().min(1),
|
|
9
|
-
})
|
|
10
|
-
|
|
17
|
+
.union([
|
|
18
|
+
z.object({ path: z.string().min(1) }).strict(),
|
|
19
|
+
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");
|
|
11
22
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
23
|
+
* Operator definitions are written as ordinary paths, so `~` and relative
|
|
24
|
+
* paths resolve here rather than in the launch path, where a wrong answer
|
|
25
|
+
* would be harder to see.
|
|
15
26
|
*/
|
|
16
27
|
function pluginUrl(path) {
|
|
17
28
|
const expanded = path.startsWith("~/")
|
|
@@ -29,6 +40,10 @@ export function selectPlugins(req, config) {
|
|
|
29
40
|
: undefined;
|
|
30
41
|
if (!plugin)
|
|
31
42
|
throw new Error(`Unknown plugin: ${name}`);
|
|
32
|
-
|
|
43
|
+
// A specifier goes through verbatim: resolving it is OpenCode's job.
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
url: "npm" in plugin ? plugin.npm : pluginUrl(plugin.path),
|
|
47
|
+
};
|
|
33
48
|
});
|
|
34
49
|
}
|
package/dist/run.js
CHANGED
|
@@ -126,7 +126,7 @@ export class Muster {
|
|
|
126
126
|
if (req.runtime !== "opencode")
|
|
127
127
|
argv = launchArgs(req, this.config, prepared.servers, bridgePath);
|
|
128
128
|
if (req.runtime === "codex") {
|
|
129
|
-
policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers);
|
|
129
|
+
policy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
|
|
130
130
|
argv.splice(1, 0, ...policy);
|
|
131
131
|
}
|
|
132
132
|
else if (req.runtime === "opencode" && req.kind === "session") {
|