Skip to content

feat(cli): add cross-session messaging (phases 1 & 2) - #652

Merged
evankolega merged 26 commits into
mainfrom
feat/cross-session-messaging-phase1
Aug 26, 2026
Merged

feat(cli): add cross-session messaging (phases 1 & 2)#652
evankolega merged 26 commits into
mainfrom
feat/cross-session-messaging-phase1

Conversation

@evandempsey

@evandempsey evandempsey commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

Implements both phases of cross-session messaging (companion doc: cross-session-messaging-plan.md):

  • Phase 1 — in-process broker: a peer inbox registry for sessions sharing one host process, list_agents/send_message model-facing tools, inbound policy (cross_session_inbound: auto/accept/hold/refuse with the permission-mode asymmetry matrix), recorded PEER_MESSAGE_* events, and distinct TUI rendering (peer_message entries with a sender badge).
  • Phase 2 — same-machine cross-process transport: per-session owner-only Unix sockets at <state-dir>/messaging/<session_id>.<pid>.sock, filesystem discovery with pid-liveness sweeping, /rename + --name addressing, /peers, headless goal/loop workers as recipients, loop protection (repeat window + queue cap), and own-child delivery via KOLEGA_MESSAGING_SOCKET.

Design notes

  • A message is information, never authority. Peer text arrives framed as context with no authority; recipients never change settings because a message asked; outbound sends prompt in ask mode (PermissionKind.MESSAGE) and can be saved as rules.
  • The recipient decides. Inbound policy resolves at the recipient from both sessions' permission modes; holds ride the existing control-channel question flow and expire after dialog_expiry.
  • Delivery failures are errors. Unknown/unreachable/self recipients raise; refused deliveries report success (reference silent-drop parity); held messages honestly report "awaiting review".
  • Dead sessions never linger. Sockets carry the owning pid in their name; discovery sweeps files whose owner died. Bind refuses to steal another live process's socket.

Testing

  • New suites: 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).
  • Fast suite: 5106 passed locally (4 remaining failures pre-existing on main: 3 live ChatGPT provider tests + flaky mouse-drag selection test).
  • ruff check/format and pyright clean repo-wide.

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.
@evankolega
evankolega merged commit 62dba14 into main Aug 26, 2026
12 checks passed
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