Skip to content

feat: name TBD sessions and unify the Claude peer registry across profiles - #605

Merged
cheapsteak merged 4 commits into
mainfrom
claude/cross-session-messaging-feasibility-ftv80r
Aug 12, 2026
Merged

feat: name TBD sessions and unify the Claude peer registry across profiles#605
cheapsteak merged 4 commits into
mainfrom
claude/cross-session-messaging-feasibility-ftv80r

Conversation

@cheapsteak

@cheapsteak cheapsteak commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Sibling Claude sessions in TBD worktrees can now talk to each other directly. A session lists its peers with ListAgents and sends one plain text with SendMessage; 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:

  • Sessions named themselves after their working-directory folder — a slug plus a random suffix that never matches an app-side rename.
  • Claude Code's peer registry lives under 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 message verb 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. --name carries 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. ListAgents already 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. --name is 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. sessions becomes a ninth host-mirror slot, so each profile's claude/sessions symlinks to the host store — the same mechanism already carrying projects, plugins, hooks, skills, and settings.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-projects slot parks pre-existing profile content in a <slot>.profile-local sidecar. 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>.json and 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, since projects already 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 crossSessionInbound in its per-repo claude-settings.json, which deep-merges into the per-spawn --settings overlay today. Shipping a value would also have loosened delivery from the user's non-TBD sessions without being asked.

What this PR does

  • ClaudeSpawnCommandBuilder gains sessionName:, emitting shell-escaped --name <value> after --dangerously-skip-permissions in the resume and fresh branches only. The cmd / shellFallback branches 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.
  • Seven call sites pass sessionName: worktree.displayName: two in WorktreeLifecycle+Create, one in HibernationCoordinator, four in RPCRouter+TerminalHandlers (including both arms of the profile-swap handler).
  • ClaudeProfileConfigDirManager adds sessions as 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.
  • TBDSkillContent gains a short section stating both channels as fact with no preference between them — native messaging, and tbd terminal send / output as the daemon-mediated alternative that can also drive input — plus the [ref] addressing rule.
  • Docs: the design spec, and a new standalone user page covering versions, confirmation, naming and rename staleness, collision addressing, reach, killswitches, the crossSessionInbound recipe, the actuation-record boundary, and the registry.

Assumptions

  • CLI ≥ 2.1.76. --name is 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).
  • The registry layout is undocumented and unversioned. sessions/<pid>.json was 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.
  • PIDs are unique per OS user at any instant. This is what makes the merge collision-free. If two rows share a filename, one is a stale leftover from a reused pid, and the newer wins.
  • Only CLI sessions register. Every registry row observed came from a terminal. No Claude desktop session was running during measurement, so this is an observation, not a proof — the docs state it at that strength.

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 that claude --name "TBD Name Probe" lands verbatim as the row's name, dropping the "nameSource": "derived" marker the cwd-slug default carries.

Live verification after scripts/restart.sh from this worktree (one TBDDaemon from the worktree path; the app bundle rebuilt and relaunched):

  • Created a worktree named 🔬 XSess Verify. Its pane start command carried --name '🔬 XSess Verify' in the designed position, and its registry row read "name": "🔬 XSess Verify" with no nameSource — emoji and space survived the shell escaping.
  • Migration adopted live rows: the profile's 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.
  • ListAgents listed the new session as 🔬 XSess Verify [6fe655]. SendMessage to that address was delivered and replied to — a full round trip.
  • Rename staleness confirmed both ways: after renaming to 🧪 XSess Renamed, the already-running session still answered to 🔬 XSess Verify while a newly spawned terminal in the same worktree registered as 🧪 XSess Renamed.
  • Scratch worktree archived afterwards.

Discriminating tests — not merely "the suite is green":

  • The call-site coverage was mutation-checked: with sessionName: deleted from the spawnPrimaryTerminals call site, WorktreeConversationCarryoverTests fails on the missing --name 'Resume Source'; restored, it passes. The worktree's displayName is deliberately different from its name so 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.
  • Two pre-existing whitelist helpers pinning the prompt-free resume shape (the guard against ever pasting into a live session) were relaxed to admit --name, and their mutation-check self-tests extended to prove a trailing prompt is still rejected with --name present — in both directions, and now modelling the escaper's '\'' form.
  • New coverage for the merge (rows move, newer wins a duplicate, empty dir just symlinks), the host-side guards (absent base dir, non-directory, dangling symlink), the create-on-demand carve-out not leaking to the other eight slots, and name sanitization.
  • A test pins ClaudeStateDetector reading 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. ClaudeStateDetector recaptures 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-session resume now succeeds for profile terminals where it previously did not.

Suite: 5417 tests. The only failures are the known load-sensitive flake cluster — two ProviderEventsSupervisorTests live-process tests and a MarkdownStylesheetTests watcher test — which pass on a targeted re-run (18 tests in 2 suites). scripts/swift-safe build clean; swiftlint --strict reports 0 violations in 681 files.

💬 Cross-Session Messaging

@cheapsteak
cheapsteak force-pushed the claude/cross-session-messaging-feasibility-ftv80r branch from 302726e to b6ba3ce Compare August 11, 2026 06:06
@cheapsteak cheapsteak changed the title docs: cross-session messaging adoption design spec feat: name TBD sessions and unify the Claude peer registry across profiles Aug 11, 2026
claude and others added 4 commits August 11, 2026 20:26
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
@cheapsteak
cheapsteak force-pushed the claude/cross-session-messaging-feasibility-ftv80r branch from 9ce7c4c to de3e8b4 Compare August 12, 2026 03:44
@cheapsteak
cheapsteak marked this pull request as ready for review August 12, 2026 03:44
@tbd-claude-reviewer

Copy link
Copy Markdown

✅ Looks good

Both review lenses — correctness and CLAUDE.md conventions — came back clean on this PR.

Correctness traced the load-bearing premises behind the sessions mirror-slot merge (the riskiest part of this change, since it merges live per-profile Claude Code registry state through a shared host directory): confirmed the three new carve-out sets gate every new code path to the sessions slot only, so the other eight existing mirror slots are unaffected; walked the merge logic through concurrent-first-migration races and found every race resolves to either a completed merge + symlink or a caught error that aborts before symlinking (retried on the next spawn, never silently dropping a row); confirmed --name is only emitted on the resume/fresh spawn branches (the cmd/shellFallback branches are untouched); confirmed all 7 call sites pass sessionName: worktree.displayName; and confirmed ClaudeStateDetector's privateinternal visibility change carries no behavior change beyond doc comments.

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 ClaudeProfileConfigDirManager.swift, and the merge path is reachable only from user-gesture-triggered call sites, not a timer), confirmed the ClaudeStateDetector changes read a machine-readable session JSON file rather than scraping terminal screen text, found no private/org-specific context leaked into the two new docs files, confirmed the referenced design spec is a substantive 400-line document rather than a stub, and ran the theory-placement battery against the CLI version floor, the mirror mode bits, and the merge tie-break rule — all explicitly documented with field evidence in the spec.

No invalid or persnickety feedback was filtered during the merge (none was raised).

Finding dispositions

No findings were raised by either specialist, so there are no dispositions to record.

Review diagnostics

No 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 CharacterSet.controlCharacters used in session-name sanitization (local swift execution was blocked by sandbox approval in its environment), and rather than assert an unverified claim about bidi/format-character handling, it dropped that line of inquiry instead of reporting it as a finding. Worth a human glance if session names ever need to defend against adversarial Unicode input, but it did not rise to a reportable defect.

Posted by the claude-review check — the review of this PR's diff at patch-id 6c57594e80f6a5a2bab55e2e698dbdbf2e965db0. A newer review comment supersedes this one.

@cheapsteak
cheapsteak merged commit 14562e4 into main Aug 12, 2026
6 checks passed
@cheapsteak
cheapsteak deleted the claude/cross-session-messaging-feasibility-ftv80r branch August 12, 2026 03:54
cheapsteak added a commit that referenced this pull request Aug 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants