@brutalsystems/muster 0.8.2 → 0.9.1

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.6.0.**
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,21 +93,47 @@ 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
 
@@ -84,31 +144,82 @@ It is the form to record in logs and to pass between tools.
84
144
 
85
145
  A peer is suffixed in its **display** form when either:
86
146
 
87
- - another peer in the same listing has the same slug, or
147
+ - another peer in the same listing has the same slug **on the same machine**, or
88
148
  - the peer has no name (`rawName === null`), which is always suffixed.
89
149
 
150
+ Collisions are counted per machine. The same slug on two computers is already
151
+ two different addresses, so suffixing both would add noise to distinguish
152
+ things that were never confusable.
153
+
90
154
  Otherwise the display form is the bare slug. Only colliding peers are suffixed;
91
155
  peers that do not collide keep their bare names in the same listing.
92
156
 
157
+ **Adding a runtime can change an existing peer's display.** `assignNames`
158
+ computes collisions across the *whole* returned list, regardless of runtime.
159
+ So a peer that displayed as a bare slug before opencode support existed can
160
+ start displaying suffixed the moment an opencode peer (or any peer) slugging
161
+ to the same string appears in the same listing — a Codex peer `foo` and an
162
+ opencode session slugging to `foo` both flip from bare `foo` to qualified
163
+ `foo.<suffix>` (see the fixture's "cross-runtime collision" case). This is
164
+ inherent to the existing rule, not new behaviour introduced by opencode
165
+ support, and it is correct: the two peers are genuinely indistinguishable by
166
+ slug. It is called out here because Muster consumes `display`, and this
167
+ should be an expected consequence of adding a runtime, not something its
168
+ maintainer discovers by surprise.
169
+
93
170
  ## Resolution
94
171
 
95
- Input is trimmed and lowercased, then matched in two passes.
172
+ Input is trimmed and lowercased, then matched in two passes against the peer
173
+ listing, with one host-local check between them.
96
174
 
97
175
  **Pass 1 — exact.** Matches if the input equals the peer's display form, its
98
176
  qualified form, or its canonical id. One match resolves. More than one is
99
177
  refused as ambiguous.
100
178
 
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.
179
+ **Self check**, only if pass 1 found nothing. If the input names the session
180
+ Tin Can is itself running in, the result is refused as `self`. See
181
+ [Self is not a peer](#self-is-not-a-peer) — this pass is **not** part of the
182
+ fixture contract and a second implementation is not expected to reproduce it.
183
+
184
+ **Pass 2 — prefix**, only if pass 1 and the self check found nothing. Matches
185
+ if the peer's slug or its qualified form *starts with* the input. One match
186
+ resolves. More than one is refused as ambiguous.
187
+
188
+ If nothing matches, the result is refused as unknown.
189
+
190
+ ### Self is not a peer
191
+
192
+ A host never lists the session it is running in, so an address naming that
193
+ session reaches pass 2 with nothing to match — or, worse, matches *something
194
+ else*. The check sits between the passes rather than before them, and the
195
+ order is load-bearing in both directions:
196
+
197
+ - **Below pass 1**, so an exact peer name still wins. A peer genuinely called
198
+ `review` receives `review` even when the host is called `review-tools`,
199
+ which the self check matches by prefix.
200
+ - **Above pass 2**, because that pass resolves on a *single* match. A host
201
+ that can only recognise itself by name — a Claude Code host carries no
202
+ session id of its own and falls back to its working directory's basename —
203
+ whose fallback name prefixed exactly one peer would otherwise resolve to
204
+ that peer and deliver. Where the listing spans config dirs, that peer
205
+ belongs to a different account: a note addressed to yourself, delivered to
206
+ a stranger, reported as sent.
207
+
208
+ **This is deliberately outside the fixture.** Every case in
209
+ `test/fixtures/canonical-id.json` resolves with no host identity supplied, and
210
+ all ten behave identically with and without this pass. "Self" is a property of
211
+ the process doing the resolving, not of the address or the listing, so a tool
212
+ that consumes addresses — Muster launches sessions and hands back peer records
213
+ for Tin Can to resolve — has no self to check and nothing to implement. The
214
+ address format is unchanged; what changed is what one particular resolver does
215
+ with its own name.
106
216
 
107
217
  - **Case-insensitive** throughout. `AUTH-REFACTOR.7F3` resolves.
108
218
  - **Input is trimmed.** Surrounding whitespace is ignored.
109
219
  - **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).
220
+ is unambiguous. The empty string is a prefix of everything, and a complete
221
+ name can be a prefix of a longer complete name — see
222
+ [known defects](#known-defects), 3 and 4.
112
223
  - **No tie-breaking.** Ambiguity is always refused, never guessed.
113
224
 
114
225
  ### Refusal shape
@@ -117,26 +228,32 @@ If neither pass matches, the result is refused as unknown.
117
228
  |---|---|
118
229
  | `ambiguous` | every matching peer, in **qualified** form |
119
230
  | `unknown` | every peer in the listing, in **display** form |
231
+ | `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
232
 
121
233
  ## Known defects
122
234
 
123
- Preserved in 0.1.0 by decision, not by oversight. They are in the fixture so
235
+ Preserved by decision, not by oversight. They are in the fixture so
124
236
  that a second implementation matches Tin Can exactly, including where Tin Can is
125
237
  wrong. **Do not "correct" them independently** — that produces addresses that
126
238
  resolve in one tool and fail silently in the other.
127
239
 
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.
240
+ **1. `canonical_id` is not unique. — FIXED in 1.0.0.**
241
+
242
+ It used to carry the three-character suffix, so two peers whose slugs matched
243
+ *and* whose ids shared their last three hex characters produced the same
244
+ `canonical_id`. Tin Can then refused to resolve either, listing two identical
245
+ candidates — a refusal that told the caller to disambiguate using a string that
246
+ did not disambiguate. Both peers were unaddressable until one exited. Roughly 1
247
+ in 4096 given a slug collision, and slug collisions are not rare: Codex titles
248
+ threads from their first prompt.
133
249
 
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.
250
+ The canonical form now carries the whole durable id, which makes a collision
251
+ impossible rather than merely unlikely. Both peers stay addressable.
137
252
 
138
- **Consumers must therefore treat `canonical_id` as a display and correlation
139
- aid, not a primary key.** Key on `thread_id` / `session_id`.
253
+ **The short forms still collide**, and still should: `display` and `qualified`
254
+ are for humans, and two sessions genuinely sharing a name are genuinely
255
+ ambiguous. The difference is that there is now an unambiguous form to fall back
256
+ to. `canonical_id` is a usable key.
140
257
 
141
258
  **2. A name that slugifies to empty is inconsistent with an unnamed peer.**
142
259
  Alone in a listing, a peer named `???` displays as `thread` with no suffix,
@@ -149,6 +266,45 @@ string is a prefix of every slug, `resolve("")` against a single-peer listing
149
266
  returns that peer rather than refusing. With two or more peers it is refused as
150
267
  ambiguous, as expected.
151
268
 
269
+ **4. A full name that is a prefix of a longer full name resolves to the longer
270
+ one once the exactly-named session is gone.** While a session named `muster` is
271
+ listed, `muster` is an exact hit and pass 2 is never reached. The moment it
272
+ exits, `muster` is nobody's name and becomes merely a prefix of its neighbour
273
+ `muster-b1` — the only match, so it resolves, and the message is delivered to a
274
+ session the caller did not mean and reported as sent. Observed live between two
275
+ sessions; the send was a reply, so `reply_misrouted` caught it, but a new
276
+ message would have gone through.
277
+
278
+ This is not an exotic collision. It happens whenever a short name and a longer
279
+ one built on it coexist — `muster` / `muster-b1`, `ferry` / `ferry-9b`.
280
+
281
+ **It is kept because it cannot be fixed from the listing alone.** The hazard is
282
+ indistinguishable from the behaviour directly above it in this document: `auth`
283
+ → `auth-refactor` and `muster` → `muster-b1` are the same shape, the same single
284
+ match, and in both cases the input is nobody's current name. The only fact that
285
+ separates them is that a session named exactly `muster` *existed and has gone*,
286
+ and a peer listing cannot carry that fact, because the session's absence is the
287
+ whole premise. Any rule that refuses the second refuses the first: removing the
288
+ prefix pass is verifiable in one edit, and it fails
289
+ `resolve > unambiguous prefix resolves` alongside the defect's own case.
290
+
291
+ **The form that cannot misroute is `canonical_id`.** Pass 2 matches against the
292
+ slug and the qualified form, never against `canonicalId`, so a canonical id can
293
+ never prefix-match a stranger — it resolves to the session it names or refuses
294
+ as unknown:
295
+
296
+ ```
297
+ muster gone , resolve("muster") -> OK, muster-b1
298
+ muster gone , resolve("claude-code:muster.18ddaa21-…") -> REFUSED unknown
299
+ ```
300
+
301
+ A loud refusal is the outcome a caller wants here. **When you hold a session's
302
+ durable id, address it by `canonical_id` rather than by its bare name** — a
303
+ short name is for a human typing, not for a program that already knows exactly
304
+ which session it means. `expect_id` does not substitute: it is compared after
305
+ resolution and only ever rejects, so it guards the send without selecting the
306
+ recipient.
307
+
152
308
  ## Changing this format
153
309
 
154
310
  The address format is a contract between Tin Can and tools built against it. A
@@ -156,6 +312,14 @@ change to slugify, suffix derivation, collision handling, or resolution produces
156
312
  addresses that resolve in one tool and fail silently in the other — no error,
157
313
  just a message delivered nowhere.
158
314
 
315
+ **Who is built against this today:**
316
+ [Muster](https://github.com/BrutalSystems/muster) launches agent sessions and
317
+ returns a peer record Tin Can must resolve. It does not import Tin Can — the two
318
+ agree on this document and on `test/fixtures/canonical-id.json`, which Muster
319
+ copies verbatim. So a change here is not local to this repo, and the person
320
+ making it is responsible for coordinating the release, not for discovering
321
+ afterwards that the other tool disagreed.
322
+
159
323
  So:
160
324
 
161
325
  - **A change to any behaviour described here is a breaking change**, and ships
@@ -299,3 +299,74 @@ 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.
package/README.md CHANGED
@@ -66,9 +66,11 @@ used to look identical to a hung or crashed launch. Muster now reads
66
66
  `hasTrustDialogAccepted` for the working directory out of the profile's
67
67
  `.claude.json` when a Claude launch times out with nothing registered, and
68
68
  either names the untrusted directory and the one-time fix or rules trust out
69
- and points at the remaining consent screens — a login prompt, or the
70
- confirmation Claude asks for the first time a profile uses bypass-permissions
71
- mode. That read happens only on the failure path and only ever reads. A
69
+ and sends you to the terminal for whatever else is holding the session — a
70
+ login prompt, or another one-time confirmation. It deliberately does not guess
71
+ which: trust is the only one of them Muster can check, and a message that names
72
+ the wrong screen costs what the vague one did. That read happens only on the
73
+ failure path and only ever reads. A
72
74
  `--kind task` launch into the same directory is the cheap discriminator by
73
75
  hand: it runs headless and never sees the dialog.
74
76
 
@@ -694,6 +696,18 @@ need it), and resume/fork (#32) cannot assume a copy is still there.
694
696
  Launching without `--identity` sets none of this: behaviour is exactly as
695
697
  before, using the ambient environment.
696
698
 
699
+ **That is the right choice for profile work, which is counter-intuitive.** If
700
+ you already work in a shell whose `CLAUDE_CONFIG_DIR` selects an account —
701
+ `~/.claude-armdev`, say — a launch with no `--identity` inherits it, and the
702
+ session lands on that account with no flag at all. `launchEnv` blocks
703
+ `CLAUDECODE`, `CLAUDE_CODE_*`, `CODEX_THREAD_ID`, `TMUX`, `TMUX_PANE` and
704
+ `MCP_*` from reaching the child, and deliberately lets `CLAUDE_CONFIG_DIR`
705
+ through for exactly this reason. Passing `--identity` there does not add
706
+ containment to that account — it **replaces** it, pointing the runtime at a
707
+ copy of a different one, so the session appears under an account you did not
708
+ mean to use. Reach for `--identity` to choose an account Muster manages, not to
709
+ harden the one your shell already selected.
710
+
697
711
  Session discovery follows the same relocated path, so a Claude identity's
698
712
  sessions are found even though `CLAUDE_CONFIG_DIR` no longer points at
699
713
  `~/.claude` — this was previously refused outright as an unsupported
@@ -62,10 +62,13 @@ export async function claudeWorkspaceTrusted(env, cwd) {
62
62
  *
63
63
  * A session sitting on a consent screen has no registry record at all, so the
64
64
  * timeout looks identical to a hung or crashed launch. Several unrelated
65
- * screens produce it — workspace trust, login, the first use of
66
- * bypass-permissions mode — and the old message guessed at two of them
67
- * together. Trust is the one Muster can actually check, so it is either named
68
- * or ruled out; the rest are listed only once it has been ruled out.
65
+ * screens can produce it, and the old message guessed at two of them together.
66
+ * Trust is the one Muster can actually check, so it is either named or ruled
67
+ * out. Nothing else is named: a first-use bypass-permissions confirmation was
68
+ * a plausible account of one report, but no profile on this machine carries a
69
+ * key for it the way they carry `hasSeenAutoModeEntryWarning` and its
70
+ * siblings, so this says "another one-time confirmation" rather than sending
71
+ * the reader after a screen nobody has demonstrated.
69
72
  *
70
73
  * A `--kind task` launch into the same directory is the cheap discriminator:
71
74
  * it runs headless and never sees the dialog, so it succeeds where a session
@@ -77,9 +80,9 @@ export function claudeRegistryDiagnostic(trusted, cwd, configDir) {
77
80
  `accepted for ${cwd} in ${configDir} — accept it once with ` +
78
81
  `\`cd ${cwd} && claude\`, then retry (trust is per-directory and is ` +
79
82
  `not inherited from a parent directory)`);
80
- return (`no Claude session registry found (${cwd} is trusted, so check the ` +
81
- `terminal for a login prompt, or for the one-time confirmation Claude ` +
82
- `asks for the first time a profile uses bypass-permissions mode)`);
83
+ return (`no Claude session registry found (${cwd} is trusted for this profile, ` +
84
+ `so the session is stopped on something else — check the terminal for a ` +
85
+ `login prompt or another one-time confirmation)`);
83
86
  }
84
87
  export async function resolveClaude(root, env, deadline) {
85
88
  const dir = claudeSessionsDir(env);
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.2",
3
+ "version": "0.9.1",
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.6.0",
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
  }