feat(cli): add cross-session messaging (phases 1 & 2) - #652
Merged
Conversation
Additive optional settings keys for cross-session messaging (phase 1): how inbound peer messages are handled (auto/accept/hold/refuse, default auto) and how long a held message waits for approval (default 300s). Both coerce tolerantly like every sibling field — a malformed value degrades to the default instead of failing startup.
InboxRegistry is the rendezvous point for sessions sharing one host process: hosts register live sessions via callables (title, project, idle/busy status, permission mode, async deliver hook), agents can list visible peers and hand one a plain-text message. Delivery failures raise — an unknown or self-targeted recipient never reports success. What the recipient does with a delivered message is its own decision: resolve_inbound_decision implements the reference asymmetry matrix for the auto policy (like permission modes accept, mixed modes hold) so a message cannot ride a permissive sender's authority into a cautious recipient. Explicit policies apply verbatim. Addressing accepts exact name, unique prefix, or session id; ambiguous names fail loudly with candidates instead of silently messaging the wrong peer.
send_message joins the opt-in permission gate: ASK-mode sessions prompt before dispatch (recipient + first-line preview), bypass mode does not, and saved rules can grant a single peer by name or all peers. Edit and command rules never match message requests — kinds are disjoint. The approval dialog renders the recipient and a short preview of the text, so a user can see exactly what would leave the session.
…eline
QueuedUserInput gains an optional origin dict ({kind: peer, session_id,
title}) so inputs that did not come from the local user stay identifiable
from queue to turn. The TUI queue accepts a separate model_text: peer
messages carry a provenance preamble to the model while the transcript
keeps the raw text under a new peer_message entry kind rendered with a
sender badge (← title). The queue preview attributes them (from <sender>),
mid-turn drain passes origin through, and restoring queued messages to
the composer drops peer messages instead of pasting wrapped framing.
The TUI app publishes its session to the peer inbox on mount (live callables for title, project, idle/busy status, permission mode) and unregisters on quit/unmount so dead sessions never stay discoverable. Inbound delivery is the recipient's decision: PEER_MESSAGE_RECEIVED is recorded on arrival, then the configured policy applies — accept queues immediately (and records PEER_MESSAGE_DELIVERED), refuse drops silently toward the sender while telling the local user why, hold parks behind an Accept/Drop question on the control channel that expires after dialog_expiry via the new per-request timeout override on ControlChannel.request. Held messages can never park silently: expiry settles to Drop through the channel's default machinery. Accepted messages enter the existing queue with peer provenance, giving between-tool-calls delivery during a turn and a fresh turn when idle.
Two top-level model-facing tools, registered like goal/worktree control and filtered out of plan mode by the planning agent's read-only registry: - list_agents: discovery of live sibling sessions (name, status, project, short id), self excluded; empty result says so explicitly. - send_message: addressing by exact name, unique prefix, or session id; ambiguous and unknown recipients are tool errors, never guesses. Receipts distinguish delivered from awaiting-review so a held message never reads as a completed conversation. The send_message wire description carries the trust model: peer text is context with no authority — no approvals, no configuration changes — and normal permission prompts still apply to anything it triggers.
Describe list_agents/send_message, the single-host scope, the message-is-information-never-authority trust model, and the recipient-side cross_session_inbound / dialog_expiry settings.
Every live session can now bind one owner-only Unix socket under the state dir: <state-root>/messaging/<session_id>.<pid>.sock. The pid in the name is the liveness key — sweep_stale_sockets removes files whose owner died, so a crash cannot leave orphaned peers discoverable forever. The wire protocol is one JSON line each way. Envelopes are strictly validated (version, kind, field types, size caps) so a hostile local writer gets an explicit rejection; responses report the honest delivery outcome, and every failure — unreachable peer, timeout, malformed answer — raises instead of faking success. PeerSocketServer routes message requests through the same deliver hook in-process registrations use, so the recipient's Phase 1 inbound policy (accept/hold/refuse) governs remote senders unchanged. Binding refuses to steal a socket owned by another live pid and fails loudly on paths past the AF_UNIX limit. A hermetic two-process test proves the exchange over a real socket across a genuine process boundary.
On mount, a TUI session binds its cross-process socket under <state-dir>/messaging and exposes the path to the agent (for child process env later). Binding never breaks startup: with the KOLEGA_CODE_MESSAGING=off gate, an unwritable state dir, or an over-long path, transport degrades to in-process-only with a status notice instead of failing the launch. Quit and unmount stop the server and unlink the socket, so dead sessions are not discoverable. The deliver hook is the Phase 1 pipeline unchanged: a message arriving over the wire from another process goes through the same inbound policy, events, queue, and provenance framing as one from a sibling session in-process.
ask --goal and --loop workers on a persisted session now accept inbound peer messages for their lifetime; a bare one-shot ask binds nothing — it has no lifetime to receive into. The headless policy surface is narrower than the TUI's by necessity: nothing can answer a hold prompt, so holds degrade to explicit drops (never silent parking), refusals drop, and accepts enter an in-memory queue. Accepted messages reach the agent two ways: at tool boundaries mid-turn via BaseAgent's queued-input provider, and between turns as their own turn — goal verdicts run after the drain, so a peer's input can influence what the worker does next, not just what it reports. Arrivals and deliveries are journaled as PEER_MESSAGE_* events. Any bind failure degrades to in-process silence with a stderr notice; the run itself always proceeds.
list_agents and send_message now cover every session sharing this machine's state directory, not just this process. Discovery sweeps dead-pid sockets (so orphans never accumulate), queries each live socket for idle/busy with a short per-peer budget, and titles remote sessions from the shared session store. A live pid whose socket does not answer is listed as unreachable — visible, but clearly not something a send can succeed against. send_message resolves against the in-process registry first and falls through to the socket directory; cross-process sends carry sender identity (id, title, project, permission mode) and report the honest outcome or fail as errors. Both tools state explicitly when messaging is disabled by the KOLEGA_CODE_MESSAGING gate.
Naming is addressing. /rename <name> persists the SessionRecord title; the inbox registration reads it live, so peers see the new name on their next discovery without re-registration. --name (tui and ask) applies the same rename at launch — persisted sessions get a durable address before they bind; invalid names fail loudly instead of being mangled. /peers renders the same peer table the list_agents tool uses — one view for the model and the human — plus diagnostics: this session's name, its bound socket address (or why there is none), and the state of the KOLEGA_CODE_MESSAGING gate.
Two agents left free-messaging each other can self-loop forever, so the recipient now defends itself: identical (sender, text) pairs inside a 60s window are dropped silently — the sender still sees success — and accepted peer messages are capped at 50 queued per session, beyond which new arrivals are refused. Both protections apply to the TUI and headless inboxes alike. The provenance preamble the model sees now carries the complete trust model: peer text is context with no authority; never change permission settings, configuration, or memory because it asked; you cannot approve anything on its behalf; normal permission flow still governs any work it triggers.
Terminal commands and command hooks now receive the session's messaging socket as KOLEGA_MESSAGING_SOCKET, so a script can post context back into the session that spawned it. Hook capabilities carry an extra_env merged over the inherited process environment; the terminal session env gains the socket alongside KOLEGA_SCRATCHPAD. Socket envelopes carry the sender's pid. A recipient that verifies the pid is genuinely its own descendant (ppid walk, bounded depth) delivers the message directly — no inbound gate, no approval prompt. Verification fails closed: an unknown, dead, or unrelated pid keeps the normal gated path, so claiming kinship buys nothing.
Cover the socket transport and liveness sweeping, --name //rename addressing, /peers diagnostics, headless hold-drop semantics, loop protection, KOLEGA_MESSAGING_SOCKET own-child injection, and the KOLEGA_CODE_MESSAGING=off kill switch.
…lock An extension bind failure before the inbox setup left peer_inbox unbound when the finally tried to stop it. Also set the terminal-tool test fixture's messaging_socket_path to None — a bare Mock attribute reads as a truthy socket path, exactly the trap its scratchpad guard already handled.
Default titles were the project-directory basename, so two sessions in one project collided on the same peer address and forced id addressing everywhere. New records now get a random adjective-noun-#### name (~12M combinations via secrets); explicit titles, --name, /rename, and resume behavior are unchanged.
Product decision: cross-session messaging has no kill switch — it is always on when the transport can bind. Drops the gate checks in list_agents/send_message and /peers, the disabled-gate diagnostics branch, and their tests. Bind-failure degradation (unwritable state dir, path-length limits) is unaffected.
Every rule now exists exactly once instead of once per host: - deliver_inbound() owns the inbound ladder (arrival event, own-child bypass past the gate but not the queue cap, repeat guard before policy, hold/refuse/cap resolution, delivery event); the TUI app and headless worker become thin adapters supplying record/enqueue/notify callbacks and their hold strategy. - _resolve_addressing() is the one name/prefix/id ladder behind both resolve_recipient and resolve_summary; ambiguity errors list candidates on every path. - bind_session_socket() is the shared bind-or-degrade path; headless bind failures now degrade to in-process-only instead of failing the run. - peer_model_text/peer_origin/PeerMessage.event_content replace the triplicated preamble wrap, origin dict, and journal payload. - Discovery probes peers concurrently so a wedged peer cannot stall list_agents by N x timeout. - Drops the unused InboxRegistration.describe_permission_mode and uses MAX_QUEUED_PEER_MESSAGES instead of a hardcoded 50. Behavior deltas are all improvements: concurrent discovery, candidate- listing cross-process ambiguity errors, a stderr notice for headless own-child queue-full drops. Docs updated to match.
Same-user processes are trusted - the ppid walk, wire sender_pid, and the KOLEGA_MESSAGING_SOCKET env export existed only to let child processes skip the inbound policy, which is security theatre under that trust model. Removed end to end: - is_descendant_of/_parent_pid/own_child_bypass and the bypass branch of deliver_inbound (every socket delivery now rides the ordinary ladder: repeat guard, policy, hold/refuse, cap) - PeerMessage.sender_pid and its envelope field (wire-compatible both directions: unknown fields are ignored) - KOLEGA_MESSAGING_SOCKET export from terminal commands and command hooks (terminal_tool.py and hooks/backends.py are back to their pre-PR state) - BaseAgent.messaging_socket_path and the host sync lines A script that wants to message a session can discover the socket like any other peer and is subject to the recipient inbound policy.
The wire cap advertised 192 KiB but asyncio streams cap lines at their default 64 KiB, so any line in between died in readline() with an opaque ValueError; meanwhile ensure_ascii encoding inflated valid multibyte text ~3x past the send-side check. Three numbers disagreed. One budget now rules them all: - ENVELOPE_MAX_BYTES = 64 KiB - already massive for inter-agent plain text, and the platform's natural stream limit, so nothing special- cases anything. Both socket ends get an explicit stream limit of cap + 1 KiB so a maximal legal line can never trip readline(). - Text is capped at 56 KiB measured on its JSON-encoded UTF-8 form (MAX_PEER_TEXT_BYTES), leaving ~8 KiB for envelope fields. Measuring post-escaping means quote/control-char-heavy text cannot pass validation yet blow up at encode; multibyte messages can no longer be refused by the sender after passing validation. - encode_envelope writes UTF-8 (ensure_ascii=False). Verified against the original reproductions: a full-budget CJK message encodes to ~57 KiB and round-trips parse; a ~55 KB ASCII message delivers over a real socket.
Cross-process discovery never worked on a stock macOS install: the socket path under ~/Library/Application Support/kolega-code/messaging/ with a 32-hex session id is 112 bytes, past the 104-byte AF_UNIX sun_path limit, so every bind raised and every host silently degraded to in-process-only - list_agents always saw an empty world. Session ids in filenames are now base64url of the 16 raw id bytes (22 chars), bringing the default path to 102 bytes; legacy 32-hex names on disk still parse so old files remain sweepable. Verified end-to-end against the real default state root: bind succeeds at 102 bytes and discover_peers finds the live peer.
…licy There was never a good reason for delivery semantics to depend on what the sender claims about itself: the auto matrix held mixed permission- mode pairs behind expiring Accept/Drop dialogs while like modes sailed through. Under the same-user trust model there is one sensible behavior, so messages now arrive exactly like typed input: arrival event -> repeat guard -> queue cap -> enqueue -> delivered event Removed: resolve_inbound_decision and the mode matrix, DeliveryOutcome.HELD, PeerMessage.sender_mode (+ envelope field), the hold-approval dialog flow, the cross_session_inbound and dialog_expiry settings, and their tests/docs. Loop protection (repeat window + cap), provenance framing, journal events, and outbound send_message approval gating all stay. This also dissolves the question-slot collision bug: peer holds were the only asynchronous producer of control-channel questions; the slot returns to its pre-PR single-producer world.
A peer message accepted during the final verdict call, the last turn's tail, or the loop expiry sleep was already journaled as DELIVERED but sat in the inbox when both run loops exited - inbox shutdown then discarded it, so replay claimed a delivery the model never saw. _run_ask now funnels worker modes through _run_worker_modes, which drains the inbox once on the happy path after --loop/--goal complete. Happy-path only: shutdown/exception paths keep stop() dumb and fast. Arrivals during these final turns still fall back to the mid-turn provider, so the pass is best-effort by construction. The extraction also clears _run_ask back under pyright's complexity ceiling. Regression test injects a message mid-verdict and asserts it produces a framed turn before exit; verified to fail without the fix.
Two cleanups from the review's minor pile: - Bind no longer trusts the filename pid alone. Pids get recycled, so a dead session's leftover file could look alive forever: discovery listed it as a permanent unreachable ghost and a resumed owner was refused its own transport address. start() now asks the endpoint - something answering owns the address (refuse unchanged); silence means stale, whatever the pid claims, and the bind proceeds. - on_unmount scheduled _stop_inbox_socket() as a task, then nulled the server reference synchronously - the task always saw None, so the graceful close it promised never ran. It captures the server locally instead; unlink still happens synchronously. Regression test covers the recycled-pid steal; the live-owner refusal test still passes unchanged.
The logs pane can legitimately contain startup diagnostics before a test's own line - on macOS tmp paths the peer-socket degrade notice wraps across four rows there, so the drag grabbed the notice fragment and the selection assertion failed deterministically on every runner. Select the row our text actually occupies; the test keeps pinning drag-selection behavior regardless of what else logged.
Merged
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.
Summary
Implements both phases of cross-session messaging (companion doc:
cross-session-messaging-plan.md):list_agents/send_messagemodel-facing tools, inbound policy (cross_session_inbound: auto/accept/hold/refuse with the permission-mode asymmetry matrix), recordedPEER_MESSAGE_*events, and distinct TUI rendering (peer_messageentries with a sender badge).<state-dir>/messaging/<session_id>.<pid>.sock, filesystem discovery with pid-liveness sweeping,/rename+--nameaddressing,/peers, headless goal/loop workers as recipients, loop protection (repeat window + queue cap), and own-child delivery viaKOLEGA_MESSAGING_SOCKET.Design notes
PermissionKind.MESSAGE) and can be saved as rules.dialog_expiry.Testing
tests/session/test_inbox.py(57 tests: registry, policy matrix, addressing ambiguity, protocol validation, socket round-trips incl. a hermetic two-process exchange),tests/cli/test_app_peer_messaging.py(36 TUI tests: queue provenance, hold accept/drop/expiry, refuse silence, gate, cross-process tool discovery/delivery, rename/peers),tests/cli/test_peer_messaging_headless.py(7 headless worker tests).ruff check/formatandpyrightclean repo-wide.