feat: desktop-led duo via an anchor pane - #70
Open
notdp wants to merge 12 commits into
Open
Conversation
The Stop-hook inbox wait armed on "any unanswered send in history", so the trailing unanswered message every finished exchange leaves behind latched it True forever — every later turn then blocked for the full wait window. Judge it from the single latest message instead, and require that send to be recent, so a stale sign-off never re-arms an unrelated turn. Also lock the collect cursor's read-claim-advance so parallel tool-call hooks can't double-deliver.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A duo can now be led from a Claude Code desktop session:
hive duo initthere forms a team where the desktop session itself is the worker and a codex validator runs in a background detached tmux session (tmux attach -t hive-ccdto watch). Messages flow both ways — the desktop worker sends over the validator's app-server daemon, and the validator's replies arrive back in the desktop session's context on their own. tmux demotes from the runtime's host to an optional display.The worker keeps a pane without living in one: every member still has a pane. The desktop worker gets an anchor pane — a plain shell carrying the member tags plus
@hive-remote=channel, whose channel socket and ready marker are symlinks to the desktop session's ownhive-client-<pid>.sock. Identity, routing, delivery state, reaping, and doctor keep operating on pane authority; no second identity scheme grows anywhere.The return path, and why it isn't a channel
Claude Code delivers channel notifications only to sessions launched with
--channels, and the desktop app owns its argv. Measured A/B on this machine, same binary and same stream-json transport: flag present → the notification reaches the model verbatim; flag absent → the server still gets its write receipt and the session hears nothing, exactly as the docs describe. Every way to inject the flag was tried and failed — no settings key or env var exists,.mcp.jsonis documented as insufficient, and replacing the app's helper binary with a wrapper works mechanically but the app restores the original before every session spawn, so a wrapper can never serve one.Plugin hooks, however, do run in desktop sessions. So the hive plugin gained a
Stophook: at the end of a turn it drains the member's bus inbox and returns the messages as blocked-stop feedback, which Claude Code injects as context and continues on — the same effect the channel would have had, minus the flag. Inside tmux it is a silent no-op, because the native channel and app-server transports already deliver there.Design decisions worth reviewing:
hive duo init --channel. No registry, no extra protocol. Without--channel, init connect-probeshive-client-*.sockand proceeds only on exactly one live listener (corpse files without a listener are ignored).Agent.sendroutes@hive-remote=channelpanes to the channel transport instead of refusing on the process probe — the channel's marker and one-byte receipt are the "somebody is listening" evidence. When the desktop session exits, its server unlinks the real socket and marker, the anchor's symlinks dangle, and sends fail closed exactly like a dead pane server. Re-running init on a restarted session relinks the anchor and keeps the team ("relinked": true) instead of stranding it.hive collect --wait N --if-awaitingblocks only while one of the member's own messages is unanswered, so a duo waiting on a verdict holds its turn open while an idle session ends immediately. The hook uses exactly this, and never re-blocks oncestop_hook_activeis set.Team.create_for_window/Agent.spawntakeallow_outside_tmuxand then require the target window/pane to actually exist on the server. The CLI root gate admitsinit/duo, plus any command once the saved default context names a team. In-tmux behavior and unbound outside-tmux behavior are unchanged.spawn_daemonresolves a barecodexto an absolute path once. Spawner and pane otherwise resolve against different PATHs; the version-skew incident from 2026-07-14 becomes structural here, because the detached session inherits the desktop app's environment rather than a login shell's.Validation
1219 unit + cli tests green. Two rounds of real-runtime evidence:
HIVE_HOME/CODEX_HOME, throwaway tmux session, checkout CLI shim): a stand-in desktop client formed the duo against a real spawned codex validator,hive send validatordelivered from outside tmux, and the validator's reply came back through the anchor symlink as a channel notification carrying the exact nonce.Stophook injected the reply mid-stop and the turn continued, withreply-tomatching the sent msgId and the token verbatim.Note for deployment: the
Stophook only reaches desktop sessions once this ships as a plugin version, since plugins load from the installed cache rather than a checkout. Sidecar ergonomics for the anchor member (notify/resume surfacing) stay out of scope here.New concepts
Anchor pane — proxy identity for an external member
An anchor pane preserves the runtime's core invariant — every member is a pane — for a member whose process lives elsewhere. The pane is a plain shell holding the identity tags; the member's transport endpoints are symlinks pointing at the external process's real endpoints.
flowchart LR V[validator pane] -->|hive reply worker| S["hive-pane-%7.sock (symlink)"] S --> C["hive-client-11750.sock"] C --> D[desktop Claude session]Why this over "paneless members": a member without a pane forks every pane-keyed authority (identity, liveness, reaping, doctor, resume) into a second code path. Anchoring inverts the cost — one placeholder pane buys back every existing mechanism, including
kill-paneas the natural kick-a-member control, at the price of one honest asymmetry: pane-alive no longer implies member-alive, so delivery receipts carry that truth instead.When not to use it: don't anchor a process that can't expose a linkable endpoint — a proxy that can't forward is just a lie with tags on it.