Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/runtime-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,50 @@ exiting; the retained shell is not an agent runtime. The three states:
|---|---|---|---|---|---|
| pane dead | false | false | offline | pane_dead | false |
| retained shell (CLI exited) | true | false | offline | cli_exited | false |
| anchored member (`remote`) | true | false | offline | cli_exited | false |
| live CLI | true | true | per runtime | per runtime | per runtime |

Consumers — delivery refuses a retained shell before any native transport
(the send event stays durable on the bus); idle notify, session-snapshot
capture, and duo pairing all skip retained shells.

### `remote`

Source — the member pane's `@hive-remote` tag, written at registration.

Meaning — the member's agent process does not live on this pane. `channel`
is the only value today: the pane is an **anchor** whose channel socket and
ready marker are symlinks to an external Claude session's own
`hive-client-<pid>.sock`. The pane exists to hold the member's identity, so
every pane-keyed authority (routing, tags, `kill-pane` as the kick control,
doctor) keeps working unchanged.

The honest asymmetry: `alive` no longer implies the member is reachable, and
`cliAlive` is permanently `false` because no CLI was ever meant to run here.

**An anchored member has no push transport.** A host that launches its
sessions without `--channels` — the desktop app owns its argv — cannot
receive channel notifications at all, so nothing is pushed to this pane and
the channel socket is never written to. Delivery is the durable bus write
plus the member's own inbox hook, which drains it after each tool call and
again at the end of the turn. `Agent.send` names that boundary
`busInboxAccepted`; it deliberately claims less than the channel and daemon
classifications, because no push was attempted and none may be claimed.

The socket still earns its keep as **liveness evidence**: the external
session's channel server unlinks the real socket and marker when it exits, so
the anchor's symlinks dangle and delivery fails closed exactly like a dead
pane server — the message stays on the bus for its return.

Consumers — delivery gates an anchored member on that liveness rather than
refusing it as a retained shell; the resume snapshot records the marker and
resume skips those members instead of spawning a look-alike CLI on the team's
routing key (an external session reconnects itself by re-running `hive duo
init --channel <socket>`, which relinks the anchor). Because every session on
the host runs the same inbox hook, the saved context carries a `session`
claim: the first session to drain owns the identity and siblings are refused,
and re-forming the duo clears the claim.

### `inputState`

Source:
Expand Down
23 changes: 23 additions & 0 deletions plugins/hive/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/inbox_hook.py\"",
"timeout": 20
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/inbox_hook.py\"",
"timeout": 180,
"statusMessage": "hive inbox..."
}
]
}
]
}
}
126 changes: 126 additions & 0 deletions plugins/hive/scripts/inbox_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Deliver a hive member's inbound messages into a session that channels cannot reach.

Claude Code delivers channel notifications only to sessions launched with
``--channels``. Sessions hosted by the Claude Code desktop app are not: the
app owns its argv, so a desktop-led duo worker never sees the push (measured
A/B: same binary, same stream-json transport, flag present -> delivered, flag
absent -> silently dropped). A hook is the only injection point that lives
*inside* the session process, so it is the delivery path for those members.

Two events, two jobs:

- ``PostToolUse`` — mid-turn delivery. A member doing real work calls tools
constantly, so each tool result is a delivery point; a reply that lands
during a long turn arrives within one tool call instead of at turn end.
Never blocks, never waits.
- ``Stop`` — the idle/settle path. Drains what is left, and while one of the
member's own messages is still unanswered it holds the turn open waiting
for the answer (the duo's "wait for the verdict" semantics).

Silent no-op everywhere else: inside tmux the native channel/app-server
transports already deliver, and a session bound to no hive team has no inbox.
Each drain advances a durable cursor, so no message is delivered twice and a
blocked stop cannot repeat on the same message. ``--session`` scopes the
drain to the member's own session, so other desktop sessions running the same
hook never steal its inbox.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys

WAIT_SECONDS = 120 # ponytail: fixed budget; make it a setting if a duo outgrows it


def _collect(args: list[str]) -> dict:
hive = shutil.which("hive")
if not hive:
return {}
try:
out = subprocess.run(
[hive, "collect", *args],
capture_output=True,
text=True,
timeout=WAIT_SECONDS + 30,
)
except (OSError, subprocess.SubprocessError):
return {}
if out.returncode != 0:
return {}
try:
payload = json.loads(out.stdout)
except ValueError:
return {}
return payload if isinstance(payload, dict) else {}


def _envelope(message: dict) -> str:
head = f"<HIVE from={message.get('from', '?')} to={message.get('to', '?')}"
if message.get("msgId"):
head += f" msgId={message['msgId']}"
if message.get("replyTo") or message.get("inReplyTo"):
head += f" reply-to={message.get('replyTo') or message.get('inReplyTo')}"
if message.get("artifact"):
head += f" artifact={message['artifact']}"
return f"{head}>\n{message.get('body', '')}\n</HIVE>"


def _preamble(count: int) -> str:
return (
f"{count} hive message{'s' if count > 1 else ''} arrived. Follow the hive "
f"protocol for each (reply with `hive reply <agent>`):\n\n"
)


def main() -> None:
if os.environ.get("TMUX_PANE"):
return # native channel / app-server transports own delivery in tmux
try:
event = json.load(sys.stdin)
except (ValueError, OSError):
event = {}
session = str(event.get("session_id") or "")
claim = ["--session", session] if session else []

if event.get("hook_event_name") == "PostToolUse":
# Mid-turn delivery: every tool call is a delivery point, so a message
# that lands while the member is working arrives with the next tool
# result instead of waiting for the turn to end. Never waits — a turn
# in progress must not be stalled by an empty inbox.
messages = (_collect(claim).get("messages")) or []
if not messages:
return
body = "\n\n".join(_envelope(m) for m in messages)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": _preamble(len(messages)) + body,
},
}))
return

already_blocking = bool(event.get("stop_hook_active"))
payload = _collect(claim)
if not payload.get("messages") and not already_blocking:
# Nothing unread. Wait for the answer only while one is actually owed,
# so an idle session ends its turn immediately.
payload = _collect([*claim, "--wait", str(WAIT_SECONDS), "--if-awaiting"])
messages = payload.get("messages") or []
if not messages:
return

body = "\n\n".join(_envelope(m) for m in messages)
count = len(messages)
print(json.dumps({
"decision": "block",
"reason": _preamble(count) + body,
"systemMessage": f"hive: {count} message{'s' if count > 1 else ''} delivered",
}))


if __name__ == "__main__":
main()
58 changes: 58 additions & 0 deletions src/hive/adapters/claude_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,64 @@ def prepare_pane(cwd: str) -> list[str]:
return ["--channels", PLUGIN_SPEC]


# --- anchor pane linkage ----------------------------------------------------

def link_client_socket(pane: str, client_sock: str | Path) -> str | None:
"""Point *pane*'s channel socket + marker at an external client's.

Used by ``hive duo init --channel``: the anchor pane of a desktop-led duo
has no CLI of its own, so its pane-addressed socket/marker become symlinks
to the external Claude session's ``hive-client-<pid>.sock``. Delivery
(:func:`send_to_pane`) is untouched: ``connect()`` follows symlinks, and
when the client's server exits and unlinks its real files the symlinks
dangle — ``exists()`` goes False and sends fail closed, exactly like a
dead pane server.

Returns None on success, or a human-readable refusal. Fail-closed: the
client socket must exist and its marker must advertise a known version
before any link is created.
"""
client_sock = Path(client_sock)
client_marker = Path(marker_path_for_socket_path(client_sock))
if not client_sock.exists():
return f"channel socket {client_sock} does not exist (is the Claude session still running?)"
try:
version = client_marker.read_text().strip()
except OSError:
return f"channel marker {client_marker} is unreadable; refusing to link an unready channel"
if version not in (MARKER_LEGACY, MARKER_RECEIPT_CAPABLE):
return f"channel marker {client_marker} has unknown version {version!r}; refusing to link"
sock_link = channel_socket_path(pane)
marker_link = ready_marker_path(pane)
sock_link.parent.mkdir(parents=True, exist_ok=True)
for link, target in ((sock_link, client_sock), (marker_link, client_marker)):
try:
link.unlink()
except OSError:
pass
os.symlink(target, link)
return None


def marker_path_for_socket_path(sock_path: str | Path) -> str:
"""Marker path for a raw socket path (same shape the server writes)."""
s = str(sock_path)
return s[: -len(".sock")] + ".ready" if s.endswith(".sock") else s + ".ready"


def remote_member_alive(pane: str) -> bool:
"""Whether an anchored member's external session is still running.

Its channel server unlinks the real socket and marker on exit, so the
anchor's symlinks dangle and ``exists()`` (which follows them) goes False.
Nothing is ever pushed over this socket — a session the app launched
without ``--channels`` cannot receive channel notifications — so this is
liveness evidence only, and delivery to a live member is the bus plus the
member's own inbox hook.
"""
return channel_socket_path(pane).exists() and ready_marker_path(pane).exists()


# --- delivery ---------------------------------------------------------------

def _extract_msg_id(text: str) -> str:
Expand Down
Loading