feat: name TBD sessions and unify the Claude peer registry across profiles - #605
Conversation
302726e to
b6ba3ce
Compare
Adopt Claude Code's native cross-session messaging (ListAgents/SendMessage) for TBD-spawned sessions: spawn-time --name with the worktree display name behind a version probe, cross-profile discovery verification with a contingent registry symlink, factual skill coverage of both channels, and a documented user-land inbound-policy recipe. Native peer messages stay outside the actuation record by decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YXSyPKtVFMDzRBK31bHbrx
Unknown options verified to fail spawn-shaped invocations (exit 1) on CLI v2.1.226, with --version/--help as short-circuiting exceptions; pin the --name gate constant to 2.1.76 from the changelog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YXSyPKtVFMDzRBK31bHbrx
npm publish dates show 2.1.76 is five months old on a self-updating CLI, and messaging itself needs 2.1.224 — the probe subsystem protected almost nobody from a loud, self-describing failure. Floor documented; probe moved to rejected alternatives with the evidence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YXSyPKtVFMDzRBK31bHbrx
…files Claude Code v2.1.224 added cross-session messaging: a session discovers peers with ListAgents and sends one plain text with SendMessage, peer-to-peer over Unix sockets. TBD's workflow — many sessions across parallel worktrees — is what the feature targets, so adopt the native transport rather than build one. TBD's job is to make its sessions addressable and keep discovery whole; the transport is Claude Code's. Two things blocked that. Sessions named themselves after their working directory (a slug plus a random suffix that never matches an app-side rename), and the peer registry hangs off CLAUDE_CONFIG_DIR, which TBD gives each profile its own of — field measurement found 24 live sessions in one profile invisible to a session in another. ClaudeSpawnCommandBuilder gains sessionName, emitting shell-escaped --name after --dangerously-skip-permissions in the resume and fresh branches only; the cmd/shellFallback branches are untouched. Names are sanitized, since a display name is free text that now reaches a command string. Seven call sites pass the worktree display name. ClaudeProfileConfigDirManager adds sessions as a ninth host-mirror slot, symlinking each profile's registry to the host store — only the index was fragmented, as the sockets in /tmp/cc-socks are already shared per OS user. Three carve-outs, each its own named set so the other eight slots are provably unaffected: the host directory is created when absent (0700), the host entry's type is validated, and pre-existing rows are MERGED rather than moved to a sidecar. The merge matters: the registry holds live process state and seeding runs on every spawn, so a sidecar would move running sessions' rows out from under their own processes and orphan them permanently, since a session unlinks its row by path at exit. Rows are <pid>.json and PIDs are unique per OS user, so merging is collision-free and adopts running sessions instead of hiding them. Unifying the registry also makes ClaudeStateDetector's session-ID recapture work for profile terminals, which previously read a host path their rows never reached. A test pins it. Naming is not unique — a worktree can host several Claude terminals — so the skill text and docs direct senders to the [ref] ListAgents already prints per row, rather than adding a suffixing scheme that would break the sidebar correspondence. Spec: docs/specs/2026-08-09-cross-session-messaging-design.md Docs: docs/cross-session-messaging.md
9ce7c4c to
de3e8b4
Compare
|
✅ Looks good Both review lenses — correctness and CLAUDE.md conventions — came back clean on this PR. Correctness traced the load-bearing premises behind the Conventions checked the PR's own no-flag argument against the actual code (regular-file/dangling-symlink refusal and partial-merge-leaves-symlink-uncreated both verified in No invalid or persnickety feedback was filtered during the merge (none was raised). Finding dispositionsNo findings were raised by either specialist, so there are no dispositions to record. Review diagnosticsNo tool calls were denied or failed for either specialist. The correctness specialist noted one self-imposed limitation, not a tool failure: it could not empirically verify the exact Unicode-category coverage of Posted by the claude-review check — the review of this PR's diff at patch-id |
The messaging guidance shipped in #605 told readers to use a peer's bare name when only one row answers to it, and to reach for the `[ref]` only to break a tie between same-named rows. That fails on first contact. Measured against Claude Code 2.1.227/2.1.228 on two independent peers, each with a name unique in the listing: a bare-name send is refused with `'<name>' is not an agent in this conversation. Re-send with the ref to confirm you mean: <name> [<ref>]`, and nothing is delivered. Uniqueness does not help — the refusal names a single candidate and still fires. After a message to that peer has gone through, its bare name resolves. So a reader following the shipped text loses a round trip on every first contact, having been told the bare name would work. The guidance is now written operationally — what to do and what you will see — rather than asserting a mechanism inside Claude Code that this repo cannot maintain: address a peer you have not messaged before as `name [ref]`, expect a refusal that names the ref you need, and know the bare name works thereafter. The ref remains the picker when rows share a name, which TBD makes ordinary since every session in a worktree takes that worktree's display name. The skill-content test now pins the addressing form and the refusal together; text describing the ref as only-for-collisions satisfies neither.
Summary
Sibling Claude sessions in TBD worktrees can now talk to each other directly. A session lists its peers with
ListAgentsand sends one plain text withSendMessage; delivery is peer-to-peer over Unix sockets, and neither Anthropic's servers nor TBD's daemon sit in the path. An orchestrator can hand a finding to a worker, and a worker can report back, without the human relaying between tabs.TBD's contribution is deliberately small: make its sessions addressable under the name you see in the sidebar, and make peer discovery whole across TBD profiles. The transport, throttling, and inbound-safety rails are Claude Code's.
Two things had to be true for that, and neither was:
CLAUDE_CONFIG_DIR, and TBD gives every profile its own. Sessions on different profiles were mutually invisible: field measurement found 24 live sessions in one profile that a session in another could not list.Why this shape
Design spec:
docs/specs/2026-08-09-cross-session-messaging-design.md. User-facing page:docs/cross-session-messaging.md.Adopt the native transport rather than build one. A
tbd messageverb speaking the peer socket protocol would put peer messages in the actuation record, but it would couple TBD to an unversioned wire format at the wrong layer — the same class of coupling the no-screen-scraping rule exists to prevent. Native peer messages are therefore outside TBD's actuation record, in both directions, by decision. The record's claim narrows to "everything sent through TBD's transport", which is all it ever attested.The name is a label; the
[ref]is the address.--namecarries the worktree display name so listings match the sidebar. But a worktree can host several Claude terminals — measured: 16 worktrees with more than one, up to 4 — and display names carry no uniqueness constraint, so a name can answer for several sessions.ListAgentsalready prints a short[ref]per row for exactly this; the skill text and docs direct senders to it when more than one row shares a name. No suffixing scheme, so the sidebar correspondence survives.A documented CLI floor instead of a capability probe.
--nameis emitted unconditionally. It shipped in CLI 2.1.76, five months old on a self-updating tool and looser than the 2.1.224 the messaging itself needs. Below the floor the failure is loud and self-describing (error: unknown option '--name', exit 1), so a probe subsystem plus its cache and branch tests bought almost nothing.Registry unification reuses machinery that already exists.
sessionsbecomes a ninth host-mirror slot, so each profile'sclaude/sessionssymlinks to the host store — the same mechanism already carryingprojects,plugins,hooks,skills, andsettings.json. Only the index was fragmented; the sockets in/tmp/cc-socks/are already shared per OS user, so no transport work exists.Merge, not sidecar — this is the decision the change turns on. Every other non-
projectsslot parks pre-existing profile content in a<slot>.profile-localsidecar. That is right for cold user content and wrong here: the registry holds live process state, and seeding runs on every spawn, not once at profile creation. Measured: four profiles held 48 rows, 47 belonging to running processes. A sidecar would have moved those rows out from under their own processes, dropped them from every peer's listing while still running, and orphaned them permanently — a session unlinks its row by path at exit, which after symlinking resolves to a host location where the row was never written. Rows are<pid>.jsonand PIDs are unique per OS user, so merging is collision-free by construction and the migration adopts running sessions instead of hiding them.No feature flag, argued on the mirror's own merits rather than by extension of
--name: it destroys nothing (the only loss case is a duplicate filename, where the older row belongs to a dead process); the operation class is precedented, sinceprojectsalready merges profile content hostward through the same function, over far more valuable data; it runs only in response to a user-initiated spawn, on no timer, killing no process; and rollback is deleting one symlink, with rows staying valid where they are.Inbound policy stays user-land. TBD spawns with permissions bypassed, so TBD↔TBD messages already deliver under the class-based default. A repo wanting something stricter sets
crossSessionInboundin its per-repoclaude-settings.json, which deep-merges into the per-spawn--settingsoverlay today. Shipping a value would also have loosened delivery from the user's non-TBD sessions without being asked.What this PR does
ClaudeSpawnCommandBuildergainssessionName:, emitting shell-escaped--name <value>after--dangerously-skip-permissionsin the resume and fresh branches only. Thecmd/shellFallbackbranches are structurally untouched. Names are sanitized — control characters and newlines stripped, trimmed, length-capped — because a display name is free text that now reaches a command string.sessionName: worktree.displayName: two inWorktreeLifecycle+Create, one inHibernationCoordinator, four inRPCRouter+TerminalHandlers(including both arms of the profile-swap handler).ClaudeProfileConfigDirManageraddssessionsas a ninth mirror slot with three narrowly-scoped carve-outs, each its own named set so the other eight are provably unaffected: the host directory is created when absent (0700), rows are merged rather than sidecar'd, and the host entry's type is validated. Guards: the host base directory must already exist, so TBD never conjures a host store; and a regular file or dangling symlink in that position is refused with a warning instead of wedging the slot permanently. A partial merge leaves the symlink uncreated so the next spawn retries rather than burying it.TBDSkillContentgains a short section stating both channels as fact with no preference between them — native messaging, andtbd terminal send/outputas the daemon-mediated alternative that can also drive input — plus the[ref]addressing rule.crossSessionInboundrecipe, the actuation-record boundary, and the registry.Assumptions
--nameis unconditional, so every TBD Claude spawn depends on it — not just messaging. Below the floor all spawning fails loudly. Recorded as a rejected alternative with rebuild-worthy evidence (field reports from users legitimately pinned below it, e.g. an enterprise-frozen CLI).sessions/<pid>.jsonwas established by field measurement, not by contract. If Claude Code relocates it, the mirror slot silently mirrors an unused directory: discovery reverts to per-profile, and nothing else breaks.Evidence & verification
Field measurement (macOS, CLI 2.1.227) established the facts the design rests on: the registry path and row contents; that sockets live in a shared
/tmp/cc-socks/; that discovery fragments per profile; and thatclaude --name "TBD Name Probe"lands verbatim as the row'sname, dropping the"nameSource": "derived"marker the cwd-slug default carries.Live verification after
scripts/restart.shfrom this worktree (oneTBDDaemonfrom the worktree path; the app bundle rebuilt and relaunched):🔬 XSess Verify. Its pane start command carried--name '🔬 XSess Verify'in the designed position, and its registry row read"name": "🔬 XSess Verify"with nonameSource— emoji and space survived the shell escaping.sessions/became a symlink to the host store, host rows went 3 → 24 as 20 live rows merged in, and zero sidecars were created. The session doing the observing kept working throughout.ListAgentslisted the new session as🔬 XSess Verify [6fe655].SendMessageto that address was delivered and replied to — a full round trip.🧪 XSess Renamed, the already-running session still answered to🔬 XSess Verifywhile a newly spawned terminal in the same worktree registered as🧪 XSess Renamed.Discriminating tests — not merely "the suite is green":
sessionName:deleted from thespawnPrimaryTerminalscall site,WorktreeConversationCarryoverTestsfails on the missing--name 'Resume Source'; restored, it passes. The worktree'sdisplayNameis deliberately different from itsnameso the assertion cannot pass off the wrong field. Before this, deleting the argument from any of the seven call sites left the whole suite green.--name, and their mutation-check self-tests extended to prove a trailing prompt is still rejected with--namepresent — in both directions, and now modelling the escaper's'\''form.ClaudeStateDetectorreading a profile session's row through the mirrored path, so the recapture consequence below cannot be quietly reverted.One intended second-order effect, called out because it is easy to miss.
ClaudeStateDetectorrecaptures a session id by reading<host claude home>/sessions/<pid>.json. Before the mirror that read missed for every profile-spawned terminal; with it, the two paths are the same file, so session-ID recapture after a--fork-sessionresume now succeeds for profile terminals where it previously did not.Suite: 5417 tests. The only failures are the known load-sensitive flake cluster — two
ProviderEventsSupervisorTestslive-process tests and aMarkdownStylesheetTestswatcher test — which pass on a targeted re-run (18 tests in 2 suites).scripts/swift-safe buildclean;swiftlint --strictreports 0 violations in 681 files.💬 Cross-Session Messaging