Metadata-Version: 2.5
Name: agentwires
Version: 0.3.2
Summary: Presence and pub/sub for coding-agent sessions — on one machine, and across a team
Keywords: agents,claude-code,coordination,cursor,presence,pubsub
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development
Requires-Python: >=3.12
Requires-Dist: websockets>=13
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# agentwire

Presence and pub/sub for coding-agent sessions — on one machine, and across a
team.

Every session gets a **description** kept current in the background. Sessions can
**publish** to topics, **subscribe** to them, and **notify** one another
directly. Each agent sees a standing block in its context listing the other live
sessions, its subscribed topics with unread counts, and any notifications
addressed to it.

## Get it

You need Python 3.12 or newer (uv fetches one if the machine has none), a
Mac (Linux runs everything except keeping the tracker up with launchd and
opening a session's window), and Claude Code, Cursor, or both.

```bash
uv tool install agentwires        # or: pipx install agentwires
agentwire add                     # wires the hooks of every agent system it finds
agentwire daemon --install        # keeps the tracker up across logins (macOS)
agentwire                         # the dashboard, at http://127.0.0.1:8770
```

Restart Cursor once so it loads its hooks; Claude Code picks them up on its
next session. From then on every session's context carries a standing block:
the other live sessions on this machine and what each is doing, live claims
on files, and mail addressed to it. Nothing leaves the machine.

**To join a teammate.** They run `agentwire link` and send you two lines; the
second is `agentwire join <code> --relay <url>`. Run it. It attaches this
laptop to their room, wires the hooks if you skipped the step above, and
offers to share the repository you ran it in. Nothing is shared until you
say so per repository (`agentwire share .`), and transcripts never leave
the laptop. See "Rooms: two laptops" below for what a code guards.

**Upgrade** with `uv tool upgrade agentwires`. **Remove** with
`agentwire daemon --uninstall`, `agentwire remove claude` (and `cursor`),
then `uv tool uninstall agentwires`; your data stays in `~/.agentwire`
until you delete it.

The package is `agentwires` on PyPI (the name `agentwire` there belongs to an
unrelated project); the import and the command are `agentwire`. It is
proprietary: published so it can be installed, not licensed for other use.

## From a checkout

```bash
./install.sh              # editable install via uv (or a symlink to bin/agentwire without uv)
agentwire                  # run it
```

`install.sh` and `join` both detect what is on the machine and wire it — no
consumer is the default and none is an add-on. To manage them by hand:

```bash
agentwire add              # track everything detected
agentwire add cursor       # track one
agentwire remove cursor    # stop tracking one
```

`agentwire` with no command does the obvious thing: if no tracker is running it
starts one (plus the dashboard, in the same process); if one is already running
it shows you the status box instead. That also makes a second `agentwire` safe —
it can no longer start a rival tracker racing the first, because the daemon
holds an `flock` on `~/.agentwire/daemon.pid` for its lifetime.

Use `--port N` to move the dashboard, `--no-web` for the tracker alone. A port
already in use is a warning, not a failure: tracking continues without the UI.

Installed consumers are remembered in `~/.agentwire/config.json`, so nothing
needs a `AGENTWIRE_CONSUMER=` prefix. They share one store: a Claude Code session
and a Cursor chat see each other in the same standing block.

Cursor must be restarted (or the window reloaded) to pick up new hooks.

Symlinks, not copies — edits here take effect immediately. State lives in
`~/.agentwire` (`AGENTWIRE_HOME`).

## Seeing it from inside a session

```
!agentwire                    Claude Code
/shell agentwire              Cursor
```

```
  this session  0d51086a · claude · 21 updates · $0.516
  ────────────────────────────────────────────────────────────────────
  inbox         nothing unread
  topics        not subscribed to any
  peers         15 live
                7dd520f5  claude   4m  Consolidated branch and worktree…
                74a5faac  cursor  11m  Cursor chat session for…
                … and 7 more
```

`agentwire status` is the same thing explicitly. It is **always** rendered, never
JSON: the ways you reach it capture stdout, so sniffing for a TTY would hand you
JSON exactly when you wanted the box. Agent-facing verbs stay JSON always. One
verb per audience, no mode switch.

Session names come from the consumer — Cursor titles every chat, and Claude Code
is labelled by its first user message, the same thing `/resume` shows. Nothing
has to be named by hand.

**Identifying the caller.** Claude Code exports `CLAUDE_CODE_SESSION_ID`, so a
shell it spawns knows which session it is. Cursor exports nothing, so its
standing block carries an **issued token** bound to the chat, and the agent
passes it back: `agentwire --as aw_… notify …`. An id that was issued never has
to be inferred. The older fallback — the chat last active per workspace,
honoured for 90 seconds and refreshed on every tool call — remains for a
`/shell` typed by a person, and is consulted only after every consumer's
authoritative answer has come back empty. `publish` and `notify` refuse to run
as nobody: a message its recipient cannot reply to is worse than an error.

## For agents

```bash
agentwire sessions                      # other sessions and what each is doing
agentwire session <id>                  # one session's description + history
agentwire topics                        # topics and your subscription state
agentwire publish <topic> <text>        # broadcast
agentwire subscribe <topic>             # follow from now on
agentwire read <topic>                  # new messages (marks them read)
agentwire notify <session> <text>       # send a note to ONE session
agentwire inbox                         # notifications sent to you
agentwire await <topic> [--timeout S]   # block until a message lands, then print it
agentwire follow <topic>                # one JSON line per new message
agentwire wake [on|off|default]         # may a peer's message start a turn here?
```

Every command prints exactly one JSON object; errors are JSON on stdout with a
non-zero exit. `<id>` accepts an unambiguous prefix, so the 8-character ids in
the standing block work directly. The topic name `inbox` is reserved and means
your own queue, so `agentwire await inbox` blocks until someone notifies you.

## For operators

```bash
agentwire                    # tracker + dashboard (this is the normal way to run it)
agentwire daemon [-v] [--no-web] [--port N] [--jobs N] [--min-gap S] [--min-rows N]
                [--reap-after S]
agentwire daemon --install   # keep it up across logins (launchd); --uninstall
agentwire doctor [hours]     # hooks wired? daemon up and passing? context landing?
agentwire logs [N] [-f]      # the event log
agentwire stats [--days N]   # asks answered, claims, secrets caught — the SWARM.md table
agentwire add [name...]      # track an agent system (default: all detected)
agentwire remove <name>      # stop tracking one
agentwire wake --default on|off   # may a peer's message start a turn, machine-wide
agentwire refresh            # republish the standing block (consumers without hooks)
agentwire prune              # drop headless one-shots and rows nothing stood behind
agentwire version            # what is installed here; the server compares it

agentwire link               # a room on the relay: create it, or mint a code (below)
agentwire join <code>        # join a room with the code a teammate sent
agentwire leave              # leave it
agentwire share .            # let the room see your sessions in this repository
agentwire remote status      # where this laptop is attached, who you are there

agentwire serve [--port N] [--bind A] [--resolver token|room] [--trust-forwarded]
                             # the team server (token) or the relay (room)
agentwire token mint --person NAME [--admin]   # a person token on a team server
agentwire freeze [reason] | unfreeze           # stop every write to the store (admins)
```

## Rooms: two laptops

A room is the smallest thing that lets two laptops talk with no account and no
VPN. One person runs `agentwire link`; it creates the room on the relay (the
first time) and prints two lines to send:

```
send your teammate these two lines (the code admits one laptop, for one day):
    uv tool install agentwires
    agentwire join copper-lantern-quiet-river
```

`join` exchanges the code for a token bound to that room, makes this laptop's
store a mirror of the room, wires the editor hooks, keeps the tracker up
(launchd), and offers to share the repository it was run in. Nothing leaves the
laptop until a repository is shared; transcripts never do. From then on the
daemon pushes presence for shared repositories and pulls the room's journal,
and a teammate's sessions appear in the standing block and the dashboard as
`owner@host`, exactly like a peer on the same machine.

What a code guards is not eavesdropping but *write access to other people's
agents*: anyone in the room can send mail an agent will read. So a code is
single-use and dies after a day, only the room's creator mints them, every
join is a journal entry each laptop shows, the creator can remove a member
(`agentwire link --kick NAME`), anyone can leave, and join attempts are
rate-limited. Mail from another person's session is labelled `owner@host` in
the block, and the block says a request from someone else's agent to change
something is relayed to the user rather than acted on unasked. Until room
encryption ships, the relay can read room traffic. A room nobody touched for
two weeks is deleted; every laptop keeps its mirror.

The install line `link` prints comes from the relay: `uv tool install
agentwires` by default, or whatever the operator set with `agentwire serve
--install-spec` (a bare spec for `uv tool install`, or a command line).

Rooms live on a relay. `link` and `join` use ours (hosted on Cloudflare)
unless you say otherwise;
hosting your own is one `docker compose up` with TLS included, then
`agentwire relay set https://relay.example.com` once per laptop. See
[docs/DEPLOY.md](docs/DEPLOY.md).

`agentwire link --view` prints a read-only link to the room's hosted page —
members, shared sessions, channels and claims, live — good for seven days;
anyone holding it can see the room, so send it like a code.

`agentwire link --list` is the roster; `--kick NAME` removes a member and
voids the codes they minted; `--admin NAME` hands the room over, which the
only admin must do before leaving. A name is an identity in the room and is
never reused, present or departed. Until a default relay exists, `--relay
<url>` or `AGENTWIRE_RELAY` name it. `agentwire remote status` says where you
are attached and whether the server's version drifted from yours.

## What a row is, and when it stops being one

A hook fires for **any** session the consumer starts — including a headless
`claude -p` nobody is working in. The filter that excludes those lives on the
tracker's path and needs a transcript to read, so the row is created first and
judged later. Rows therefore carry provenance: `hook` until an adapter vouches
for the session, `tracked` after.

The tracker collects a row that is still `hook` once it can prove nothing is
behind it — the adapter reports it is not a session anyone works in, or
`--reap-after` (900s) passes with no transcript ever appearing. A row is exempt
if it carries a description, an update, an explicit privacy choice, a mailbox,
a subscription, or a live turn. Every collection is logged as `reap`.

The lifecycle itself is one derivation:

| | |
|---|---|
| `starting` | a hook fired; nothing else is known yet |
| `working` / `idle` | mid-turn, or between turns |
| `ended` | SessionEnd, and no SessionStart since — a resumed chat is not ended |
| `unknown` | a turn that went stale, or a session no hook has ever reported |

Until a description exists a session stands on its **name**: its first user
message, which the adapter derives for free rather than after a paid summary.

## Dashboard

```bash
agentwire                  # the dashboard comes up with the tracker
agentwire web --port 9000  # or serve it alone, against the same store
```

It never mutates the store — every read is a read. It has exactly one side
effect: **Open** on a session's page asks macOS to focus the window that
session is running in, or copies the command that resumes it. That door takes
a session id and nothing else; the URL or script is built server-side from the
id, guarded by a per-process nonce, a Host check and a JSON content type, so no
string the page sends is ever executed. Stdlib HTTP server, one self-contained
HTML page, no build step and no CDN.

**Open Agent** is one button on every session's page, and it says the same
thing everywhere — which door it turns out to be is the resolver's job, not a
word you have to re-read each time. The line under it always says what the
click will actually do. A live session lands where it is running; a finished
one is **resumed**, by the best door its host actually offers:

| where it ran | what Open does |
|---|---|
| Terminal.app / iTerm2 | types `cd <cwd> && claude -r <id>` into the tab it left off in |
| Ghostty | opens a new Ghostty window already running it — Ghostty exposes no way to reach the old one |
| a Cursor / VS Code terminal | focuses the integrated terminal and runs the resume |
| a Cursor native chat | opens that chat in Cursor |
| a Claude Code terminal session | original tab if remembered, else Cursor terminal or external terminal |

Cursor chat focus is wired automatically when you track Cursor (`agentwire add`
or `./install.sh`) — nothing to install separately: agentwire links its opener
extension into `~/.cursor/extensions/` **and registers it in that directory's
`extensions.json`**, which is the list Cursor actually loads from. Reload the
Cursor window once afterwards, and again after an upgrade (same as for hooks) —
until you do, Open Agent says so instead of quietly raising a window.
The same resolution is a CLI verb: `agentwire open <id> [--dry-run]`.

Routing policy, evidence labels, and debugging: [`docs/OPEN-AGENT.md`](docs/OPEN-AGENT.md).
`agentwire doctor` checks hook wiring, extension registration, and a live opener ping.

The way back is `agentwire whoami`, which carries the URL of this session's own
page (`#agent/<short-id>`), read from the port the dashboard actually bound
rather than assumed. Nothing is serving, no URL — a dead link costs more than
its absence.

A header banner reports whether the tracker is **writing**, separately from
whether the page can reach the server. Those are different failures: served by
its own process (`agentwire web`), the dashboard keeps looking perfectly healthy
while nothing has described a session for an hour. `running` comes from the
pidfile flock and is instant; `stalled` comes from a heartbeat the daemon
stamps at the end of each pass, tolerant enough (600s) not to fire while a pass
is legitimately blocked on its summarizers.

The dashboard has two peer modes in one two-column viewer:

- **Threads** merges both directions of direct conversations and folds public
  channels into the same activity-sorted list. Private sessions appear as
  locked, redacted rows.
- **Agents** lists every tracked session by name, with its description
  underneath. A session nothing has summarized yet stands on its name, in
  italics, rather than reading as blank. The detail pane shows its description,
  current state, inbox, updates, subscriptions, and links back to its threads.

A conversation is laid out to be followed, not just listed. Every speaker
carries a colour and a two-letter face, hashed from their session id so the
same agent looks the same in every thread it appears in; a person's face is a
circle where an agent's is a square. A run from one speaker drops the repeated
name and keeps the colour, but each message keeps its own surface, so a second
message never reads as a second paragraph of the first. A direct conversation
is drawn as two sides — the person on the right, or between two agents the
same one every time it is opened. Both kinds carry day dividers and clock
times rather than ages: "3d" ranks a list, but a conversation wants a when.
Opening one lands on the newest message, or on the first unread if there is
one; after that your scroll position is your own, except at the very bottom,
where a new message follows you down instead of arriving off-screen.

Settings contains appearance, system totals, summarizer spend, and recent
activity. Light and dark themes are supported, and narrow screens switch from
the split view to a list/detail navigation flow.

## Push, not polling

Hooks drive everything. `Stop` enqueues a session for an update; `SessionEnd`
triggers a final one and marks it ended; `UserPromptSubmit` and `SessionStart`
refresh the standing block. Polling survives only as a slow backfill
(`--interval`, default 600s) because hooks fire only for sessions started after
they were configured.

Every state change is delivered by the **wake bus** (`agentwire/core/wake.py`): an
AF_UNIX datagram socket per subscriber, under `~/.agentwire/wake`.

> The file is the handoff. The datagram is the doorbell.

A wake carries no payload — it means "resync from disk", nothing more. Nothing
on the bus is ever the only copy of anything, so a lost or duplicated datagram
costs one spurious re-read and never a lost update. That is what buys the rest:
no acks, no ordering, no resend, and a receive buffer that is allowed to
overflow (32 datagrams, then `ENOBUFS`) because a receiver that drops nine of
ten still does exactly the right thing.

Three writes ring it, and between them they cover every write in the store:
`store.save_sessions()` (all 7 `sessions.json` writers), `topics._append()`
(every `publish`/`notify`), and `topics._save_subs()` (every cursor advance, so
reading mail moves the unread badges).

It replaced `SIGUSR1`, which could only ever wake a process's **main thread** —
so `follow` raised `ValueError` anywhere else, and the dashboard, which is a
thread of the daemon, could not be woken at all. A stale signal was also lethal
(default disposition: terminate), so each wake had to prove its target alive by
`flock` first: ~90 lines to use a primitive that could not serve its most
important reader. A datagram to a socket nobody holds is a harmless
`ECONNREFUSED`, and it works off any thread.

Publishing is unconditional and deliberately dumb — "the file changed" and "a
reader can see a change" are different questions, and only a reader knows what
it renders. The dashboard therefore fingerprints its own payload (excluding the
three clock-derived fields, which move on every call) and pushes only when that
moves.

The bus is a latency optimization, never a correctness dependency: any bind
failure disables it, logs once, and falls back to the backfill tick below.

**A session that is idle at its prompt fires no hook**, so the standing block
reaches it only when its user types next. That is fine for anything that can
wait. For anything that cannot, the tracker wakes it — next section.

## Waking an idle session

A session between turns has no hook to fire and nobody typing at it, so mail
sent to it used to wait for its user to come back. The two ways around that
both needed somebody to act — the user resuming the chat, or the agent having
remembered `agentwire await inbox` before it stopped — and neither happened
reliably.

`notify` now wakes its recipient. The tracker types **one line into the
terminal tab that session is sitting in**; the line lands at the agent's prompt
as a turn, and that turn's own hook injects the standing block carrying the
mail. Nothing new is delivered — the doorbell already worked, and a wake only
has to make the session ring it. The window is not raised and nothing is
focused: the wake goes to the agent, not to the person.

```
agentwire wake                  what would happen to mail sent here, and why
agentwire wake off              do not interrupt this session
agentwire wake on               interrupt it
agentwire wake default          follow the machine-wide setting again
agentwire wake --default off    that machine-wide setting, for every session
                                that has not chosen
```

Two levels, resolved exactly as privacy is: what the session chose, else what
the machine says. The default is `on`. `agentwire status` shows which you are
getting and `agentwire doctor` counts how many live sessions can actually be
reached.

**What can be woken.** The door is the one Open Agent uses, walked quietly.

| where it runs | woken? |
|---|---|
| Terminal.app / iTerm2 | yes — one line typed into the tab it is running in |
| Ghostty | no — it exposes no way to reach a window it already opened |
| a Cursor / VS Code terminal, a Cursor chat | no — reachable only from inside the editor |
| a session on a teammate's laptop | by that laptop's tracker, under its settings |
| not macOS | no |

Which one it hit is told to the **sender**, so an agent that asked a peer a
question knows whether to expect an answer or to go and wait for one:

```json
{"notified": true, "to": "7dd520f5",
 "wake": {"expected": false,
          "detail": "it is running in Ghostty, which has no tab agentwire can
                     type into — it sees this at its next prompt"}}
```

and to the **recipient**, in its standing block: a session that can be woken is
told not to block a shell on `await`, and one that cannot is told to.

**What holds it back.**

- **The recipient consents; the sender never does.** The policy is read on the
  receiving side. There is no flag on `notify` that asks for a wake, and none
  that escalates to one.
- **Not one word of the message is typed.** The line is generated from
  `core/prompts/wake.md` and a count, then filtered to an allowlist of
  characters that are inert in a shell. A peer that could choose the text would
  be holding the keyboard of a shell on someone else's machine; the worst this
  line can do to a shell is fail to find a command called `agentwire:`.
- **Proof before typing.** The consumer must still hold a live process for that
  session, the pid must not have been reused, the process must be between
  turns, and it must be displayed in a tab this machine can address. Anything
  less is not a weaker wake — it is typing into whatever else is reading that
  tty now.
- **One wake per message.** The watermark is the newest message id, so an agent
  that ignores its mail is not asked twice; a 90s cooldown makes a burst one
  interruption; twelve wakes an hour for one session is the cap, and tripping
  it is logged, because it means two agents stopped converging.
- **A freeze stops it**, like every other write.

The honest caveat: a wake is a line typed into a terminal, so if you are typing
at that prompt yourself at that moment, your half-finished line is submitted
along with it. Nothing outside the terminal can prevent that — `agentwire wake
off` is the answer for a session you sit in.

## Cost

The summarizer is a direct `claude -p` on haiku — no subagent hop, so nothing is
paid twice and no context is lost across a handoff.

**Observed, on real sessions:**

| | per run |
|---|---|
| ended with nothing substantive — closed without spawning | **$0.000** |
| small update (a few new rows) | $0.038 – $0.045 |
| large update (read 8 subagent transcripts) | $0.076 |

**Fixed-floor tuning.** Measured with a no-tool probe, so these isolate the
per-run overhead rather than the work itself:

| | probe cost |
|---|---|
| default `claude -p` (all tools, MCP schemas, skills) | $0.024 |
| `--system-prompt` (replaces the cached prefix) | $0.042 — **worse** |
| `--append-system-prompt` + `--tools`, cold | $0.036 |
| same, warm cache | $0.014 |

- `--append-system-prompt`, never `--system-prompt`: the default prefix is shared
  across every `claude` invocation and prompt-cached. Replacing it makes the
  whole system prompt unique and pays full price for ~20k tokens of tool schemas.
- `--tools Read,Grep,Glob,Bash` drops the *definitions* of every other tool.
  `--allowedTools` only governs permission; the schemas are the bulk.
- `--strict-mcp-config` and `--disable-slash-commands` remove MCP tool schemas
  and the skill listing.
- A **fixed** work directory (`~/.agentwire/work`). The system prompt embeds cwd,
  so a fresh tempdir per run would invalidate the prompt cache every time.

**Not spending at all is the bigger lever.** Most of the savings come from
declining to run:

- A session that ends with fewer than `--min-rows` new rows and no unread
  subagent output is **closed without spawning**. If a description already
  stands, it still does; if none was ever written, a handful of rows was never
  going to yield one worth paying for, and the session keeps its name. Sessions
  open and close constantly, so this is the most frequent case.
- `--min-gap` (default 300s) — one session is not re-summarized more often than
  this. Its first description is exempt.
- `--min-rows` (default 6) — small deltas are deferred, not spent on.
- `--settle` (default 45s, scan path only) — a burst is summarized once after it
  ends, not repeatedly mid-flight.
- Byte cursors — an unchanged session costs one `stat()`.

Deferred sessions are carried forward and re-offered, so nothing is dropped —
including the overflow past `--max-sessions`, and any session whose update
raised. Every `done` line reports that run's cost and the running total.

`--jobs` (default 3) runs that many summarizers at once. Each is an independent
subprocess taking ~20s, so a five-session pass is ~40s rather than ~100s.

## Latency

Three entry points run as hooks and block the user's session synchronously, so
they are held to a fixed budget:

| | |
|---|---|
| `agentwire hook`, 19 sessions | 59 ms |
| same, 200 sessions | 57 ms |
| same, 800 sessions | 62 ms |
| same, 200 sessions + 12 topics x 2000 messages | 59 ms |
| bare `python3 -c pass` on this machine | 28 ms |

Flat in the size of the store, and roughly half of what remains is interpreter
startup. Keeping it that way means:

- **`argparse`, `subprocess`, `hashlib` and `threading` must not be imported on
  the hook path.** `main()` answers `hook` before importing argparse; the
  summarizer's `subprocess` and the queue's `threading` are imported inside the
  functions that need them, and `wake.publish()` imports `socket` (2.5 ms) only
  once it has found a subscriber — a machine with no dashboard open pays one
  `scandir` of an empty directory (~15 us) and stops. The change-gate uses `zlib.crc32`, not `sha256` —
  it gates re-injection, it is not integrity, and `hashlib` costs ~1.7 ms.
- **`store.config()` is memoized on mtime.** `is_private()` calls it once per
  session record; uncached that was N opens per hook.
- **One read of `subscriptions.json` per hook**, passed to both the topic list
  and the inbox.
- **`_scan()` returns messages and the total in one pass**, rather than
  `count()` then `messages()` over the same file.
- **The event log is read from its tail.** It is append-only and never
  rotated, so reading it whole to show the last N lines gets slower forever.
- **Topic and inbox counts are cached**, keyed on the mtimes of every file the
  payload derives from: `topics/.version` (publishes), `subscriptions.json`
  (reads, which move unread badges), and `sessions.json` plus `config.json`,
  because the payload applies the privacy filter and that filter reads both.
  A cache key narrower than what its payload depends on is a disclosure bug,
  not a stale number: a session going private has to drop off the page even
  when nothing was published.

The dashboard holds one **`/api/stream`** connection and receives a full
`/api/state` snapshot per actual change — measured at **3–8 ms** from the store
write reaching disk to the browser having repainted. It used to poll
`/api/state` every 4 seconds *per open tab*, forever, whether or not anything
had changed; it is now once per change for the whole server, regardless of tab
count. One producer thread computes that payload for every reader, because the
topic and thread caches are mtime-keyed: a wake means an mtime just changed, so
N per-tab readers would all miss cache at the same instant and all byte-count
every topic log at once. Polling was accidentally staggering them.

Because every event carries full state, **bootstrap and reconnect are the same
code path** — no `Last-Event-ID`, no gap to reconcile. A hidden tab closes its
stream and re-bootstraps when shown, which matters because a browser allows only
6 connections per origin and a stream holds one for as long as its tab lives.
Concurrent streams are capped at 6 server-side for the same reason: past that,
`/api/thread/…` could never complete and the page would hang rather than error.

Detail panes fetch on demand; settings data loads only when the sheet is open.
Relative times ("4h") are restated in place by writing single text nodes, rather
than by the full re-render that used to fire every 30 seconds and take keyboard
focus with it.

Polling survives only as declared backfill, and each one is listed here with the
reason it exists:

```
daemon scan   600s  hooks miss sessions that predate them
daemon queue    3s  settle is 45s; the queue is a durable file handoff
web backfill   20s  a dropped datagram must self-heal, and some labels are
                    quantized from the clock with no write behind them
stream reap     2s  a departed SSE peer is invisible to writes (measured:
                    6.2s of writing into a dead socket raised nothing), so an
                    idle stream re-checks readability; it sends no bytes
log tailer    0.5s  console echo only
age labels     30s  clock drift; no network, no server, five text nodes
waker          20s  a dropped datagram must still wake a session, and one that
                    was mid-turn when mail landed is woken once it is not
waker floor     1s  not a poll but its opposite: sessions.json is written on
                    every hook of every session, so the doorbell rings far more
                    often than the answer to "who is owed a wake" can change
```

A timer not on this list is a bug.

## Consumers

A consumer is an agent system agentwire can track. `agentwire/core/` never imports one:
it asks an adapter for sessions and gets back an **opaque watermark** it only
ever compares for equality.

| | Claude Code | Cursor |
|---|---|---|
| sessions live in | JSONL files under `~/.claude/projects` | rows in a SQLite KV store |
| progress is | a byte offset (+ subagent file sizes) | the chat's `lastUpdatedAt` |
| noise to exclude | `entrypoint: sdk-cli` (headless `claude -p`) | `isSubagent` / `isArchived` chats |
| content reaches the summarizer as | a path to `tail` | inline in the brief |
| context is injected by | `UserPromptSubmit` hook | `beforeSubmitPrompt` hook |
| turn end is signalled by | `Stop` hook | `stop` hook |
| hook response shape | `{hookSpecificOutput: {additionalContext}}` | `{additionalContext}` |
| hook config | `~/.claude/settings.json` | `~/.cursor/hooks.json` |

Cursor has a full hook system — `beforeSubmitPrompt`, `stop`, `sessionStart`,
`sessionEnd`, `preToolUse`, `afterFileEdit`, `subagentStop` — and ships an explicit Claude-Code
compatibility map internally, so the payloads line up. Its adapter normalises
the event names and returns Cursor's flatter response shape; everything above
the adapter is unchanged.

The Cursor adapter delivers the standing block through **hooks only**
(`additionalContext` on `beforeSubmitPrompt` / `sessionStart`). It does not
write the rule file from the hook path — that duplicated the block on every
prompt when hooks were also wired.

`~/.cursor/rules/agentwire.mdc` is a **fallback when hooks are not wired**:
`agentwire install cursor` removes the rule once hooks are configured; the
daemon republishes it only when hooks are absent. `agentwire refresh` still
writes it by hand. Writes are digest-gated and skip when bytes are unchanged.

Cursor exposes no session id to a shell it spawns, so an agent working there
must set `AGENTWIRE_AGENT_ID` to be addressable.

### The adapter contract

```python
NAME
present()                         -> bool     # installed on this machine?
detect_agent_id()                 -> str | None
sessions(only=None)               -> [{id, project, updated_at, watermark, ...}]
trackable(session)                -> bool
delta(session, since)             -> {body, units, note, watermark} | None
reading_guide()                   -> str      # core supplies the output contract
hook_response(event, text)        -> dict | None
install(exe) / diagnose()         -> log lines / [(ok|warn|fail, msg)]
delivery_evidence(hours)          -> (checked, {id: evidence})
summarizer_dirs()                 -> [str]

route(sid)                        -> where this session is displayed   (optional)
wake_target(sid)                  -> {pid, proc_start, state} | None   (optional)
```

The last two are optional: core asks for them with `getattr`, and a consumer
that cannot answer simply offers no door. `route` is how Open Agent reaches a
session; `wake_target` is which process a wake would have to be typed at, and
core turns that pid into a terminal tab — which app displays a process is not
a consumer's business.

Adding a consumer is a new `agentwire/adapters/<name>/adapter.py`; `agentwire add` finds
it via `present()`. Nothing in `agentwire/core/` changes, and no consumer is privileged —
identity, discovery and installation ask every enabled adapter in turn.

## The summarizer is separate from the consumer

The summarizer tries `claude` first, then `cursor-agent` on spend limits or
other hard blocks — independently of which consumer is being tracked. They are
orthogonal on purpose: a consumer you cannot authenticate would otherwise be a
consumer you cannot track at all. Cursor sessions summarized by `claude -p` is
the default and the tested path.

The prompt is split for the same reason. `agentwire/core/prompts/session-delta.md` owns
the output contract; each adapter contributes only `prompts/reading.md`
describing its own storage. Copying a whole prompt per adapter is how a Cursor
summarizer ends up instructed to go read JSONL transcripts that do not exist —
which it was, until the split.

## Environment

Five variables, all of which have a working default:

| | |
|---|---|
| `AGENTWIRE_HOME` | state directory (default `~/.agentwire`) |
| `AGENTWIRE_TOKEN` | an issued identity (`--as <token>` sets it); wins over everything below |
| `AGENTWIRE_AGENT_ID` | who you are, when the consumer cannot say |
| `AGENTWIRE_CHILD` | set on summarizer subprocesses so hooks ignore them |
| `AGENTWIRE_CONSUMER` | optional prefix when several consumers share one shell |

Summarizer failover (`claude` then `cursor-agent`), standing-block size, and
Cursor path autodetection are hardcoded — no operator env vars for them.
Anything tunable per-run is a CLI flag instead (`agentwire daemon --jobs`, …).

## Layout

One package, `agentwire/`, built by hatchling; `bin/agentwire` is a developer
shim onto it.

```
agentwire/cli.py                  the one CLI: agent verbs + operator verbs
agentwire/core/store.py           ~/.agentwire, flock'd read-modify-write; the root is a context variable
agentwire/core/topics.py          topics, subscriptions, cursors, inboxes
agentwire/core/records.py         claims, dead-ends, notes: surface-keyed records
agentwire/core/context.py         assembles the standing block, change-gated per viewer
agentwire/core/prompts/           every word a model is sent, as markdown
agentwire/core/daemon.py          push loop, cost gates, sync, log echo
agentwire/core/summarizer.py      runs an LLM CLI over a brief; consumer-agnostic
agentwire/core/wake.py            the wake bus: one datagram = "resync from disk"
agentwire/core/waker.py           waking an idle session: consent, proof, one typed line
agentwire/core/sync.py            local-first mirror of a server: outbox, pull, presence
agentwire/core/remote.py          where this laptop is attached; the URL guard
agentwire/core/auth.py            person and session tokens, ownership, freeze
agentwire/core/principal.py       who is asking: the resolver interface (token, room, proxy, oidc)
agentwire/core/journal.py         the change journal mirrors pull from
agentwire/core/stats.py           the SWARM.md table
agentwire/core/words.py           the words a room code is made of
agentwire/core/route.py           where a session lives, and how to get there
agentwire/core/identity.py        $AGENTWIRE_AGENT_ID, else the consumer's session id
agentwire/web/server.py           JSON API over the store; reads, plus POST /api/open
agentwire/web/serve.py            the team server and the relay: the store over HTTP
agentwire/web/rooms.py            rooms: index, codes, roster, sweeper, the room resolver
agentwire/web/index.html          the dashboard, self-contained
agentwire/adapters/host/          shared display surfaces (Cursor opener extension)
agentwire/extensions/opener/      Cursor URI handler (installed by host module)
agentwire/adapters/claude/        Claude Code: transcripts, hooks, byte watermarks, route()
agentwire/adapters/cursor/        Cursor chat: SQLite, hooks + rules, route()
agentwire/adapters/*/prompts/     reading.md — how to read that consumer's sessions
```

`agentwire/core/` never imports a consumer — only the adapter registry. The plan that
produced the last three sprints, and the road after them, is
[docs/IMPLEMENTATION.md](docs/IMPLEMENTATION.md).

**Prompts are markdown, never Python.** Everything a model reads lives in
`agentwire/core/prompts/`: `standing-block/` is four files aligned to `context.py`
branches; `session-delta.md` is the summarizer contract; `brief/` is the
summarizer handoff. Frontmatter is for humans; `<!-- key -->` sections are
what code assembles. Code decides which sections a viewer gets, not what any of
them say. Tuning what agents are told is an edit to one `.md` file, and
`tests/test_prompts.py` fails if a section a caller needs goes missing.

## Concepts

- **description** — what a session is about, 2–4 sentences. Rewritten only when
  it has become wrong, not on every pass.
- **updates** — append-only per-session history of what the tracker observed.
- **topic** — a named append-only log. Created on first publish.
- **subscription** — `(session, topic)` plus a cursor. Reading marks read.
- **inbox** — a session's private queue, written only by `notify`. Stored as a
  topic named `@<session-id>`; the public verbs refuse `@` names, and reading
  one that isn't yours is refused.
