@brutalsystems/muster 0.8.3 → 0.9.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tin Can — Address Format
2
2
 
3
- > **Normative for `@brutalsystems/tincan` 0.1.0.**
3
+ > **Normative for `@brutalsystems/tincan` 1.9.2.**
4
4
  >
5
5
  > This describes what Tin Can does today, precisely enough for another tool to
6
6
  > produce addresses Tin Can will resolve. It is a write-down of shipped
@@ -11,22 +11,56 @@
11
11
  > asserted by `test/canonical-id.test.ts`. Copy that fixture rather than
12
12
  > reimplementing from prose.
13
13
  >
14
+ > Its `tincan_version` field records the release the expectations were verified
15
+ > against, and is bumped every release. **A version difference alone is not
16
+ > drift** — compare the file's contents, or its hash, to tell whether the format
17
+ > actually moved.
18
+ >
14
19
  > **The implementation is authoritative.** If this document and Tin Can
15
20
  > disagree, Tin Can is right and this document has a bug. The fixture is the
16
21
  > regression net that keeps them together.
17
22
 
18
23
  ## The pieces
19
24
 
20
- An address is built from two inputs:
25
+ An address is built from three inputs, one of which is usually absent:
21
26
 
22
27
  | Runtime | Name source | Id source |
23
28
  |---|---|---|
24
29
  | `codex` | thread title, or absent | `thread_id` |
25
30
  | `claude-code` | session name from the registry | `session_id` |
31
+ | `opencode` | session slug | `session_id` |
26
32
 
27
33
  Neither name is guaranteed unique, and neither is stable: **a name belongs to a
28
34
  process and dies with it.** Key durable records on the id, never on the address.
29
35
 
36
+ `peers` exposes that id on every peer — `thread_id` for Codex, `session_id` for
37
+ Claude Code and for opencode — so a caller never has to derive it from an
38
+ address.
39
+
40
+ The third input is the **machine**, and it is absent for every peer on this
41
+ one. See [Machine](#machine).
42
+
43
+ ## Machine
44
+
45
+ A peer on another machine carries `@<machine>` at the end of its address. A
46
+ peer on this machine carries nothing.
47
+
48
+ auth-refactor a session here
49
+ auth-refactor@m4pro a session on another computer
50
+
51
+ Absent rather than a literal like `localhost`, for two reasons. Every address
52
+ anyone types today keeps working and keeps meaning exactly what it meant. And
53
+ it makes the dangerous direction the explicit one: reaching another computer
54
+ requires saying so.
55
+
56
+ **A bare address never resolves to a remote peer.** Resolution filters by
57
+ whether the input contains `@` before matching anything, so an address with no
58
+ machine can only match a local session — including during prefix matching,
59
+ which is where it would otherwise slip through. A message meant for a local
60
+ peer must not leave the machine because a local session happened to exit.
61
+
62
+ The machine name is slugified by the same rule as a peer name.
63
+
30
64
  ## Slugify
31
65
 
32
66
  Applied to the runtime-supplied name.
@@ -59,56 +93,145 @@ is a timestamp, so every thread live on a machine at the same time shares its
59
93
  first characters. A leading suffix disambiguates nothing. This has been
60
94
  specified wrongly before — verify against the fixture.
61
95
 
96
+ The same holds for opencode, for the same reason by a different route. An
97
+ opencode session id is `ses_` followed by roughly twelve hex characters of
98
+ descending timestamp, then roughly fourteen base62 (`[0-9a-zA-Z]`) random
99
+ characters. Stripping to `[0-9a-f]` and taking the last three lands in that
100
+ random tail, not the timestamp — exactly as it does for a Codex UUIDv7 or a
101
+ Claude Code UUIDv4. `suffixOf('ses_f41a2b3c4ffeExampleSess01Z')` → `'e01'`.
102
+
62
103
  - `01a0b9b4-a33e-7ab1-80a0-bb715504a0fb` → `0fb`
63
104
  - `5af69d42-2214-41d9-b13f-9c3177eb60ce` → `0ce`
105
+ - `ses_f41a2b3c4ffeExampleSess01Z` → `e01`
64
106
  - Uppercase hex is kept, then lowered: `...ABC` → `abc`
65
107
  - Fewer than three hex characters yields a shorter suffix: `z-9` → `9`
66
108
  - No hex at all yields an empty suffix: `zzzz` → `""`
67
109
 
110
+ Two caveats specific to opencode's id shape:
111
+
112
+ - **The random tail is base62, not hex, so filtering it down to `[0-9a-f]`
113
+ skews the alphabet.** `a`–`f` are reachable from two source characters each
114
+ (upper- and lower-case), while `0`–`9` are reachable from only one, so the
115
+ surviving hex digits are not uniform. Collision odds for the suffix
116
+ therefore sit slightly above the nominal 1 in 4096 for a uniform 3-hex-digit
117
+ space. Worth stating; not worth changing — see
118
+ [Changing this format](#changing-this-format).
119
+ - **An opencode id can never produce the empty-suffix defect.** The fixed
120
+ `ses_` + hex-timestamp prefix guarantees at least three hex characters are
121
+ always present, so unlike an adversarial or degenerate id (`zzzz` above),
122
+ `suffixOf` never returns `""` for a real opencode session id.
123
+
68
124
  ## Forms
69
125
 
70
126
  Three forms exist. Two are emitted; all three are accepted as input.
71
127
 
72
128
  | Form | Shape | Emitted |
73
129
  |---|---|---|
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 |
130
+ | **Display** | `auth-refactor`, `auth-refactor@m4pro` | by `peers`, when the slug does not collide |
131
+ | **Qualified** | `auth-refactor.7f3`, `auth-refactor.7f3@m4pro` | by `peers`, only on collision |
132
+ | **Canonical** | `codex:auth-refactor.01a0b9b4-a33e-7ab1-80a0-bb715504a0fb` | as `canonical_id`, always |
133
+
134
+ **The canonical form carries the whole durable id**, not the three-character
135
+ suffix. That is what makes it unique — see [Known defects](#known-defects),
136
+ where its non-uniqueness used to be recorded as a defect preserved on purpose.
77
137
 
78
138
  The separator is `.` so that an address needs no shell quoting.
79
139
 
80
140
  `canonical_id` is always fully qualified, whether or not there is a collision.
81
141
  It is the form to record in logs and to pass between tools.
82
142
 
143
+ **Do not recover the suffix from a canonical id by string surgery.** Taking the
144
+ last dot-separated segment gave the three-character suffix before 1.0.0 and
145
+ gives the whole durable id now — `01a0b9b4-a33e-7ab1-80a0-bb715504a0fb`, not
146
+ `0fb`. A reimplementation that does this builds qualified addresses that look
147
+ right and match nothing, and the failure surfaces as `unknown`, which names a
148
+ missing peer rather than a malformed address. Reported by muster, where it made
149
+ every `slug.suffix` address fail silently until a test was written for it.
150
+
151
+ Derive the suffix from the **uuid**, with `suffixOf` — the last three hex
152
+ characters of the id with non-hex removed. The canonical form is an address to
153
+ pass along whole, not a record to parse fields out of.
154
+
83
155
  ## Collision
84
156
 
85
157
  A peer is suffixed in its **display** form when either:
86
158
 
87
- - another peer in the same listing has the same slug, or
159
+ - another peer in the same listing has the same slug **on the same machine**, or
88
160
  - the peer has no name (`rawName === null`), which is always suffixed.
89
161
 
162
+ Collisions are counted per machine. The same slug on two computers is already
163
+ two different addresses, so suffixing both would add noise to distinguish
164
+ things that were never confusable.
165
+
90
166
  Otherwise the display form is the bare slug. Only colliding peers are suffixed;
91
167
  peers that do not collide keep their bare names in the same listing.
92
168
 
169
+ **Adding a runtime can change an existing peer's display.** `assignNames`
170
+ computes collisions across the *whole* returned list, regardless of runtime.
171
+ So a peer that displayed as a bare slug before opencode support existed can
172
+ start displaying suffixed the moment an opencode peer (or any peer) slugging
173
+ to the same string appears in the same listing — a Codex peer `foo` and an
174
+ opencode session slugging to `foo` both flip from bare `foo` to qualified
175
+ `foo.<suffix>` (see the fixture's "cross-runtime collision" case). This is
176
+ inherent to the existing rule, not new behaviour introduced by opencode
177
+ support, and it is correct: the two peers are genuinely indistinguishable by
178
+ slug. It is called out here because Muster consumes `display`, and this
179
+ should be an expected consequence of adding a runtime, not something its
180
+ maintainer discovers by surprise.
181
+
93
182
  ## Resolution
94
183
 
95
- Input is trimmed and lowercased, then matched in two passes.
184
+ Input is trimmed and lowercased, then matched in two passes against the peer
185
+ listing, with one host-local check between them.
96
186
 
97
187
  **Pass 1 — exact.** Matches if the input equals the peer's display form, its
98
188
  qualified form, or its canonical id. One match resolves. More than one is
99
189
  refused as ambiguous.
100
190
 
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.
191
+ **Self check**, only if pass 1 found nothing. If the input names the session
192
+ Tin Can is itself running in, the result is refused as `self`. See
193
+ [Self is not a peer](#self-is-not-a-peer) — this pass is **not** part of the
194
+ fixture contract and a second implementation is not expected to reproduce it.
195
+
196
+ **Pass 2 — prefix**, only if pass 1 and the self check found nothing. Matches
197
+ if the peer's slug or its qualified form *starts with* the input. One match
198
+ resolves. More than one is refused as ambiguous.
199
+
200
+ If nothing matches, the result is refused as unknown.
201
+
202
+ ### Self is not a peer
203
+
204
+ A host never lists the session it is running in, so an address naming that
205
+ session reaches pass 2 with nothing to match — or, worse, matches *something
206
+ else*. The check sits between the passes rather than before them, and the
207
+ order is load-bearing in both directions:
208
+
209
+ - **Below pass 1**, so an exact peer name still wins. A peer genuinely called
210
+ `review` receives `review` even when the host is called `review-tools`,
211
+ which the self check matches by prefix.
212
+ - **Above pass 2**, because that pass resolves on a *single* match. A host
213
+ that can only recognise itself by name — a Claude Code host carries no
214
+ session id of its own and falls back to its working directory's basename —
215
+ whose fallback name prefixed exactly one peer would otherwise resolve to
216
+ that peer and deliver. Where the listing spans config dirs, that peer
217
+ belongs to a different account: a note addressed to yourself, delivered to
218
+ a stranger, reported as sent.
219
+
220
+ **This is deliberately outside the fixture.** Every case in
221
+ `test/fixtures/canonical-id.json` resolves with no host identity supplied, and
222
+ all ten behave identically with and without this pass. "Self" is a property of
223
+ the process doing the resolving, not of the address or the listing, so a tool
224
+ that consumes addresses — Muster launches sessions and hands back peer records
225
+ for Tin Can to resolve — has no self to check and nothing to implement. The
226
+ address format is unchanged; what changed is what one particular resolver does
227
+ with its own name.
106
228
 
107
229
  - **Case-insensitive** throughout. `AUTH-REFACTOR.7F3` resolves.
108
230
  - **Input is trimmed.** Surrounding whitespace is ignored.
109
231
  - **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).
232
+ is unambiguous. The empty string is a prefix of everything, and a complete
233
+ name can be a prefix of a longer complete name — see
234
+ [known defects](#known-defects), 3 and 4.
112
235
  - **No tie-breaking.** Ambiguity is always refused, never guessed.
113
236
 
114
237
  ### Refusal shape
@@ -117,26 +240,32 @@ If neither pass matches, the result is refused as unknown.
117
240
  |---|---|
118
241
  | `ambiguous` | every matching peer, in **qualified** form |
119
242
  | `unknown` | every peer in the listing, in **display** form |
243
+ | `self` | every peer the address *also* prefixed, in **qualified** form — so the caller is told which full name to type instead of guessing one exists |
120
244
 
121
245
  ## Known defects
122
246
 
123
- Preserved in 0.1.0 by decision, not by oversight. They are in the fixture so
247
+ Preserved by decision, not by oversight. They are in the fixture so
124
248
  that a second implementation matches Tin Can exactly, including where Tin Can is
125
249
  wrong. **Do not "correct" them independently** — that produces addresses that
126
250
  resolve in one tool and fail silently in the other.
127
251
 
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.
252
+ **1. `canonical_id` is not unique. — FIXED in 1.0.0.**
253
+
254
+ It used to carry the three-character suffix, so two peers whose slugs matched
255
+ *and* whose ids shared their last three hex characters produced the same
256
+ `canonical_id`. Tin Can then refused to resolve either, listing two identical
257
+ candidates — a refusal that told the caller to disambiguate using a string that
258
+ did not disambiguate. Both peers were unaddressable until one exited. Roughly 1
259
+ in 4096 given a slug collision, and slug collisions are not rare: Codex titles
260
+ threads from their first prompt.
133
261
 
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.
262
+ The canonical form now carries the whole durable id, which makes a collision
263
+ impossible rather than merely unlikely. Both peers stay addressable.
137
264
 
138
- **Consumers must therefore treat `canonical_id` as a display and correlation
139
- aid, not a primary key.** Key on `thread_id` / `session_id`.
265
+ **The short forms still collide**, and still should: `display` and `qualified`
266
+ are for humans, and two sessions genuinely sharing a name are genuinely
267
+ ambiguous. The difference is that there is now an unambiguous form to fall back
268
+ to. `canonical_id` is a usable key.
140
269
 
141
270
  **2. A name that slugifies to empty is inconsistent with an unnamed peer.**
142
271
  Alone in a listing, a peer named `???` displays as `thread` with no suffix,
@@ -149,6 +278,45 @@ string is a prefix of every slug, `resolve("")` against a single-peer listing
149
278
  returns that peer rather than refusing. With two or more peers it is refused as
150
279
  ambiguous, as expected.
151
280
 
281
+ **4. A full name that is a prefix of a longer full name resolves to the longer
282
+ one once the exactly-named session is gone.** While a session named `muster` is
283
+ listed, `muster` is an exact hit and pass 2 is never reached. The moment it
284
+ exits, `muster` is nobody's name and becomes merely a prefix of its neighbour
285
+ `muster-b1` — the only match, so it resolves, and the message is delivered to a
286
+ session the caller did not mean and reported as sent. Observed live between two
287
+ sessions; the send was a reply, so `reply_misrouted` caught it, but a new
288
+ message would have gone through.
289
+
290
+ This is not an exotic collision. It happens whenever a short name and a longer
291
+ one built on it coexist — `muster` / `muster-b1`, `ferry` / `ferry-9b`.
292
+
293
+ **It is kept because it cannot be fixed from the listing alone.** The hazard is
294
+ indistinguishable from the behaviour directly above it in this document: `auth`
295
+ → `auth-refactor` and `muster` → `muster-b1` are the same shape, the same single
296
+ match, and in both cases the input is nobody's current name. The only fact that
297
+ separates them is that a session named exactly `muster` *existed and has gone*,
298
+ and a peer listing cannot carry that fact, because the session's absence is the
299
+ whole premise. Any rule that refuses the second refuses the first: removing the
300
+ prefix pass is verifiable in one edit, and it fails
301
+ `resolve > unambiguous prefix resolves` alongside the defect's own case.
302
+
303
+ **The form that cannot misroute is `canonical_id`.** Pass 2 matches against the
304
+ slug and the qualified form, never against `canonicalId`, so a canonical id can
305
+ never prefix-match a stranger — it resolves to the session it names or refuses
306
+ as unknown:
307
+
308
+ ```
309
+ muster gone , resolve("muster") -> OK, muster-b1
310
+ muster gone , resolve("claude-code:muster.18ddaa21-…") -> REFUSED unknown
311
+ ```
312
+
313
+ A loud refusal is the outcome a caller wants here. **When you hold a session's
314
+ durable id, address it by `canonical_id` rather than by its bare name** — a
315
+ short name is for a human typing, not for a program that already knows exactly
316
+ which session it means. `expect_id` does not substitute: it is compared after
317
+ resolution and only ever rejects, so it guards the send without selecting the
318
+ recipient.
319
+
152
320
  ## Changing this format
153
321
 
154
322
  The address format is a contract between Tin Can and tools built against it. A
@@ -156,6 +324,14 @@ change to slugify, suffix derivation, collision handling, or resolution produces
156
324
  addresses that resolve in one tool and fail silently in the other — no error,
157
325
  just a message delivered nowhere.
158
326
 
327
+ **Who is built against this today:**
328
+ [Muster](https://github.com/BrutalSystems/muster) launches agent sessions and
329
+ returns a peer record Tin Can must resolve. It does not import Tin Can — the two
330
+ agree on this document and on `test/fixtures/canonical-id.json`, which Muster
331
+ copies verbatim. So a change here is not local to this repo, and the person
332
+ making it is responsible for coordinating the release, not for discovering
333
+ afterwards that the other tool disagreed.
334
+
159
335
  So:
160
336
 
161
337
  - **A change to any behaviour described here is a breaking change**, and ships
@@ -299,3 +299,185 @@ same-runtime exclusion, or Tin Can state. Its count assertion applies only to
299
299
  the matching canonical ID in the isolated test environment. Naming hashes do
300
300
  not cover listing membership, self-exclusion, or state semantics. A future
301
301
  upgrade must test these behaviors separately where relied upon.
302
+
303
+ ## Re-sync to Tin Can 1.5.1 — 2026-09-24
304
+
305
+ Both contract copies were replaced wholesale from Tin Can at tag `v1.5.1`,
306
+ commit `b62ca6a719131dbd1b43875fa47604c1c17cd9fb`. This ends the 0.1.0-vintage
307
+ freeze described above: Muster had never been verified against any Tin Can 1.x,
308
+ and the contract suite passed only because `RELEASING.md` pinned it to 0.6.4.
309
+
310
+ | File | SHA-256 |
311
+ | --- | --- |
312
+ | `CANONICAL_ID.md` | `7ec8857238262ff49277488a515f830078df25451cff11dad69c0b6aa820dfa5` |
313
+ | `test/fixtures/canonical-id.json` | `dde6ce24bf259fc0a44685f08fc724232968cd9ec3c839802cd2b37de702a85a` |
314
+
315
+ The fixture carries `tincan_version: 1.5.1` and 39 cases: 8 slugify, 7 suffix,
316
+ 12 assignment, 12 resolution. These hashes are the same numbers asserted by
317
+ `test/naming.test.ts` ("contract copies remain frozen"); the two sites must
318
+ always agree, and the earlier divergence between this document's recorded
319
+ fixture hash and the test's is what #48 reports.
320
+
321
+ **Why the tag and not HEAD.** Tin Can's HEAD was one commit past `v1.5.1` when
322
+ this was copied, and the three contract-relevant files were byte-identical
323
+ between the two — confirmed from both sides. HEAD has since gained a 40th
324
+ resolve case, but the fixture's `tincan_version` still reads 1.5.1 because
325
+ those version sites only move on a cut. Copying HEAD and recording it as 1.5.1
326
+ would manufacture exactly the provenance confusion this document exists to
327
+ prevent, so the tag is the acquisition point and the 40th case is a follow-up.
328
+
329
+ **What the 40th case is, and why it matters here.** It freezes a known defect
330
+ Muster reported upstream and Tin Can confirmed as
331
+ [tincan#39](https://github.com/BrutalSystems/tincan/issues/39): a full name
332
+ that is a prefix of a longer full name resolves to the longer one once the
333
+ exactly-named session has gone — `muster` lands on `muster-b1`. Tin Can chose
334
+ to document it rather than change resolution, because no rule can separate it
335
+ from fixture case [4] (`auth` resolving to `auth-refactor`) using anything a
336
+ peer listing carries; an implementation of the stricter rule failed both the
337
+ new case and the existing one. Muster reimplements resolution, so it inherits
338
+ the defect by construction. Adopt the frozen case when it is cut; do not
339
+ special-case it locally, because a local rule is drift by another name.
340
+
341
+ `canonical_id` is the form that cannot misroute — resolution matches it
342
+ exactly and never by prefix. Prefer it over a bare name wherever a durable id
343
+ is already in hand.
344
+
345
+ ## Superseded same day: re-sync to Tin Can 1.6.0 — 2026-09-24
346
+
347
+ The 1.5.1 acquisition above **never shipped**. Its release, 0.9.0, failed in CI
348
+ before publishing (see below), and Tin Can cut 1.6.0 while that was being
349
+ fixed. The copies are now from tag `v1.6.0`, commit
350
+ `3d66ceaa718a098bb9e962282d901aff03dd47ff`.
351
+
352
+ | File | SHA-256 |
353
+ | --- | --- |
354
+ | `CANONICAL_ID.md` | `06dd8afe83fa641183fb44b2625dbc684c2bed13c9ac5c8f51a6c1206a00da3e` |
355
+ | `test/fixtures/canonical-id.json` | `a574fe291a515d920aab07f29629100caadb4fc8d8e8130a0851559bf2384aac` |
356
+
357
+ `tincan_version: 1.6.0`, 40 cases: 8 slugify, 7 suffix, 12 assignment, 13
358
+ resolution. This removes the compromise the 1.5.1 section describes — the
359
+ fixture is now labelled for the release that contains it, so the acquisition
360
+ point and the version metadata agree, and no follow-up is outstanding.
361
+
362
+ The 40th case freezes the prefix-resolution defect
363
+ ([tincan#39](https://github.com/BrutalSystems/tincan/issues/39)) that Muster
364
+ reported. `naming.ts`, written against the 39-case fixture, passes it
365
+ unchanged — no expected value moved, so this is not an address-format change.
366
+
367
+ **The case was verified to have teeth rather than assumed to.** A case that has
368
+ never failed has not been shown to test anything. Implementing the stricter
369
+ rule it documents — refuse a prefix that ends at a segment boundary — fails the
370
+ new case *and* `resolve: unambiguous prefix resolves` together, in this copy.
371
+ That pair failing together is the argument for Tin Can's decision to document
372
+ the defect rather than change resolution, and it reproduces here.
373
+
374
+ ## Re-sync to Tin Can 1.7.0 — 2026-09-24
375
+
376
+ The copies are now from tag `v1.7.0`, commit
377
+ `f2aaeb20ea278c229afe955db59f69301fc6626f`.
378
+
379
+ | File | SHA-256 |
380
+ | --- | --- |
381
+ | `CANONICAL_ID.md` | `8d5e93894e5c43cc854330f7b64ebbdcc450649cfa910c8b4015edf2559125e7` |
382
+ | `test/fixtures/canonical-id.json` | `1db118b22484271932e7cde33c1c80b469563f18f2961532d6910d79ce1e4c3b` |
383
+
384
+ `tincan_version: 1.7.0`, still 40 cases: 8 slugify, 7 suffix, 12 assignment, 13
385
+ resolution. **No expected value moved and no case was added.** Diffed
386
+ `v1.6.0..v1.7.0` upstream before copying: `src/naming.ts` does not appear in the
387
+ diff at all, the fixture's only changed line is its `tincan_version`, and
388
+ `CANONICAL_ID.md` gains fourteen lines of warning with no normative change. So
389
+ `naming.ts` is untouched here and the contract suite is expected to stay 4/4
390
+ without it — this is a re-label plus a doc, not an address-format change.
391
+
392
+ What the doc gains is the hazard this repo hit and reported: recovering the
393
+ three-hex suffix by taking a canonical id's last dot-separated segment returned
394
+ the suffix before 1.0.0 and returns the whole uuid now, so every qualified
395
+ `slug.suffix` address resolves as `unknown` — a missing peer, not a malformed
396
+ address, which is why it stayed quiet here until a test was written for it.
397
+ Tin Can names Muster as where it was found. The rule is to derive the suffix
398
+ from the uuid via `suffixOf` and otherwise pass a canonical id along whole
399
+ rather than parse fields out of it.
400
+
401
+ **A re-sync touches three sites, not two.** Both Tin Can pins —
402
+ `RELEASING.md` and `.github/workflows/publish.yml` — plus the SHA-256 drift
403
+ hashes in `test/naming.test.ts` ("contract copies remain frozen"), which must
404
+ move whenever a copied file changes. The two pins are the ones handed down in
405
+ conversation; the hashes were found by grepping for the old version and the old
406
+ digests rather than by working from that list, so grep the repo instead of
407
+ trusting an enumeration. That advice earned itself twice over: this very
408
+ enumeration missed `README.md`, which carried a literal baseline of its own —
409
+ recorded below.
410
+
411
+ Moving only the first pin is what made v0.9.0 an inert tag that published
412
+ nothing. `test/workflow-npm-pin.test.ts` now fails when the two pins disagree,
413
+ or when the `publish.yml` pin and the fixture's `tincan_version` disagree, and
414
+ that guard was re-confirmed by mutation here rather than assumed: reverting
415
+ `publish.yml` alone to 1.6.0 fails both assertions, and restoring it passes
416
+ them. A stale drift hash fails `npm run check` on its own.
417
+
418
+ ### The pin sites became one derived value
419
+
420
+ Recording the fix rather than only the incident. The two Tin Can pins no longer
421
+ restate a version; both compute it from this fixture's `tincan_version`. That
422
+ field is the single place saying which Tin Can the vendored copies describe, so
423
+ a pin can no longer lag it — the v0.9.0 failure class is closed structurally
424
+ instead of guarded after the fact.
425
+
426
+ The guard that replaced the old agreement tests asserts the derivation
427
+ expression itself, not the word `tincan_version`. The first version of it
428
+ checked for the word and **passed against a `publish.yml` whose command had
429
+ been mutated to `ver="1.6.0"`**, because the prose explaining the derivation
430
+ contains that word too. It was caught by running the mutation rather than by
431
+ reading the test. A guard that cannot fail is the thing this document exists to
432
+ prevent, so the failure is written down next to the fix.
433
+
434
+ ### README.md was a fourth site, and had already gone stale
435
+
436
+ The enumeration above missed it. `README.md`'s Verification section named its own
437
+ baseline — **Tin Can 0.6.4** in prose, and `@brutalsystems/tincan@0.6.4` in a
438
+ copy-paste command — and had drifted unnoticed while the vendored copies moved to
439
+ 1.6.0 and then 1.9.2. Nothing failed, because it gates nothing: it is
440
+ instructions for a human, so the cost was silence rather than an inert tag.
441
+
442
+ That makes it worse than a failing pin, not better. Anyone following the README
443
+ installed a Tin Can four minors behind the one the fixture describes and watched
444
+ the contract suite pass 4/4 — which reads as confirmation that the contract holds
445
+ at a version nobody vendored.
446
+
447
+ Fixed the same way as the other two sites: the command derives `ver` from the
448
+ fixture, and the prose points at `tincan_version` rather than naming a release.
449
+ `test/workflow-npm-pin.test.ts` now includes `README.md` in the derivation loop,
450
+ and adds one assertion that its Verification section names no Tin Can version in
451
+ prose at all, because prose restates a pin as easily as a command does. Both were
452
+ watched failing against the stale README before anything was changed — the
453
+ literal-pin assertion on the command, and `expected 'Tin Can 0.6.4' to be
454
+ undefined` on the prose — and the derived command was then extracted from the
455
+ README and run end to end, installing 1.9.2 and passing the contract suite 4/4.
456
+
457
+ The prose assertion is scoped to `## Verification` on purpose. Elsewhere the
458
+ README records what was observed at a particular Tin Can — "Tin Can 0.6.0
459
+ delivers over v1, so nothing here depends on the v2 path" — which is a historical
460
+ claim and stays pinned to its version deliberately. A whole-file regex would fail
461
+ on those and teach the next person to delete the guard.
462
+
463
+ ## Re-sync to Tin Can 1.9.2 — 2026-09-25
464
+
465
+ Copies are from tag `v1.9.2`.
466
+
467
+ | File | SHA-256 |
468
+ | --- | --- |
469
+ | `CANONICAL_ID.md` | `6e0dde03154956a480ae59f4d31b727ecc5a9e735c185fed6e4445f2574263a9` |
470
+ | `test/fixtures/canonical-id.json` | `b91406e068f6bec18629a4d2b9e91bc4d2e5a0487e4b6b4534fd5ba37c7f617a` |
471
+
472
+ `tincan_version: 1.9.2`, still 40 cases. Diffing `v1.7.0..v1.9.2` upstream, the
473
+ ONLY change across all three contract files is the version string in two lines —
474
+ `naming.ts` does not appear, and no fixture case or expected value moved. 1.8.0
475
+ (log chaining, arrival record), 1.9.0 (version reporting in `peers`), 1.9.1 and
476
+ 1.9.2 (registration drift) do not touch the address format. Verified here rather
477
+ than taken from upstream's word: they explicitly said not to treat their summary
478
+ as a re-copy signal.
479
+
480
+ **This re-sync needed no pin edit.** The pins derive from `tincan_version`, so
481
+ updating the fixture moved CI on its own — the first cut where the failure class
482
+ that produced v0.9.0 had nothing to act on. The drift hashes still moved, which
483
+ is the third site behaving exactly as documented above.
package/README.md CHANGED
@@ -1024,19 +1024,25 @@ The ordinary suite requires tmux, node-pty, POSIX `ps`/`lsof`, and Python 3 for
1024
1024
  real file locks in the fake Codex executable. It uses isolated runtime homes and
1025
1025
  no model APIs. It skips the four explicitly invoked compatibility cases.
1026
1026
 
1027
- The verified contract baseline is **Tin Can 0.6.4**. The suite spawns an installed binary as an MCP
1028
- subprocess, compares durable IDs, and verifies delivery to fake runtimes through
1029
- both terminal hosts. A missing binary or missing durable field fails the test.
1030
- For reproducible verification, install that exact release outside Muster:
1027
+ The verified contract baseline is the Tin Can named by `tincan_version` in the
1028
+ frozen fixture, `test/fixtures/canonical-id.json`. That field is the single place
1029
+ recording which release the vendored copies describe, and both `RELEASING.md` and
1030
+ `publish.yml` derive their pin from it. The suite spawns an installed binary as
1031
+ an MCP subprocess, compares durable IDs, and verifies delivery to fake runtimes
1032
+ through both terminal hosts. A missing binary or missing durable field fails the
1033
+ test. For reproducible verification, install that exact release outside Muster —
1034
+ derived here too, rather than typed, so this command cannot name a release the
1035
+ fixture has moved past:
1031
1036
 
1032
1037
  ```sh
1033
1038
  contract_dir=$(mktemp -d)
1034
- npm install --prefix "$contract_dir" --no-save @brutalsystems/tincan@0.6.4
1039
+ ver=$(node -p "require('./test/fixtures/canonical-id.json').tincan_version")
1040
+ npm install --prefix "$contract_dir" --no-save "@brutalsystems/tincan@$ver"
1035
1041
  MUSTER_TINCAN_BIN="$contract_dir/node_modules/.bin/tincan" npm run test:contract
1036
1042
  ```
1037
1043
 
1038
- Verification uses the published 0.6.4 release installed in an isolated
1039
- temporary directory. No Tin Can source edits are made.
1044
+ Verification uses that published release installed in an isolated temporary
1045
+ directory. No Tin Can source edits are made.
1040
1046
  Fixture version metadata alone does not indicate address-format drift: compare
1041
1047
  the case arrays. Those arrays cover naming only, not peer-list membership or
1042
1048
  state semantics. Upgrades require separate compatibility verification; passing
package/dist/naming.js CHANGED
@@ -19,32 +19,52 @@ export function suffixOf(uuid) {
19
19
  .slice(-3)
20
20
  .toLowerCase();
21
21
  }
22
+ /** `@machine`, or nothing at all for a peer on this machine. */
23
+ function at(machine) {
24
+ return machine === undefined || machine === "" ? "" : `@${slugify(machine)}`;
25
+ }
22
26
  export function assignNames(peers) {
23
27
  const slugs = peers.map((peer) => {
24
28
  const s = peer.rawName ? slugify(peer.rawName) : "";
25
29
  return s.length > 0 ? s : "thread";
26
30
  });
31
+ // Collisions are counted per machine. The same slug on two computers is two
32
+ // different addresses already, so suffixing both would add noise to
33
+ // distinguish things that were never confusable.
27
34
  const counts = new Map();
28
- for (const s of slugs)
29
- counts.set(s, (counts.get(s) ?? 0) + 1);
35
+ for (const [i, s] of slugs.entries()) {
36
+ const key = `${s}${at(peers[i].machine)}`;
37
+ counts.set(key, (counts.get(key) ?? 0) + 1);
38
+ }
30
39
  return peers.map((peer, i) => {
31
40
  const slug = slugs[i];
32
41
  const suffix = suffixOf(peer.uuid);
33
- const qualified = `${slug}.${suffix}`;
42
+ const host = at(peer.machine);
43
+ const qualified = `${slug}.${suffix}${host}`;
34
44
  // An unnamed thread has no name to stand on, so it always carries its suffix.
35
- const collides = (counts.get(slug) ?? 0) > 1 || peer.rawName === null;
45
+ const collides = (counts.get(`${slug}${host}`) ?? 0) > 1 || peer.rawName === null;
36
46
  return {
37
47
  ...peer,
38
48
  slug,
39
49
  suffix,
40
- canonicalId: `${peer.runtime}:${qualified}`,
41
- display: collides ? qualified : slug,
50
+ canonicalId: `${peer.runtime}:${slug}.${peer.uuid}${host}`,
51
+ display: collides ? qualified : `${slug}${host}`,
42
52
  };
43
53
  });
44
54
  }
45
55
  export function resolvePeer(peers, input) {
46
56
  const q = input.trim().toLowerCase();
47
- const qualified = (p) => `${p.slug}.${p.suffix}`;
57
+ const qualified = (p) => `${p.slug}.${p.suffix}${at(p.machine)}`;
58
+ // A bare address names a peer on THIS machine. Prefix matching is what makes
59
+ // that matter: without this gate an address with no `@` could fall through
60
+ // and resolve to a session on another computer, so a message meant for a
61
+ // peer at this desk would leave the machine and be reported as sent. The
62
+ // dangerous direction has to be the explicit one.
63
+ const wantsMachine = q.includes("@");
64
+ peers = peers.filter((p) => {
65
+ const remote = p.machine !== undefined && p.machine !== "";
66
+ return wantsMachine ? remote : !remote;
67
+ });
48
68
  const exact = peers.filter((p) => p.display.toLowerCase() === q ||
49
69
  qualified(p) === q ||
50
70
  p.canonicalId.toLowerCase() === q);
package/dist/run.js CHANGED
@@ -26,7 +26,7 @@ import { codexPolicyArgs } from "./codex-policy.js";
26
26
  import { CodexRpc } from "./codex-rpc.js";
27
27
  import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
28
28
  import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
29
- import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
29
+ import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
30
30
  import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
31
31
  import { pollInterval } from "./reap.js";
32
32
  import { selectPlugins } from "./plugins.js";
@@ -783,7 +783,12 @@ export class Muster {
783
783
  slug: String(e.peer.canonical_id)
784
784
  .split(":")[1]
785
785
  .replace(/\.[^.]*$/, ""),
786
- suffix: String(e.peer.canonical_id).split(".").at(-1),
786
+ // From the durable id, not from the canonical id's last dot-segment.
787
+ // That segment used to BE the three-hex suffix; since Tin Can 1.0.0 the
788
+ // canonical form carries the whole uuid, so the old surgery yielded the
789
+ // entire id and every qualified `slug.suffix` address stopped resolving.
790
+ // It failed quietly, as "unknown", which is why it needed a test.
791
+ suffix: suffixOf(e.id),
787
792
  canonicalId: String(e.peer.canonical_id),
788
793
  display: String(e.peer.name),
789
794
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.8.3",
3
+ "version": "0.9.2",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,161 +1,590 @@
1
1
  {
2
2
  "$comment": "FROZEN. Describes @brutalsystems/tincan address behaviour as shipped. Adding cases is fine; changing an expected value means the address format changed - see CANONICAL_ID.md, 'Changing this format'.",
3
- "tincan_version": "0.1.0",
3
+ "tincan_version": "1.9.2",
4
4
  "spec": "CANONICAL_ID.md",
5
-
6
5
  "slugify": [
7
- { "case": "spaces and case are folded", "input": "Auth Refactor", "output": "auth-refactor" },
8
- { "case": "runs of punctuation collapse to one dash", "input": "auth/refactor (v2)", "output": "auth-refactor-v2" },
9
- { "case": "leading and trailing separators are stripped", "input": " --Auth-- ", "output": "auth" },
10
- { "case": "non-ascii is not transliterated, it is a separator", "input": "café ☕ time", "output": "caf-time" },
11
- { "case": "digits survive", "input": "123", "output": "123" },
12
- { "case": "all-caps folds", "input": "ALLCAPS", "output": "allcaps" },
13
- { "case": "a name of only punctuation slugifies to empty", "input": "???", "output": "" },
14
- { "case": "empty in, empty out", "input": "", "output": "" }
6
+ {
7
+ "case": "spaces and case are folded",
8
+ "input": "Auth Refactor",
9
+ "output": "auth-refactor"
10
+ },
11
+ {
12
+ "case": "runs of punctuation collapse to one dash",
13
+ "input": "auth/refactor (v2)",
14
+ "output": "auth-refactor-v2"
15
+ },
16
+ {
17
+ "case": "leading and trailing separators are stripped",
18
+ "input": " --Auth-- ",
19
+ "output": "auth"
20
+ },
21
+ {
22
+ "case": "non-ascii is not transliterated, it is a separator",
23
+ "input": "café ☕ time",
24
+ "output": "caf-time"
25
+ },
26
+ {
27
+ "case": "digits survive",
28
+ "input": "123",
29
+ "output": "123"
30
+ },
31
+ {
32
+ "case": "all-caps folds",
33
+ "input": "ALLCAPS",
34
+ "output": "allcaps"
35
+ },
36
+ {
37
+ "case": "a name of only punctuation slugifies to empty",
38
+ "input": "???",
39
+ "output": ""
40
+ },
41
+ {
42
+ "case": "empty in, empty out",
43
+ "input": "",
44
+ "output": ""
45
+ }
15
46
  ],
16
-
17
47
  "suffix": [
18
- { "case": "codex thread id (uuidv7): last three hex", "input": "01a0b9b4-a33e-7ab1-80a0-bb715504a0fb", "output": "0fb" },
19
- { "case": "claude session id (uuidv4): last three hex", "input": "5af69d42-2214-41d9-b13f-9c3177eb60ce", "output": "0ce" },
20
- { "case": "uppercase hex is lowercased", "input": "...ABC", "output": "abc" },
21
- { "case": "non-hex characters are stripped before slicing", "input": "z-9", "output": "9" },
22
- { "case": "no hex at all yields an empty suffix", "input": "zzzz", "output": "" },
23
- { "case": "empty in, empty out", "input": "", "output": "" }
48
+ {
49
+ "case": "codex thread id (uuidv7): last three hex",
50
+ "input": "01a0b9b4-a33e-7ab1-80a0-bb715504a0fb",
51
+ "output": "0fb"
52
+ },
53
+ {
54
+ "case": "claude session id (uuidv4): last three hex",
55
+ "input": "5af69d42-2214-41d9-b13f-9c3177eb60ce",
56
+ "output": "0ce"
57
+ },
58
+ {
59
+ "case": "opencode session id (ses_ + hex timestamp + base62 random tail): last three hex",
60
+ "input": "ses_f41a2b3c4ffeExampleSess01Z",
61
+ "output": "e01"
62
+ },
63
+ {
64
+ "case": "uppercase hex is lowercased",
65
+ "input": "...ABC",
66
+ "output": "abc"
67
+ },
68
+ {
69
+ "case": "non-hex characters are stripped before slicing",
70
+ "input": "z-9",
71
+ "output": "9"
72
+ },
73
+ {
74
+ "case": "no hex at all yields an empty suffix",
75
+ "input": "zzzz",
76
+ "output": ""
77
+ },
78
+ {
79
+ "case": "empty in, empty out",
80
+ "input": "",
81
+ "output": ""
82
+ }
24
83
  ],
25
-
26
84
  "assign": [
27
85
  {
28
86
  "case": "no collision: display is the bare slug, canonical id is always qualified",
29
- "peers": [ { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
30
- "expect": [ { "display": "auth-refactor", "canonicalId": "codex:auth-refactor.7f3" } ]
87
+ "peers": [
88
+ {
89
+ "runtime": "codex",
90
+ "rawName": "Auth refactor",
91
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
92
+ }
93
+ ],
94
+ "expect": [
95
+ {
96
+ "display": "auth-refactor",
97
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
98
+ }
99
+ ]
31
100
  },
32
101
  {
33
102
  "case": "claude peers are qualified with their runtime",
34
- "peers": [ { "runtime": "claude-code", "rawName": "billing-api", "uuid": "5af69d42-2214-41d9-b13f-9c3177eb60ce" } ],
35
- "expect": [ { "display": "billing-api", "canonicalId": "claude-code:billing-api.0ce" } ]
103
+ "peers": [
104
+ {
105
+ "runtime": "claude-code",
106
+ "rawName": "billing-api",
107
+ "uuid": "5af69d42-2214-41d9-b13f-9c3177eb60ce"
108
+ }
109
+ ],
110
+ "expect": [
111
+ {
112
+ "display": "billing-api",
113
+ "canonicalId": "claude-code:billing-api.5af69d42-2214-41d9-b13f-9c3177eb60ce"
114
+ }
115
+ ]
116
+ },
117
+ {
118
+ "case": "opencode peer, no collision: display is the bare slug",
119
+ "peers": [
120
+ {
121
+ "runtime": "opencode",
122
+ "rawName": "nimble-wizard",
123
+ "uuid": "ses_7f6e5d4c3b2aNimbleWizardQ7"
124
+ }
125
+ ],
126
+ "expect": [
127
+ {
128
+ "display": "nimble-wizard",
129
+ "canonicalId": "opencode:nimble-wizard.ses_7f6e5d4c3b2aNimbleWizardQ7"
130
+ }
131
+ ]
132
+ },
133
+ {
134
+ "case": "collision between two opencode sessions with the same slug: both suffixed",
135
+ "peers": [
136
+ {
137
+ "runtime": "opencode",
138
+ "rawName": "swift-otter",
139
+ "uuid": "ses_8e7d6c5b4a39SwiftOtterAlpha"
140
+ },
141
+ {
142
+ "runtime": "opencode",
143
+ "rawName": "swift-otter",
144
+ "uuid": "ses_8e7d6c5b4a39SwiftOtterBeta7"
145
+ }
146
+ ],
147
+ "expect": [
148
+ {
149
+ "display": "swift-otter.eaa",
150
+ "canonicalId": "opencode:swift-otter.ses_8e7d6c5b4a39SwiftOtterAlpha"
151
+ },
152
+ {
153
+ "display": "swift-otter.ea7",
154
+ "canonicalId": "opencode:swift-otter.ses_8e7d6c5b4a39SwiftOtterBeta7"
155
+ }
156
+ ]
157
+ },
158
+ {
159
+ "case": "cross-runtime collision: an opencode peer and a codex peer sharing a slug both flip to suffixed",
160
+ "peers": [
161
+ {
162
+ "runtime": "opencode",
163
+ "rawName": "auth-refactor",
164
+ "uuid": "ses_9f8e7d6c5b4aAuthRefactorX9"
165
+ },
166
+ {
167
+ "runtime": "codex",
168
+ "rawName": "Auth refactor",
169
+ "uuid": "01b1c2d3-e4f5-7a6b-90c1-d2e3f4a5b6c7"
170
+ }
171
+ ],
172
+ "expect": [
173
+ {
174
+ "display": "auth-refactor.ac9",
175
+ "canonicalId": "opencode:auth-refactor.ses_9f8e7d6c5b4aAuthRefactorX9"
176
+ },
177
+ {
178
+ "display": "auth-refactor.6c7",
179
+ "canonicalId": "codex:auth-refactor.01b1c2d3-e4f5-7a6b-90c1-d2e3f4a5b6c7"
180
+ }
181
+ ]
36
182
  },
37
183
  {
38
184
  "case": "collision: every colliding peer is suffixed, and only those",
39
185
  "peers": [
40
- { "runtime": "codex", "rawName": "Review phase 1 handoff", "uuid": "00000000-0000-0000-0000-000000000aaa" },
41
- { "runtime": "codex", "rawName": "Review phase-1 handoff", "uuid": "00000000-0000-0000-0000-000000000bbb" },
42
- { "runtime": "codex", "rawName": "Something else", "uuid": "00000000-0000-0000-0000-000000000ccc" }
186
+ {
187
+ "runtime": "codex",
188
+ "rawName": "Review phase 1 handoff",
189
+ "uuid": "00000000-0000-0000-0000-000000000aaa"
190
+ },
191
+ {
192
+ "runtime": "codex",
193
+ "rawName": "Review phase-1 handoff",
194
+ "uuid": "00000000-0000-0000-0000-000000000bbb"
195
+ },
196
+ {
197
+ "runtime": "codex",
198
+ "rawName": "Something else",
199
+ "uuid": "00000000-0000-0000-0000-000000000ccc"
200
+ }
43
201
  ],
44
202
  "expect": [
45
- { "display": "review-phase-1-handoff.aaa", "canonicalId": "codex:review-phase-1-handoff.aaa" },
46
- { "display": "review-phase-1-handoff.bbb", "canonicalId": "codex:review-phase-1-handoff.bbb" },
47
- { "display": "something-else", "canonicalId": "codex:something-else.ccc" }
203
+ {
204
+ "display": "review-phase-1-handoff.aaa",
205
+ "canonicalId": "codex:review-phase-1-handoff.00000000-0000-0000-0000-000000000aaa"
206
+ },
207
+ {
208
+ "display": "review-phase-1-handoff.bbb",
209
+ "canonicalId": "codex:review-phase-1-handoff.00000000-0000-0000-0000-000000000bbb"
210
+ },
211
+ {
212
+ "display": "something-else",
213
+ "canonicalId": "codex:something-else.00000000-0000-0000-0000-000000000ccc"
214
+ }
48
215
  ]
49
216
  },
50
217
  {
51
218
  "case": "an unnamed thread always carries its suffix, even alone",
52
- "peers": [ { "runtime": "codex", "rawName": null, "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
53
- "expect": [ { "display": "thread.7f3", "canonicalId": "codex:thread.7f3" } ]
219
+ "peers": [
220
+ {
221
+ "runtime": "codex",
222
+ "rawName": null,
223
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
224
+ }
225
+ ],
226
+ "expect": [
227
+ {
228
+ "display": "thread.7f3",
229
+ "canonicalId": "codex:thread.00000000-0000-0000-0000-0000000007f3"
230
+ }
231
+ ]
54
232
  },
55
233
  {
56
234
  "case": "KNOWN DEFECT: a name that slugifies to empty displays bare, unlike an unnamed thread",
57
- "peers": [ { "runtime": "codex", "rawName": "???", "uuid": "00000000-0000-0000-0000-000000000def" } ],
58
- "expect": [ { "display": "thread", "canonicalId": "codex:thread.def" } ]
235
+ "peers": [
236
+ {
237
+ "runtime": "codex",
238
+ "rawName": "???",
239
+ "uuid": "00000000-0000-0000-0000-000000000def"
240
+ }
241
+ ],
242
+ "expect": [
243
+ {
244
+ "display": "thread",
245
+ "canonicalId": "codex:thread.00000000-0000-0000-0000-000000000def"
246
+ }
247
+ ]
59
248
  },
60
249
  {
61
250
  "case": "unnamed and slugifies-to-empty collide on 'thread', so both are suffixed",
62
251
  "peers": [
63
- { "runtime": "codex", "rawName": null, "uuid": "00000000-0000-0000-0000-000000000aaa" },
64
- { "runtime": "codex", "rawName": "???", "uuid": "00000000-0000-0000-0000-000000000bbb" }
252
+ {
253
+ "runtime": "codex",
254
+ "rawName": null,
255
+ "uuid": "00000000-0000-0000-0000-000000000aaa"
256
+ },
257
+ {
258
+ "runtime": "codex",
259
+ "rawName": "???",
260
+ "uuid": "00000000-0000-0000-0000-000000000bbb"
261
+ }
65
262
  ],
66
263
  "expect": [
67
- { "display": "thread.aaa", "canonicalId": "codex:thread.aaa" },
68
- { "display": "thread.bbb", "canonicalId": "codex:thread.bbb" }
264
+ {
265
+ "display": "thread.aaa",
266
+ "canonicalId": "codex:thread.00000000-0000-0000-0000-000000000aaa"
267
+ },
268
+ {
269
+ "display": "thread.bbb",
270
+ "canonicalId": "codex:thread.00000000-0000-0000-0000-000000000bbb"
271
+ }
69
272
  ]
70
273
  },
71
274
  {
72
275
  "case": "KNOWN DEFECT: canonical_id is not unique when slug and suffix both collide",
73
276
  "peers": [
74
- { "runtime": "codex", "rawName": "Auth refactor", "uuid": "11111111-0000-0000-0000-000000000abc" },
75
- { "runtime": "codex", "rawName": "auth-refactor", "uuid": "22222222-0000-0000-0000-000000000abc" }
277
+ {
278
+ "runtime": "codex",
279
+ "rawName": "Auth refactor",
280
+ "uuid": "11111111-0000-0000-0000-000000000abc"
281
+ },
282
+ {
283
+ "runtime": "codex",
284
+ "rawName": "auth-refactor",
285
+ "uuid": "22222222-0000-0000-0000-000000000abc"
286
+ }
287
+ ],
288
+ "expect": [
289
+ {
290
+ "display": "auth-refactor.abc",
291
+ "canonicalId": "codex:auth-refactor.11111111-0000-0000-0000-000000000abc"
292
+ },
293
+ {
294
+ "display": "auth-refactor.abc",
295
+ "canonicalId": "codex:auth-refactor.22222222-0000-0000-0000-000000000abc"
296
+ }
297
+ ]
298
+ },
299
+ {
300
+ "case": "a peer on another machine carries @machine in both forms",
301
+ "peers": [
302
+ {
303
+ "runtime": "codex",
304
+ "rawName": "Auth refactor",
305
+ "uuid": "00000000-0000-0000-0000-0000000007f3",
306
+ "machine": "m4pro"
307
+ }
308
+ ],
309
+ "expect": [
310
+ {
311
+ "display": "auth-refactor@m4pro",
312
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3@m4pro"
313
+ }
314
+ ]
315
+ },
316
+ {
317
+ "case": "the same slug on two machines does not collide",
318
+ "peers": [
319
+ {
320
+ "runtime": "codex",
321
+ "rawName": "Auth refactor",
322
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
323
+ },
324
+ {
325
+ "runtime": "codex",
326
+ "rawName": "Auth refactor",
327
+ "uuid": "00000000-0000-0000-0000-0000000007f3",
328
+ "machine": "m4pro"
329
+ }
76
330
  ],
77
331
  "expect": [
78
- { "display": "auth-refactor.abc", "canonicalId": "codex:auth-refactor.abc" },
79
- { "display": "auth-refactor.abc", "canonicalId": "codex:auth-refactor.abc" }
332
+ {
333
+ "display": "auth-refactor",
334
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
335
+ },
336
+ {
337
+ "display": "auth-refactor@m4pro",
338
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3@m4pro"
339
+ }
80
340
  ]
81
341
  }
82
342
  ],
83
-
84
343
  "resolve": [
85
344
  {
86
345
  "case": "exact display name",
87
- "peers": [ { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
346
+ "peers": [
347
+ {
348
+ "runtime": "codex",
349
+ "rawName": "Auth refactor",
350
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
351
+ }
352
+ ],
88
353
  "input": "auth-refactor",
89
- "expect": { "ok": true, "canonicalId": "codex:auth-refactor.7f3" }
354
+ "expect": {
355
+ "ok": true,
356
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
357
+ }
90
358
  },
91
359
  {
92
360
  "case": "qualified form, accepted even with no collision",
93
- "peers": [ { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
361
+ "peers": [
362
+ {
363
+ "runtime": "codex",
364
+ "rawName": "Auth refactor",
365
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
366
+ }
367
+ ],
94
368
  "input": "auth-refactor.7f3",
95
- "expect": { "ok": true, "canonicalId": "codex:auth-refactor.7f3" }
369
+ "expect": {
370
+ "ok": true,
371
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
372
+ }
96
373
  },
97
374
  {
98
375
  "case": "fully qualified canonical id is accepted as input",
99
- "peers": [ { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
100
- "input": "codex:auth-refactor.7f3",
101
- "expect": { "ok": true, "canonicalId": "codex:auth-refactor.7f3" }
376
+ "peers": [
377
+ {
378
+ "runtime": "codex",
379
+ "rawName": "Auth refactor",
380
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
381
+ }
382
+ ],
383
+ "input": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3",
384
+ "expect": {
385
+ "ok": true,
386
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
387
+ }
102
388
  },
103
389
  {
104
390
  "case": "case-insensitive, and input is trimmed",
105
- "peers": [ { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
391
+ "peers": [
392
+ {
393
+ "runtime": "codex",
394
+ "rawName": "Auth refactor",
395
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
396
+ }
397
+ ],
106
398
  "input": " AUTH-REFACTOR.7F3 ",
107
- "expect": { "ok": true, "canonicalId": "codex:auth-refactor.7f3" }
399
+ "expect": {
400
+ "ok": true,
401
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
402
+ }
108
403
  },
109
404
  {
110
405
  "case": "unambiguous prefix resolves",
111
406
  "peers": [
112
- { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" },
113
- { "runtime": "codex", "rawName": "Billing sync", "uuid": "00000000-0000-0000-0000-000000000b12" }
407
+ {
408
+ "runtime": "codex",
409
+ "rawName": "Auth refactor",
410
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
411
+ },
412
+ {
413
+ "runtime": "codex",
414
+ "rawName": "Billing sync",
415
+ "uuid": "00000000-0000-0000-0000-000000000b12"
416
+ }
114
417
  ],
115
418
  "input": "auth",
116
- "expect": { "ok": true, "canonicalId": "codex:auth-refactor.7f3" }
419
+ "expect": {
420
+ "ok": true,
421
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
422
+ }
117
423
  },
118
424
  {
119
425
  "case": "ambiguous prefix is refused, listing every candidate in qualified form",
120
426
  "peers": [
121
- { "runtime": "codex", "rawName": "Billing sync", "uuid": "00000000-0000-0000-0000-000000000b12" },
122
- { "runtime": "codex", "rawName": "Billing report", "uuid": "00000000-0000-0000-0000-000000000b34" }
427
+ {
428
+ "runtime": "codex",
429
+ "rawName": "Billing sync",
430
+ "uuid": "00000000-0000-0000-0000-000000000b12"
431
+ },
432
+ {
433
+ "runtime": "codex",
434
+ "rawName": "Billing report",
435
+ "uuid": "00000000-0000-0000-0000-000000000b34"
436
+ }
123
437
  ],
124
438
  "input": "billing",
125
- "expect": { "ok": false, "reason": "ambiguous", "candidates": ["billing-report.b34", "billing-sync.b12"] }
439
+ "expect": {
440
+ "ok": false,
441
+ "reason": "ambiguous",
442
+ "candidates": [
443
+ "billing-sync.b12",
444
+ "billing-report.b34"
445
+ ]
446
+ }
126
447
  },
127
448
  {
128
449
  "case": "unknown name is refused, and candidates list every peer in display form",
129
450
  "peers": [
130
- { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" },
131
- { "runtime": "codex", "rawName": "Billing sync", "uuid": "00000000-0000-0000-0000-000000000b12" }
451
+ {
452
+ "runtime": "codex",
453
+ "rawName": "Auth refactor",
454
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
455
+ },
456
+ {
457
+ "runtime": "codex",
458
+ "rawName": "Billing sync",
459
+ "uuid": "00000000-0000-0000-0000-000000000b12"
460
+ }
132
461
  ],
133
462
  "input": "zzz",
134
- "expect": { "ok": false, "reason": "unknown", "candidates": ["auth-refactor", "billing-sync"] }
463
+ "expect": {
464
+ "ok": false,
465
+ "reason": "unknown",
466
+ "candidates": [
467
+ "auth-refactor",
468
+ "billing-sync"
469
+ ]
470
+ }
135
471
  },
136
472
  {
137
- "case": "KNOWN DEFECT: colliding canonical ids refuse with two identical candidates",
473
+ "case": "two peers whose slug AND short suffix collide are each still addressable by canonical id",
138
474
  "peers": [
139
- { "runtime": "codex", "rawName": "Auth refactor", "uuid": "11111111-0000-0000-0000-000000000abc" },
140
- { "runtime": "codex", "rawName": "auth-refactor", "uuid": "22222222-0000-0000-0000-000000000abc" }
475
+ {
476
+ "runtime": "codex",
477
+ "rawName": "Auth refactor",
478
+ "uuid": "11111111-0000-0000-0000-000000000abc"
479
+ },
480
+ {
481
+ "runtime": "codex",
482
+ "rawName": "auth-refactor",
483
+ "uuid": "22222222-0000-0000-0000-000000000abc"
484
+ }
141
485
  ],
142
- "input": "codex:auth-refactor.abc",
143
- "expect": { "ok": false, "reason": "ambiguous", "candidates": ["auth-refactor.abc", "auth-refactor.abc"] }
486
+ "input": "codex:auth-refactor.11111111-0000-0000-0000-000000000abc",
487
+ "expect": {
488
+ "ok": true,
489
+ "canonicalId": "codex:auth-refactor.11111111-0000-0000-0000-000000000abc"
490
+ }
144
491
  },
145
492
  {
146
493
  "case": "empty input is a prefix of everything: ambiguous when more than one peer",
147
494
  "peers": [
148
- { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" },
149
- { "runtime": "codex", "rawName": "Billing sync", "uuid": "00000000-0000-0000-0000-000000000b12" }
495
+ {
496
+ "runtime": "codex",
497
+ "rawName": "Auth refactor",
498
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
499
+ },
500
+ {
501
+ "runtime": "codex",
502
+ "rawName": "Billing sync",
503
+ "uuid": "00000000-0000-0000-0000-000000000b12"
504
+ }
150
505
  ],
151
506
  "input": "",
152
- "expect": { "ok": false, "reason": "ambiguous", "candidates": ["auth-refactor.7f3", "billing-sync.b12"] }
507
+ "expect": {
508
+ "ok": false,
509
+ "reason": "ambiguous",
510
+ "candidates": [
511
+ "auth-refactor.7f3",
512
+ "billing-sync.b12"
513
+ ]
514
+ }
153
515
  },
154
516
  {
155
517
  "case": "KNOWN DEFECT: empty input resolves to the only peer when there is exactly one",
156
- "peers": [ { "runtime": "codex", "rawName": "Auth refactor", "uuid": "00000000-0000-0000-0000-0000000007f3" } ],
518
+ "peers": [
519
+ {
520
+ "runtime": "codex",
521
+ "rawName": "Auth refactor",
522
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
523
+ }
524
+ ],
157
525
  "input": "",
158
- "expect": { "ok": true, "canonicalId": "codex:auth-refactor.7f3" }
526
+ "expect": {
527
+ "ok": true,
528
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3"
529
+ }
530
+ },
531
+ {
532
+ "case": "a remote peer resolves by its @machine address",
533
+ "peers": [
534
+ {
535
+ "runtime": "codex",
536
+ "rawName": "Auth refactor",
537
+ "uuid": "00000000-0000-0000-0000-0000000007f3"
538
+ },
539
+ {
540
+ "runtime": "codex",
541
+ "rawName": "Auth refactor",
542
+ "uuid": "00000000-0000-0000-0000-0000000007f3",
543
+ "machine": "m4pro"
544
+ }
545
+ ],
546
+ "input": "auth-refactor@m4pro",
547
+ "expect": {
548
+ "ok": true,
549
+ "canonicalId": "codex:auth-refactor.00000000-0000-0000-0000-0000000007f3@m4pro"
550
+ }
551
+ },
552
+ {
553
+ "case": "a bare address never reaches another machine",
554
+ "peers": [
555
+ {
556
+ "runtime": "codex",
557
+ "rawName": "Auth refactor",
558
+ "uuid": "00000000-0000-0000-0000-0000000007f3",
559
+ "machine": "m4pro"
560
+ }
561
+ ],
562
+ "input": "auth-refactor",
563
+ "expect": {
564
+ "ok": false,
565
+ "reason": "unknown",
566
+ "candidates": []
567
+ }
568
+ },
569
+ {
570
+ "case": "KNOWN DEFECT: a full name that is a prefix of a longer full name resolves to the longer one once the exactly-named session is gone",
571
+ "peers": [
572
+ {
573
+ "runtime": "claude-code",
574
+ "rawName": "muster-b1",
575
+ "uuid": "22222222-2222-4222-8222-222222222222"
576
+ },
577
+ {
578
+ "runtime": "claude-code",
579
+ "rawName": "cxx-be",
580
+ "uuid": "33333333-3333-4333-8333-333333333333"
581
+ }
582
+ ],
583
+ "input": "muster",
584
+ "expect": {
585
+ "ok": true,
586
+ "canonicalId": "claude-code:muster-b1.22222222-2222-4222-8222-222222222222"
587
+ }
159
588
  }
160
589
  ]
161
590
  }