@brutalsystems/muster 0.1.0
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/CANONICAL_ID.md +168 -0
- package/CONTRACT_PROVENANCE.md +53 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/TINCAN_LICENSE +21 -0
- package/dist/claude-policy.js +51 -0
- package/dist/codex-policy.js +40 -0
- package/dist/codex-rpc.js +116 -0
- package/dist/config.js +31 -0
- package/dist/guard.js +106 -0
- package/dist/hosts/bootstrap.js +29 -0
- package/dist/hosts/index.js +12 -0
- package/dist/hosts/macos-terminal.js +21 -0
- package/dist/hosts/pty.js +73 -0
- package/dist/hosts/tmux.js +115 -0
- package/dist/hosts/types.js +1 -0
- package/dist/identity/claude.js +64 -0
- package/dist/identity/codex.js +53 -0
- package/dist/identity/processes.js +73 -0
- package/dist/log.js +19 -0
- package/dist/muster.js +189 -0
- package/dist/naming.js +68 -0
- package/dist/reach/claude.js +18 -0
- package/dist/reach/codex.js +25 -0
- package/dist/registry.js +105 -0
- package/dist/run.js +424 -0
- package/dist/task-worker.js +65 -0
- package/dist/types.js +1 -0
- package/package.json +66 -0
- package/scripts/prepare-pty.mjs +19 -0
- package/test/fixtures/canonical-id.json +161 -0
package/CANONICAL_ID.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Tin Can — Address Format
|
|
2
|
+
|
|
3
|
+
> **Normative for `@brutalsystems/tincan` 0.1.0.**
|
|
4
|
+
>
|
|
5
|
+
> This describes what Tin Can does today, precisely enough for another tool to
|
|
6
|
+
> produce addresses Tin Can will resolve. It is a write-down of shipped
|
|
7
|
+
> behaviour, not a design. Where the behaviour is wrong, it is marked **Known
|
|
8
|
+
> defect** and preserved anyway — see [Known defects](#known-defects).
|
|
9
|
+
>
|
|
10
|
+
> The executable form is [`test/fixtures/canonical-id.json`](test/fixtures/canonical-id.json),
|
|
11
|
+
> asserted by `test/canonical-id.test.ts`. Copy that fixture rather than
|
|
12
|
+
> reimplementing from prose.
|
|
13
|
+
>
|
|
14
|
+
> **The implementation is authoritative.** If this document and Tin Can
|
|
15
|
+
> disagree, Tin Can is right and this document has a bug. The fixture is the
|
|
16
|
+
> regression net that keeps them together.
|
|
17
|
+
|
|
18
|
+
## The pieces
|
|
19
|
+
|
|
20
|
+
An address is built from two inputs:
|
|
21
|
+
|
|
22
|
+
| Runtime | Name source | Id source |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `codex` | thread title, or absent | `thread_id` |
|
|
25
|
+
| `claude-code` | session name from the registry | `session_id` |
|
|
26
|
+
|
|
27
|
+
Neither name is guaranteed unique, and neither is stable: **a name belongs to a
|
|
28
|
+
process and dies with it.** Key durable records on the id, never on the address.
|
|
29
|
+
|
|
30
|
+
## Slugify
|
|
31
|
+
|
|
32
|
+
Applied to the runtime-supplied name.
|
|
33
|
+
|
|
34
|
+
1. Lowercase the whole string.
|
|
35
|
+
2. Replace every run of characters outside `[a-z0-9]` with a single `-`.
|
|
36
|
+
3. Strip leading and trailing `-`.
|
|
37
|
+
|
|
38
|
+
Consequences worth stating, because they are easy to get wrong:
|
|
39
|
+
|
|
40
|
+
- **Non-ASCII is a separator, not transliterated.** `café ☕ time` → `caf-time`.
|
|
41
|
+
- Runs collapse: `auth/refactor (v2)` → `auth-refactor-v2`.
|
|
42
|
+
- Digits survive: `123` → `123`.
|
|
43
|
+
- **The result may be empty.** `???` → `""`, `""` → `""`.
|
|
44
|
+
|
|
45
|
+
An empty slug is replaced by the literal `thread`. This applies both to a peer
|
|
46
|
+
with no name at all and to a peer whose name slugifies away — which is where a
|
|
47
|
+
[known defect](#known-defects) lives.
|
|
48
|
+
|
|
49
|
+
## Suffix
|
|
50
|
+
|
|
51
|
+
Applied to the id.
|
|
52
|
+
|
|
53
|
+
1. Remove every character outside `[0-9a-f]`, case-insensitively.
|
|
54
|
+
2. Take the **last three** characters of what remains.
|
|
55
|
+
3. Lowercase.
|
|
56
|
+
|
|
57
|
+
**The last three, not the first.** Codex thread ids are UUIDv7: the leading hex
|
|
58
|
+
is a timestamp, so every thread live on a machine at the same time shares its
|
|
59
|
+
first characters. A leading suffix disambiguates nothing. This has been
|
|
60
|
+
specified wrongly before — verify against the fixture.
|
|
61
|
+
|
|
62
|
+
- `01a0b9b4-a33e-7ab1-80a0-bb715504a0fb` → `0fb`
|
|
63
|
+
- `5af69d42-2214-41d9-b13f-9c3177eb60ce` → `0ce`
|
|
64
|
+
- Uppercase hex is kept, then lowered: `...ABC` → `abc`
|
|
65
|
+
- Fewer than three hex characters yields a shorter suffix: `z-9` → `9`
|
|
66
|
+
- No hex at all yields an empty suffix: `zzzz` → `""`
|
|
67
|
+
|
|
68
|
+
## Forms
|
|
69
|
+
|
|
70
|
+
Three forms exist. Two are emitted; all three are accepted as input.
|
|
71
|
+
|
|
72
|
+
| Form | Shape | Emitted |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| **Display** | `auth-refactor` | by `peers`, when the slug does not collide |
|
|
75
|
+
| **Qualified** | `auth-refactor.7f3` | by `peers`, only on collision |
|
|
76
|
+
| **Canonical** | `codex:auth-refactor.7f3` | as `canonical_id`, always |
|
|
77
|
+
|
|
78
|
+
The separator is `.` so that an address needs no shell quoting.
|
|
79
|
+
|
|
80
|
+
`canonical_id` is always fully qualified, whether or not there is a collision.
|
|
81
|
+
It is the form to record in logs and to pass between tools.
|
|
82
|
+
|
|
83
|
+
## Collision
|
|
84
|
+
|
|
85
|
+
A peer is suffixed in its **display** form when either:
|
|
86
|
+
|
|
87
|
+
- another peer in the same listing has the same slug, or
|
|
88
|
+
- the peer has no name (`rawName === null`), which is always suffixed.
|
|
89
|
+
|
|
90
|
+
Otherwise the display form is the bare slug. Only colliding peers are suffixed;
|
|
91
|
+
peers that do not collide keep their bare names in the same listing.
|
|
92
|
+
|
|
93
|
+
## Resolution
|
|
94
|
+
|
|
95
|
+
Input is trimmed and lowercased, then matched in two passes.
|
|
96
|
+
|
|
97
|
+
**Pass 1 — exact.** Matches if the input equals the peer's display form, its
|
|
98
|
+
qualified form, or its canonical id. One match resolves. More than one is
|
|
99
|
+
refused as ambiguous.
|
|
100
|
+
|
|
101
|
+
**Pass 2 — prefix**, only if pass 1 found nothing. Matches if the peer's slug or
|
|
102
|
+
its qualified form *starts with* the input. One match resolves. More than one is
|
|
103
|
+
refused as ambiguous.
|
|
104
|
+
|
|
105
|
+
If neither pass matches, the result is refused as unknown.
|
|
106
|
+
|
|
107
|
+
- **Case-insensitive** throughout. `AUTH-REFACTOR.7F3` resolves.
|
|
108
|
+
- **Input is trimmed.** Surrounding whitespace is ignored.
|
|
109
|
+
- **There is no minimum prefix length.** A one-character prefix resolves if it
|
|
110
|
+
is unambiguous. The empty string is a prefix of everything — see
|
|
111
|
+
[known defects](#known-defects).
|
|
112
|
+
- **No tie-breaking.** Ambiguity is always refused, never guessed.
|
|
113
|
+
|
|
114
|
+
### Refusal shape
|
|
115
|
+
|
|
116
|
+
| Reason | `candidates` contains |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `ambiguous` | every matching peer, in **qualified** form |
|
|
119
|
+
| `unknown` | every peer in the listing, in **display** form |
|
|
120
|
+
|
|
121
|
+
## Known defects
|
|
122
|
+
|
|
123
|
+
Preserved in 0.1.0 by decision, not by oversight. They are in the fixture so
|
|
124
|
+
that a second implementation matches Tin Can exactly, including where Tin Can is
|
|
125
|
+
wrong. **Do not "correct" them independently** — that produces addresses that
|
|
126
|
+
resolve in one tool and fail silently in the other.
|
|
127
|
+
|
|
128
|
+
**1. `canonical_id` is not unique.** Two peers whose slugs match *and* whose ids
|
|
129
|
+
share their last three hex characters produce the same `canonical_id`. Tin Can
|
|
130
|
+
then refuses to resolve either, listing two identical candidates — a refusal
|
|
131
|
+
that tells the caller to disambiguate using a string that does not
|
|
132
|
+
disambiguate. The peers are unaddressable until one exits.
|
|
133
|
+
|
|
134
|
+
Probability is roughly 1 in 4096 *given* a slug collision, and slug collisions
|
|
135
|
+
are not rare: Codex titles threads from their first prompt, so two sessions
|
|
136
|
+
started from similar prompts collide readily.
|
|
137
|
+
|
|
138
|
+
**Consumers must therefore treat `canonical_id` as a display and correlation
|
|
139
|
+
aid, not a primary key.** Key on `thread_id` / `session_id`.
|
|
140
|
+
|
|
141
|
+
**2. A name that slugifies to empty is inconsistent with an unnamed peer.**
|
|
142
|
+
Alone in a listing, a peer named `???` displays as `thread` with no suffix,
|
|
143
|
+
while a peer with no name displays as `thread.<suffix>`. Both slug to `thread`,
|
|
144
|
+
so when they appear together they collide and both are suffixed — the
|
|
145
|
+
inconsistency only shows when each is alone.
|
|
146
|
+
|
|
147
|
+
**3. The empty string resolves when exactly one peer exists.** Because the empty
|
|
148
|
+
string is a prefix of every slug, `resolve("")` against a single-peer listing
|
|
149
|
+
returns that peer rather than refusing. With two or more peers it is refused as
|
|
150
|
+
ambiguous, as expected.
|
|
151
|
+
|
|
152
|
+
## Changing this format
|
|
153
|
+
|
|
154
|
+
The address format is a contract between Tin Can and tools built against it. A
|
|
155
|
+
change to slugify, suffix derivation, collision handling, or resolution produces
|
|
156
|
+
addresses that resolve in one tool and fail silently in the other — no error,
|
|
157
|
+
just a message delivered nowhere.
|
|
158
|
+
|
|
159
|
+
So:
|
|
160
|
+
|
|
161
|
+
- **A change to any behaviour described here is a breaking change**, and ships
|
|
162
|
+
as a major version under semver.
|
|
163
|
+
- Update this document and the fixture in the same commit as the code.
|
|
164
|
+
- Adding cases to the fixture is not a breaking change. Changing an existing
|
|
165
|
+
expected value is.
|
|
166
|
+
|
|
167
|
+
Fixing the known defects above is therefore a deliberate, coordinated release —
|
|
168
|
+
not a bug fix to be slipped in.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Tin Can contract provenance
|
|
2
|
+
|
|
3
|
+
Copied verbatim from `https://github.com/BrutalSystems/tincan` at commit
|
|
4
|
+
`fbaaea5842fd4a5c86849d7f51c8e16b8683b058` on 2026-09-19.
|
|
5
|
+
The contract describes `@brutalsystems/tincan` version `0.1.0`.
|
|
6
|
+
|
|
7
|
+
| File | SHA-256 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `CANONICAL_ID.md` | `8cf4312d6af1c4d85ec0641d8739bec261be2feb35dd16bdd9d8be2b89551915` |
|
|
10
|
+
| `test/fixtures/canonical-id.json` | `47fb41fe62efaab6d9ecc7a77623fb2761f79829faf8bcd67c6e54bc4414b751` |
|
|
11
|
+
|
|
12
|
+
Both copies were verified byte-for-byte against the clean source checkout.
|
|
13
|
+
The fixture contains 31 cases: 8 slugify, 6 suffix, 7 assignment, 10 resolution.
|
|
14
|
+
|
|
15
|
+
Tin Can's fixture runner compares refusal candidates after sorting both arrays;
|
|
16
|
+
candidate ordering is not asserted by the fixture. Preserve duplicate candidates.
|
|
17
|
+
CLI runtime `claude` maps to address runtime `claude-code`.
|
|
18
|
+
|
|
19
|
+
Known defects are intentionally preserved: non-unique canonical IDs,
|
|
20
|
+
unnamed versus empty-slug display differences, and empty-input resolution.
|
|
21
|
+
See the normative document for exact behavior.
|
|
22
|
+
|
|
23
|
+
## Compatibility check against Tin Can 0.2.0
|
|
24
|
+
|
|
25
|
+
On 2026-09-19, verified source commit
|
|
26
|
+
`3f13fdcc9ac1246b2e5707b6a44f0cf8d207899f`: Claude `peers` now emits
|
|
27
|
+
`session_id`; Codex emits `thread_id`. The integration test must compare those
|
|
28
|
+
durable fields directly, in addition to verifying message delivery.
|
|
29
|
+
|
|
30
|
+
The four fixture case arrays are identical to our 0.1.0 copy. The upstream
|
|
31
|
+
fixture version is now 0.2.0; that metadata change is not a naming change.
|
|
32
|
+
Our original frozen files remain unchanged. Their full-file hashes above
|
|
33
|
+
detect local edits; they must not be compared to an entire newer upstream
|
|
34
|
+
fixture to infer a format change.
|
|
35
|
+
|
|
36
|
+
For case comparison, SHA-256 of UTF-8 `JSON.stringify` applied to an object
|
|
37
|
+
with the keys `slugify`, `suffix`, `assign`, `resolve` in that order is:
|
|
38
|
+
`a92936ef4b713641d29da3fbafccab0e6da79a54e27bfea3c6a779e801a925b3`.
|
|
39
|
+
Both revisions produce this hash.
|
|
40
|
+
|
|
41
|
+
## Upgrade boundary: listing and state
|
|
42
|
+
|
|
43
|
+
The upstream release notes report that 0.3.0 expands
|
|
44
|
+
Codex-hosted listings to both runtimes and 0.3.1 corrects Codex status decoding.
|
|
45
|
+
These releases have not been verified by Muster's contract suite. The tested
|
|
46
|
+
baseline remains exactly 0.2.0; README installation instructions pin it outside
|
|
47
|
+
Muster's dependency tree.
|
|
48
|
+
|
|
49
|
+
Inspection confirms the contract test does not assert total peer counts,
|
|
50
|
+
same-runtime exclusion, or Tin Can state. Its count assertion applies only to
|
|
51
|
+
the matching canonical ID in the isolated test environment. Naming hashes do
|
|
52
|
+
not cover listing membership, self-exclusion, or state semantics. A future
|
|
53
|
+
upgrade must test these behaviors separately where relied upon.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Williams
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Muster
|
|
2
|
+
|
|
3
|
+
[](./LICENSE)
|
|
4
|
+
|
|
5
|
+
Muster launches instructed Codex or Claude Code agents on this machine. A
|
|
6
|
+
`session` returns an address only after its runtime is reachable. A `task`
|
|
7
|
+
runs once, captures output, and never advertises a peer address.
|
|
8
|
+
|
|
9
|
+
**Live launch verification:** Codex and Claude both launched and answered their
|
|
10
|
+
initial prompts without terminal interaction. Claude requires a directory
|
|
11
|
+
already trusted by the operator. The automated suite uses fake runtimes,
|
|
12
|
+
not real models.
|
|
13
|
+
|
|
14
|
+
## Build and run locally
|
|
15
|
+
|
|
16
|
+
Requires Node 22.12+ (tested with 24.16), TypeScript 5, and Codex 0.155.1 / Claude
|
|
17
|
+
Code 2.1.267. Terminal drivers use tmux or node-pty. Process discovery currently
|
|
18
|
+
requires a POSIX host with `ps` and `lsof`; the terminal interface itself is
|
|
19
|
+
platform-neutral. The macOS Terminal driver is an unavailable v1 stub.
|
|
20
|
+
|
|
21
|
+
Before launching a Claude session, open Claude normally in the target directory
|
|
22
|
+
and complete its workspace-trust review yourself, then exit that setup session.
|
|
23
|
+
This is a one-time prerequisite for each directory Claude requires you to trust.
|
|
24
|
+
Muster never accepts trust prompts or changes trust settings. An untrusted
|
|
25
|
+
directory can block startup; Muster times out and cleans up that launch.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm ci
|
|
29
|
+
npm run build
|
|
30
|
+
node dist/muster.js run codex --prompt 'Review the authentication flow'
|
|
31
|
+
node dist/muster.js run claude --prompt 'Summarize the project' --host tmux
|
|
32
|
+
node dist/muster.js run codex --kind task --prompt 'Explain the test layout'
|
|
33
|
+
node dist/muster.js list
|
|
34
|
+
node dist/muster.js list --kind task
|
|
35
|
+
node dist/muster.js output RUN_ID
|
|
36
|
+
node dist/muster.js stop THREAD_OR_SESSION_OR_RUN_ID
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`--prompt` is required and cannot be blank. `--cwd` defaults to the current
|
|
40
|
+
working directory. `--kind` defaults to `session`. All commands except `output`
|
|
41
|
+
emit JSON; `output` prints the captured task output. `stop` also accepts an
|
|
42
|
+
unambiguous peer name or canonical address, refusing ambiguity with candidates.
|
|
43
|
+
Use the durable ID to stop a session whose runtime has renamed it.
|
|
44
|
+
|
|
45
|
+
`run` accepts an optional `--` followed by runtime arguments. The normal allowlist
|
|
46
|
+
is `--model` / `-m`, plus Claude's `--effort`. Unknown options, bundled short
|
|
47
|
+
options, config injection and permission overrides are refused. Prompt strings
|
|
48
|
+
are passed as a single literal argument after the runtime's option terminator.
|
|
49
|
+
|
|
50
|
+
## Terminal lifetime
|
|
51
|
+
|
|
52
|
+
Auto-selection tries tmux, then pty. Every peer includes `host`, `capabilities`
|
|
53
|
+
and `attach_hint`. Watchability is separate from the runtime's idle/busy state.
|
|
54
|
+
|
|
55
|
+
- **tmux:** a window in the dedicated `muster` session on the `muster` tmux
|
|
56
|
+
server. Watchable and attachable; survives the CLI or MCP server exiting.
|
|
57
|
+
Use the returned attach hint, or `tmux -L muster attach -t muster`.
|
|
58
|
+
- **pty:** not watchable or attachable. The CLI prints the peer record and stays
|
|
59
|
+
running to own the terminal. Ctrl-C stops it. MCP-owned pty sessions stop when
|
|
60
|
+
the MCP server disconnects. There is no persistent pty daemon.
|
|
61
|
+
- **task:** a per-run worker captures stdout/stderr and exit status after the
|
|
62
|
+
launching CLI exits. It supervises one task, with no retry or restart behavior.
|
|
63
|
+
|
|
64
|
+
`list` refreshes live session metadata and shows ended runs distinctly. A timeout
|
|
65
|
+
or startup failure cleans the process tree and host window, logs failure, and
|
|
66
|
+
returns an error instead of a peer record.
|
|
67
|
+
|
|
68
|
+
## Configuration and permissions
|
|
69
|
+
|
|
70
|
+
Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
|
|
71
|
+
Missing configuration uses these defaults:
|
|
72
|
+
|
|
73
|
+
```toml
|
|
74
|
+
host = "auto"
|
|
75
|
+
launch_timeout_sec = 30
|
|
76
|
+
max_concurrent = 4
|
|
77
|
+
sandbox = "read-only"
|
|
78
|
+
allow_dangerous_flags = false
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Choose `sandbox = "workspace-write"` yourself when agents should edit files.
|
|
82
|
+
`danger-full-access` additionally requires `allow_dangerous_flags = true`.
|
|
83
|
+
The concurrency cap is shared by separate CLI/MCP processes, including pending
|
|
84
|
+
launches. Settings apply to both kinds; tasks are read-only by default.
|
|
85
|
+
|
|
86
|
+
Codex gets an explicit sandbox and never-ask approval policy. Muster enumerates
|
|
87
|
+
and explicitly disables inherited MCP servers, then verifies the effective
|
|
88
|
+
configuration. Hooks, plugins, app connectors, automatic skill-MCP installation
|
|
89
|
+
and external notifications are disabled for the child. Unknown MCP names that
|
|
90
|
+
cannot be addressed safely are refused.
|
|
91
|
+
|
|
92
|
+
Claude's built-in tools default to enabled. Its command sandbox is enabled,
|
|
93
|
+
requires availability, and forbids unsandboxed retries. File-writing tools and
|
|
94
|
+
sandbox writes are denied in read-only mode; permissions use `dontAsk` so an
|
|
95
|
+
unattended child does not auto-grant escalations. User/project settings and MCP
|
|
96
|
+
servers are not inherited. Detected managed policy is refused because the CLI
|
|
97
|
+
cannot prove that inline settings override it. Enterprise remote policy can
|
|
98
|
+
arrive after startup; this v1 is not an enterprise policy-enforcement layer.
|
|
99
|
+
Claude permissions and its command sandbox are different mechanisms; neither
|
|
100
|
+
claim implies that every external tool is OS-sandboxed.
|
|
101
|
+
|
|
102
|
+
These child restrictions deliberately prevent propagating spawn authority.
|
|
103
|
+
Custom MCP tools—including Tin Can—are not automatically injected into the
|
|
104
|
+
child. The child's runtime inbox remains reachable from an external Tin Can.
|
|
105
|
+
No user-level runtime configuration is rewritten, and workspace-trust dialogs
|
|
106
|
+
are never accepted automatically.
|
|
107
|
+
|
|
108
|
+
Launch intent is fsynced to `~/.muster/launches.jsonl` before a runtime starts;
|
|
109
|
+
ready/failure outcomes follow. The log contains the **full prompt**, cwd,
|
|
110
|
+
requester, runtime, kind and host. Registry and task outputs also live under
|
|
111
|
+
`~/.muster`, with private file permissions. An abandoned `registry.lock` fails
|
|
112
|
+
closed: verify no Muster operation is running before removing that directory.
|
|
113
|
+
There is no automatic time-based lock theft.
|
|
114
|
+
|
|
115
|
+
## MCP installation
|
|
116
|
+
|
|
117
|
+
Installed deliberately, in the one session that should hold spawn authority —
|
|
118
|
+
never at user scope.
|
|
119
|
+
|
|
120
|
+
Muster exposes `run`, `list`, `stop`, and `output` over stdio. No arguments or
|
|
121
|
+
`mcp` starts the server. Diagnostics go to stderr, never protocol stdout.
|
|
122
|
+
Schemas match the CLI (`args` is the array of optional runtime arguments).
|
|
123
|
+
|
|
124
|
+
For a single Codex session, use per-invocation configuration:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
codex -c 'mcp_servers.muster.command="node"' \
|
|
128
|
+
-c 'mcp_servers.muster.args=["/absolute/path/to/muster/dist/muster.js","mcp"]'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For a single Claude session:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
claude --mcp-config '{"mcpServers":{"muster":{"command":"node","args":["/absolute/path/to/muster/dist/muster.js","mcp"]}}}'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Do not add Muster to `~/.codex/config.toml`, Claude's user-scope MCP registry,
|
|
138
|
+
or another shared configuration that grants launch authority to every agent.
|
|
139
|
+
|
|
140
|
+
## Tin Can compatibility
|
|
141
|
+
|
|
142
|
+
Muster has no Tin Can build or runtime dependency. Each tool implements the
|
|
143
|
+
written address contract independently. Vendored mechanics carry source-commit
|
|
144
|
+
headers and the original MIT license in `TINCAN_LICENSE`.
|
|
145
|
+
|
|
146
|
+
Addresses belong to running sessions and can expire or collide. Store
|
|
147
|
+
`thread_id` / `session_id`, and re-resolve through Tin Can's `peers` before
|
|
148
|
+
sending instead of caching a launch address. Canonical IDs are not unique keys.
|
|
149
|
+
The frozen cases intentionally preserve Tin Can's known naming defects.
|
|
150
|
+
|
|
151
|
+
Codex `idle` means reachable and not known to be busy, not guaranteed free.
|
|
152
|
+
The querying app-server can report `notLoaded` for a live thread; Muster maps
|
|
153
|
+
that to `idle`. Claude state comes from its session registry.
|
|
154
|
+
|
|
155
|
+
## Verification
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
npm test
|
|
159
|
+
npm run build
|
|
160
|
+
npm run test:contract
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
|
|
164
|
+
real file locks in the fake Codex executable. It uses isolated runtime homes and
|
|
165
|
+
no model APIs. It skips the four explicitly invoked compatibility cases.
|
|
166
|
+
|
|
167
|
+
The verified contract baseline is **Tin Can 0.2.0**. The suite spawns an installed binary as an MCP
|
|
168
|
+
subprocess, compares durable IDs, and verifies delivery to fake runtimes through
|
|
169
|
+
both terminal hosts. A missing binary or missing durable field fails the test.
|
|
170
|
+
For reproducible verification, install that exact release outside Muster:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
contract_dir=$(mktemp -d)
|
|
174
|
+
npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.2.0
|
|
175
|
+
MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Verification uses the published 0.2.0 release installed in an isolated
|
|
179
|
+
temporary directory. No Tin Can source edits are made.
|
|
180
|
+
Fixture version metadata alone does not indicate address-format drift: compare
|
|
181
|
+
the case arrays. Those arrays cover naming only, not peer-list membership or
|
|
182
|
+
state semantics. Upgrades require separate compatibility verification; passing
|
|
183
|
+
fixture hashes alone does not establish compatibility. The tests locate the
|
|
184
|
+
launched peer by canonical ID and compare its durable ID; they do not assert a
|
|
185
|
+
total peer count, exclude same-runtime peers, or assert Tin Can's busy state.
|
|
186
|
+
`CONTRACT_PROVENANCE.md` records both types of integrity checks.
|
|
187
|
+
|
|
188
|
+
One-time live probes are separate from the automated suite. Both runtimes
|
|
189
|
+
launched and answered an initial prompt with the versions listed above; Claude
|
|
190
|
+
used a directory already trusted by its operator. `npm test` does not run
|
|
191
|
+
real models.
|
|
192
|
+
|
|
193
|
+
## License and releases
|
|
194
|
+
|
|
195
|
+
MIT © 2026 Mike Williams. See [LICENSE](./LICENSE). Vendored Tin Can code
|
|
196
|
+
retains source attribution and its [MIT notice](./TINCAN_LICENSE).
|
|
197
|
+
|
|
198
|
+
[RELEASING.md](./RELEASING.md) covers versioning, package inspection, publication,
|
|
199
|
+
and release notes. Changes to the shared address format require an explicit
|
|
200
|
+
contract update; Muster never independently fixes the frozen naming behavior.
|
package/TINCAN_LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mike Williams
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
/** Claude's CLI cannot report all effective enterprise policy before starting.
|
|
7
|
+
* Refuse detected managed sources rather than pretending inline settings win. */
|
|
8
|
+
export async function assertClaudePolicy(env) {
|
|
9
|
+
if (env.CLAUDE_CONFIG_DIR)
|
|
10
|
+
throw new Error("Custom CLAUDE_CONFIG_DIR is unsupported: Tin Can discovers ~/.claude/sessions");
|
|
11
|
+
const home = env.HOME ?? homedir();
|
|
12
|
+
const system = process.platform === "darwin"
|
|
13
|
+
? "/Library/Application Support/ClaudeCode"
|
|
14
|
+
: "/etc/claude-code";
|
|
15
|
+
const files = [
|
|
16
|
+
join(system, "managed-settings.json"),
|
|
17
|
+
join(system, "managed-mcp.json"),
|
|
18
|
+
join(home, ".claude", "remote-settings.json"),
|
|
19
|
+
];
|
|
20
|
+
try {
|
|
21
|
+
for (const file of await readdir(join(system, "managed-settings.d")))
|
|
22
|
+
if (file.endsWith(".json"))
|
|
23
|
+
files.push(join(system, "managed-settings.d", file));
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e.code !== "ENOENT")
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
try {
|
|
31
|
+
const settings = JSON.parse(await readFile(file, "utf8"));
|
|
32
|
+
if (settings && Object.keys(settings).length)
|
|
33
|
+
throw new Error(`Managed Claude policy detected at ${file}; effective sandbox policy cannot be verified by this v1 launcher`);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e.code !== "ENOENT")
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (process.platform === "darwin") {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await promisify(execFile)("defaults", ["read", "com.anthropic.claudecode"], { timeout: 1000, env });
|
|
43
|
+
if (stdout.trim())
|
|
44
|
+
throw new Error("Managed Claude preferences detected; effective sandbox policy cannot be verified by this v1 launcher");
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e.code !== 1)
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
export async function codexPolicyArgs(env, cwd, deadline = Date.now() + 8000) {
|
|
5
|
+
const args = [
|
|
6
|
+
"--disable",
|
|
7
|
+
"hooks",
|
|
8
|
+
"--disable",
|
|
9
|
+
"plugins",
|
|
10
|
+
"--disable",
|
|
11
|
+
"remote_plugin",
|
|
12
|
+
"--disable",
|
|
13
|
+
"apps",
|
|
14
|
+
"--disable",
|
|
15
|
+
"skill_mcp_dependency_install",
|
|
16
|
+
"-c",
|
|
17
|
+
"notify=[]",
|
|
18
|
+
];
|
|
19
|
+
async function list(extra) {
|
|
20
|
+
const { stdout } = await exec("codex", [...extra, "mcp", "list", "--json"], {
|
|
21
|
+
env,
|
|
22
|
+
cwd,
|
|
23
|
+
timeout: Math.max(1, Math.min(4000, deadline - Date.now())),
|
|
24
|
+
maxBuffer: 1024 * 1024,
|
|
25
|
+
});
|
|
26
|
+
const value = JSON.parse(stdout);
|
|
27
|
+
if (!Array.isArray(value))
|
|
28
|
+
throw new Error("Cannot verify effective Codex MCP configuration");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
for (const server of await list(args)) {
|
|
32
|
+
if (typeof server.name !== "string" ||
|
|
33
|
+
!/^[a-zA-Z0-9_-]+$/.test(server.name))
|
|
34
|
+
throw new Error("Cannot safely disable an inherited MCP server with a non-simple name");
|
|
35
|
+
args.push("-c", `mcp_servers.${server.name}.enabled=false`);
|
|
36
|
+
}
|
|
37
|
+
if ((await list(args)).some((s) => s.enabled !== false))
|
|
38
|
+
throw new Error("Inherited Codex MCP servers remain enabled; refusing launch");
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { codexPolicyArgs } from "./codex-policy.js";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
/** JSONL protocol mechanics independently adapted from Tin Can src/codex/cli.ts
|
|
4
|
+
* at fbaaea5842fd4a5c86849d7f51c8e16b8683b058 (MIT). */
|
|
5
|
+
export class CodexRpc {
|
|
6
|
+
env;
|
|
7
|
+
cwd;
|
|
8
|
+
policy;
|
|
9
|
+
child;
|
|
10
|
+
serial = 0;
|
|
11
|
+
buffer = "";
|
|
12
|
+
initialized = false;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
constructor(env, cwd = process.cwd(), policy) {
|
|
15
|
+
this.env = env;
|
|
16
|
+
this.cwd = cwd;
|
|
17
|
+
this.policy = policy;
|
|
18
|
+
}
|
|
19
|
+
async start(deadline) {
|
|
20
|
+
if (this.child)
|
|
21
|
+
return;
|
|
22
|
+
const policy = this.policy ?? (await codexPolicyArgs(this.env, this.cwd, deadline));
|
|
23
|
+
const child = spawn("codex", [...policy, "app-server", "--listen", "stdio://"], {
|
|
24
|
+
env: this.env,
|
|
25
|
+
cwd: this.cwd,
|
|
26
|
+
stdio: "pipe",
|
|
27
|
+
});
|
|
28
|
+
this.child = child;
|
|
29
|
+
child.stderr.resume();
|
|
30
|
+
const fail = (e) => {
|
|
31
|
+
for (const p of this.pending.values()) {
|
|
32
|
+
clearTimeout(p.timer);
|
|
33
|
+
p.reject(e);
|
|
34
|
+
}
|
|
35
|
+
this.pending.clear();
|
|
36
|
+
};
|
|
37
|
+
child.on("error", fail);
|
|
38
|
+
child.on("exit", () => fail(new Error("Codex app-server exited")));
|
|
39
|
+
child.stdin.on("error", fail);
|
|
40
|
+
child.stdout.on("data", (data) => {
|
|
41
|
+
this.buffer += data;
|
|
42
|
+
let i;
|
|
43
|
+
while ((i = this.buffer.indexOf("\n")) >= 0) {
|
|
44
|
+
const line = this.buffer.slice(0, i);
|
|
45
|
+
this.buffer = this.buffer.slice(i + 1);
|
|
46
|
+
let m;
|
|
47
|
+
try {
|
|
48
|
+
m = JSON.parse(line);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const p = this.pending.get(m.id);
|
|
54
|
+
if (!p)
|
|
55
|
+
continue;
|
|
56
|
+
clearTimeout(p.timer);
|
|
57
|
+
this.pending.delete(m.id);
|
|
58
|
+
if (m.error)
|
|
59
|
+
p.reject(new Error(m.error.message ?? "Codex RPC error"));
|
|
60
|
+
else if (!m.result || typeof m.result !== "object")
|
|
61
|
+
p.reject(new Error("Invalid Codex RPC result"));
|
|
62
|
+
else
|
|
63
|
+
p.resolve(m.result);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
send(method, params, deadline) {
|
|
68
|
+
const remaining = deadline - Date.now();
|
|
69
|
+
if (remaining <= 0)
|
|
70
|
+
return Promise.reject(new Error(`Timed out calling ${method}`));
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const id = ++this.serial;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
this.pending.delete(id);
|
|
75
|
+
reject(new Error(`Timed out calling ${method}`));
|
|
76
|
+
}, Math.min(remaining, 2000));
|
|
77
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
78
|
+
this.child.stdin.write(JSON.stringify({ id, method, params }) + "\n");
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async call(method, params, deadline) {
|
|
82
|
+
await this.start(deadline);
|
|
83
|
+
if (!this.initialized) {
|
|
84
|
+
await this.send("initialize", {
|
|
85
|
+
clientInfo: { name: "muster", version: "0.1.0" },
|
|
86
|
+
capabilities: { experimentalApi: true },
|
|
87
|
+
}, deadline);
|
|
88
|
+
this.initialized = true;
|
|
89
|
+
}
|
|
90
|
+
return this.send(method, params, deadline);
|
|
91
|
+
}
|
|
92
|
+
async close() {
|
|
93
|
+
if (!this.child)
|
|
94
|
+
return;
|
|
95
|
+
const child = this.child;
|
|
96
|
+
this.child = undefined;
|
|
97
|
+
for (const p of this.pending.values()) {
|
|
98
|
+
clearTimeout(p.timer);
|
|
99
|
+
p.reject(new Error("Codex RPC closed"));
|
|
100
|
+
}
|
|
101
|
+
this.pending.clear();
|
|
102
|
+
if (child.exitCode !== null)
|
|
103
|
+
return;
|
|
104
|
+
await new Promise((resolve) => {
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
child.kill("SIGKILL");
|
|
107
|
+
resolve();
|
|
108
|
+
}, 1000);
|
|
109
|
+
child.once("exit", () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolve();
|
|
112
|
+
});
|
|
113
|
+
child.kill("SIGTERM");
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|