feat(@projects/@claire): improve session targeting for direct sends

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-22 00:30:39 -07:00
parent f7277f6619
commit eb382ff1c6
6 changed files with 144 additions and 9 deletions

View file

@ -400,18 +400,42 @@ def send_to_session(
session_ref: str,
text: str,
) -> dict[str, Any]:
"""Send text directly to one session, bypassing project-broadcast."""
"""Send text directly to one session, bypassing project-broadcast.
Targets the session by its `tmux_name`: `rclaude send --match` matches
tmux *session names*, never Claude session UUIDs, so passing a UUID
there silently matches nothing. The session's `tmux_name` is populated
by the pull loop (for resumed sessions) or by `dispatch_task` (for
freshly-spawned ones). If it's still unknown, fail loudly rather than
fire a send that would match nothing.
"""
try:
sid = service.resolve_session_id(conn, session_ref)
except (service.NotFound, service.InvalidInput) as exc:
raise ToolError(str(exc)) from exc
session = read.get_session(conn, sid)
if session is None:
raise ToolError(
f"session {sid} has no projection row yet — run `pull` first so "
f"its tmux_name can be resolved"
)
if not session.tmux_name:
raise ToolError(
f"session {sid} has no known tmux_name — it has not been observed "
f"as a live tmux pane (run `pull`, or the session may not be "
f"running). `rclaude --match` cannot target a session by UUID."
)
from ..rclaude import Rclaude, RclaudeError
rcl = Rclaude()
try:
rcl.send(text=text, match=str(sid)[:8], yes=True, dry_run=False)
rcl.send(text=text, match=session.tmux_name, yes=True, dry_run=False)
except RclaudeError as exc:
raise ToolError(f"rclaude send failed: {exc}") from exc
return {"session_uuid": str(sid), "delivered": True}
return {
"session_uuid": str(sid),
"tmux_name": session.tmux_name,
"delivered": True,
}
# ---------------------------------------------------------------------------

View file

@ -52,6 +52,12 @@ def _session_snapshot(
return {UUID(r["uuid"]): (r["host"], r["cwd"], r["last_seen_mtime"]) for r in rows}
def _tmux_name_snapshot(conn: sqlite3.Connection) -> dict[UUID, str | None]:
"""Return {uuid: tmux_name} from the sessions table — for the tmux-mapping diff."""
rows = conn.execute("SELECT uuid, tmux_name FROM sessions").fetchall()
return {UUID(r["uuid"]): r["tmux_name"] for r in rows}
def _triage_snapshot(
conn: sqlite3.Connection,
) -> dict[UUID, tuple[int | None, str | None, str | None]]:
@ -146,6 +152,42 @@ def pull(
except RclaudeError as exc:
errors.append(f"list_tmux: {exc}")
tmux_rows = []
# --- UUID → tmux_name mapping -----------------------------------------
# A tmux pane started via `claude --resume <uuid>` carries that UUID in
# its `resumed_uuid` column. That's the only signal that resolves a
# *specific* session UUID to a live tmux session name (the tmux name
# itself encodes only the cwd slug). Without it, `send_to_session` can't
# target a session — `rclaude --match` matches tmux names, never UUIDs.
# Emit a SessionObserved carrying `tmux_name` so the projection's
# `sessions.tmux_name` column gets populated. Diff against the current
# projection so we only emit when the mapping actually changed; only set
# tmux_name when known (the COALESCE in the projection never clobbers a
# known name with None).
tmux_name_snap = _tmux_name_snapshot(conn)
for trow in tmux_rows:
if not trow.resumed_uuid:
continue
try:
mapped_uuid = UUID(trow.resumed_uuid)
except ValueError:
errors.append(f"list_tmux: bad resumed_uuid {trow.resumed_uuid!r}")
continue
if tmux_name_snap.get(mapped_uuid) == trow.session_name:
continue
events.append(
conn,
generator,
events.SessionObserved(
session_uuid=mapped_uuid,
host=trow.host,
cwd=None,
tmux_name=trow.session_name,
last_seen_mtime=None,
),
)
sessions_observed += 1
# Slug used in tmux names mirrors rclaude's: leading slashes/tildes
# stripped, non-alphanumeric → '-'.
def _slug(s: str) -> str:

View file

@ -53,6 +53,10 @@ class TmuxRow:
host: str
session_name: str
detail: str
# The Claude session UUID this tmux pane was started with via
# `claude --resume <uuid>`. `None` for fresh-spawned panes (no --resume)
# or for output from an older rclaude that doesn't emit the 5th column.
resumed_uuid: str | None = None
@dataclass(frozen=True)
@ -144,7 +148,9 @@ class Rclaude:
def list_tmux(self) -> list[TmuxRow]:
"""Cross-host live tmux sessions via `rclaude list tmux --tsv`.
Raw TSV row shape: `host\\ttmux\\tsession_name\\tdetail`.
Raw TSV row shape: `host\\ttmux\\tsession_name\\tdetail\\tresumed_uuid`.
The 5th column (`resumed_uuid`) is additive rows from an older
rclaude with only 4 columns are tolerated, mapping to `None`.
"""
raw = self._run(["list", "tmux", "--tsv"])
rows: list[TmuxRow] = []
@ -154,11 +160,13 @@ class Rclaude:
parts = line.split("\t")
if len(parts) < 3 or parts[1] != "tmux":
continue
resumed = parts[4].strip() if len(parts) >= 5 and parts[4].strip() else None
rows.append(
TmuxRow(
host=parts[0],
session_name=parts[2],
detail=parts[3] if len(parts) >= 4 else "",
resumed_uuid=resumed,
)
)
return rows

View file

@ -412,8 +412,16 @@ def broadcast(
failed = 0
errors: list[str] = []
for sid in session_uuids:
# `rclaude --match` matches tmux session names, never Claude UUIDs —
# resolve each session to its `tmux_name` before targeting. A session
# with no known tmux_name can't be reached; surface that as a failure.
session = read.get_session(conn, UUID(sid))
if session is None or not session.tmux_name:
failed += 1
errors.append(f"{sid}: no known tmux_name (not observed as a live pane)")
continue
try:
rcl.send(text=text, match=sid[:8], yes=yes, dry_run=not yes)
rcl.send(text=text, match=session.tmux_name, yes=yes, dry_run=not yes)
delivered += 1
except RclaudeError as exc:
failed += 1
@ -1435,6 +1443,23 @@ def dispatch_task(
"session spawned but not discovered within timeout",
)
# Record the UUID → tmux_name mapping for this fresh session. A freshly
# spawned pane has no `claude --resume <uuid>` in its start command, so
# the pull loop's resumed_uuid mapping will never see it — but dispatch
# knows both the new UUID and the tmux name it spawned. Emitting this now
# makes the dispatched agent addressable via `send_to_session` (which
# resolves UUID → tmux_name → `rclaude send --match`).
ev.append(
conn,
gen,
ev.SessionObserved(
session_uuid=UUID(new_uuid),
host=host,
cwd=cwd,
tmux_name=tmux_name,
),
)
assignment = create_assignment(
conn, gen, task_id=task_id, session_uuid=UUID(new_uuid),
)

View file

@ -14,21 +14,27 @@ from __future__ import annotations
from uuid import UUID
from claire.pull import pull
from claire.rclaude import SessionRow, TriageRow
from claire.rclaude import SessionRow, TmuxRow, TriageRow
class _FakeRclaude:
"""Deterministic rclaude stand-in returning the same rows every call."""
def __init__(self, sessions: list[SessionRow], triage: list[TriageRow] | None = None):
def __init__(
self,
sessions: list[SessionRow],
triage: list[TriageRow] | None = None,
tmux: list[TmuxRow] | None = None,
):
self._sessions = sessions
self._triage = triage or []
self._tmux = tmux or []
def list_sessions(self) -> list[SessionRow]:
return list(self._sessions)
def list_tmux(self) -> list:
return [] # no live panes in this idempotency test
def list_tmux(self) -> list[TmuxRow]:
return list(self._tmux)
def triage(self) -> list[TriageRow]:
return list(self._triage)

View file

@ -48,6 +48,36 @@ def test_rclaude_list_tmux_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
assert rows[1].host == "apricot"
def test_rclaude_list_tmux_parses_resumed_uuid(monkeypatch: pytest.MonkeyPatch) -> None:
"""The 5th TSV column (resumed_uuid) is parsed when present."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
output = (
# resumed session — has the 5th column
"local\ttmux\tclaude-natalie-foo-1715964800\t1 windows\t"
"08381960-ecca-49eb-9d6e-9f0c505e8f9e\n"
# fresh-spawned session — 5th column present but empty
"apricot\ttmux\tclaude-natalie-bar-1715964900\t2 windows\t\n"
)
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.list_tmux()
assert len(rows) == 2
assert rows[0].resumed_uuid == "08381960-ecca-49eb-9d6e-9f0c505e8f9e"
assert rows[1].resumed_uuid is None
def test_rclaude_list_tmux_tolerates_missing_5th_column(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Output from an older rclaude (only 4 columns) maps resumed_uuid to None."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
output = "local\ttmux\tclaude-natalie-foo-1715964800\t1 windows (created ...)\n"
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.list_tmux()
assert len(rows) == 1
assert rows[0].session_name == "claude-natalie-foo-1715964800"
assert rows[0].resumed_uuid is None
def test_rclaude_list_sessions_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
# `rclaude list sessions --tsv` interleaves tmux rows with session rows;