@sjawhar/claude-envoy 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-envoy",
3
- "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
3
+ "description": "Put Envoy events into the Claude Code session's own conversation and expose Envoy MCP tools.",
4
4
  "mcpServers": "./.mcp.json",
5
- "version": "1.17.0"
5
+ "version": "1.19.0"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-envoy",
3
- "version": "1.17.0",
4
- "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
3
+ "version": "1.19.0",
4
+ "description": "Put Envoy events into the Claude Code session's own conversation and expose Envoy MCP tools.",
5
5
  "mcpServers": {}
6
6
  }
package/AGENTS.md CHANGED
@@ -1,16 +1,19 @@
1
1
  # Claude Envoy Plugin Package
2
2
 
3
- Claude Code channel plugin package for Legion's Envoy subsystem.
3
+ Claude Code plugin package for Legion's Envoy subsystem.
4
4
 
5
5
  ## Overview
6
6
 
7
- One MCP stdio process is the channel ingress. It subscribes NATS directly to the session's
7
+ One MCP stdio process is the Envoy ingress.
8
+ It subscribes NATS directly to the session's
8
9
  `notifications.agent.<session_id>` subject and every topic the session follows — the lane receipts,
9
10
  the live team topics and the core fallback ride those subscriptions, not the session's messages,
10
11
  which come from the relay below. It renders each
11
- envelope through `@legion/envoy-client/delivery`, then writes a supported
12
- `notifications/claude/channel` notification. A core-NATS direct delivery gets its empty receipt
13
- when the server enqueues the notification; that means adapter-accepted, not processed by Claude.
12
+ envelope through `@legion/envoy-client/delivery`, then writes it into the session's own
13
+ conversation through the mod (`src/mod-session.ts`), or, in a Legion pane, through the bridge's
14
+ inbox.
15
+ A core-NATS direct delivery gets its empty receipt
16
+ when the server enqueues the write; that means the server took it, not that the model read it.
14
17
  There is no Monitor and no native Claude messaging socket path. The server's tools are the
15
18
  `envoyToolSpecs` alone, the ten `envoy_*` tools and `slack_post`: agents reach Dispatch through the
16
19
  `dispatch` command, which `bin/dispatch` puts on the Bash tool's `PATH`.
@@ -28,8 +31,10 @@ as it decodes, goes to the same write queue a live frame takes, and is answered
28
31
  notifier resolved its write and the ledger recorded it (`deliverRelayed`), at once for a frame
29
32
  skipped, already shown or a late BTW, which is passed over. In a Legion pane the notifier
30
33
  resolves when `legion claude-bridge` answers its inbox, which it does once Claude Code re-emits
31
- the delivery as the user message it took (`--replay-user-messages`); in channel mode it resolves
32
- when the MCP notification write does, the last signal Claude Code gives there. A write that fails
34
+ the delivery as the user message it took (`--replay-user-messages`); in a terminal session it
35
+ resolves once the mod has stored the row, and while the session is idle the card is a prompt
36
+ instead and the write resolves once the mod reports that the engine took it (`show`). A write
37
+ that fails
33
38
  answers `nak`, and from the frame's third delivery `done` with a notice naming it; a frame that
34
39
  arrives after a `/clear` moved the session to another id is answered `nak`. The core copies of
35
40
  those subjects answer only a lane's receipt (`relaysCoreTopic`), and the replay engine makes no
@@ -50,8 +55,8 @@ from Envoy's notification stream through its listener (`POST /v1/replay`) with t
50
55
  `@legion/envoy-client/replay`, which pi-envoy uses too (its rules are "Missed messages" in
51
56
  `packages/pi-envoy/AGENTS.md`). A sweep runs at channel start (a resume included), on each nats.js
52
57
  `reconnect` status, on every heartbeat and after a new follow, and shows each stored frame the
53
- session was not shown, once, through the same path a live frame takes (the channel notification,
54
- or the Legion bridge's inbox), with a last line saying when it was published while the
58
+ session was not shown, once, through the same path a live frame takes (the mod's write into the
59
+ conversation, or the Legion bridge's inbox), with a last line saying when it was published while the
55
60
  connection was down (`replayedText`, worded by the engine, as are the notices, which its
56
61
  `envoyNoticeText` renders in TOON as every live card is); a frame there runs nothing. On this
57
62
  path, role messages and team or other ad-hoc topics are not recovered. The live path records
@@ -89,18 +94,19 @@ ping interval left a frozen path for up to 6 minutes. Every registration names t
89
94
  directory the bundle runs from (`…/plugins/cache/<marketplace>/<plugin>/<version>/`, the npm
90
95
  version Claude Code installed, since the marketplace entry is an `npm` source;
91
96
  `installedPluginVersion` in
92
- `src/claude-channel.ts`), or `source` for a bundle outside the plugin cache (a checkout,
97
+ `src/installed-build.ts`), or `source` for a bundle outside the plugin cache (a checkout,
93
98
  `--plugin-dir`); a checkout's `package.json` `version` names no release and is never sent.
94
99
 
95
- A session advertises the delivery modes its deliveries' path can honour, never `btw`, and none at
96
- all when its deliveries are channel notifications Claude Code did not load (`src/claude-channel.ts`;
97
- a Legion pane's go to the bridge's inbox instead, so the gate does not apply to it). One whose
98
- deliveries are channel notifications advertises `aside` alone (`CHANNEL_CAPABILITIES`): Claude Code
99
- queues a notification for the model's next turn, so a `steer` cannot be honoured. A Legion pane
100
- whose deliveries go to `legion claude-bridge`'s inbox advertises `aside` and `steer`
101
- (`INBOX_CAPABILITIES` in `src/legion-inbox.ts`): the bridge starts a turn with a delivery when
102
- Claude Code is idle and sends it as the next user message of the running turn otherwise. `btw`
103
- remains intentionally unsupported until there is a server-owned pending-delivery/reply-ack design.
100
+ A session advertises what it can take now, read again at every registration
101
+ (`sessionCapabilities` in `src/envoy-channel-server.ts`). A terminal session reaches the model
102
+ only through its mod (`hooks/mod.ts`, `src/mod-session.ts`), so it advertises nothing until the
103
+ mod connects and `aside`, `btw` and `steer` once it has; it registers again as soon as the link
104
+ hears from the mod, by its hello or by its first poll, since a mod says hello once per Claude
105
+ Code process and a server Claude Code starts again inside that process hears only polls.
106
+ A Legion pane whose deliveries go to `legion claude-bridge`'s inbox advertises
107
+ `aside` and `steer` (`INBOX_CAPABILITIES` in `src/legion-inbox.ts`) whether or not the mod is
108
+ there — the bridge starts a turn with a delivery when Claude Code is idle and sends it as the
109
+ next user message of the running turn otherwise — and gains `btw` with the mod.
104
110
  Permission relay is also intentionally absent: without a sender-authenticated reply path and
105
111
  reply-ack design, declaring it would allow an Envoy input to decide Claude Code permissions.
106
112
  Dispatch asks stay on Dispatch.
@@ -118,18 +124,17 @@ Dispatch asks stay on Dispatch.
118
124
  | Hook command | `hooks/hooks.json`, `hooks/session-hook.ts`, `hooks/session-title-hook.ts` | Two bundles. `dist/session-hook.js` takes its mode as an argument — `open-asks`, `dispatch-first` or `answers`, plus `answers-refresh`, which only the `answers` mode starts and hooks.json never names; an argument naming none of them throws — and each mode is a hook of its own, because Claude Code caps each hook's output at 10,000 characters. `open-asks` (every `SessionStart`) names the `dispatch` command's host and hands on the session's envoy.json in `CLAUDE_ENV_FILE`, records the current session id per Claude process, records the session's chosen title and injects the open-asks summary; always exits 0. For a Legion pane (`LEGION_STATE_DIR` set) it writes the pane's jj attribution overlay under that directory too, and keeps exactly one `export JJ_CONFIG='<chain>'` line in `CLAUDE_ENV_FILE`: the line replaces an earlier one in its place rather than joining it, since a resume or a compaction of the same session id runs the hook again over the file it already wrote and the chain it resolves can differ; when no overlay could be written the file is left as it is, no line written and none removed, since `CLAUDE_ENV_FILE` is one file per session id (`<config>/session-env/<session id>/sessionstart-hook-<n>.sh`, read on the pinned 2.1.311) — a `/clear` gets a new and empty one, and a line already there names this same session's own overlay, still on disk and still correct. `dispatch-first` (every `SessionStart`, and every `SubagentStart`, since a subagent runs the same `dispatch` command) injects the `dispatch-first` skill as `hookSpecificOutput.additionalContext` under the input's `hook_event_name` when Dispatch is configured, reading `${CLAUDE_PLUGIN_ROOT}/skills/dispatch-first/SKILL.md` at run time and exiting non-zero naming it when it is missing. On a resume or fork Claude Code adds a SessionStart hook's context only when the transcript does not already hold the same text (measured on 2.1.283), so a resumed session keeps one copy and one opened without the skill gets it; the hook itself checks nothing. `answers` (every `SessionStart`, and every `SubagentStart`) injects what people wrote to the session in the last 72 hours as `hookSpecificOutput.additionalContext`: `readSessionAnswers` from `@legion/envoy-client/session-answers` for the input's `session_id` (at `SubagentStart` the parent's, which `tests/answers-claude.test.ts` checks on the real binary), the list pi-envoy puts on every model request, or its failure line naming why when a read failed and no list read before has an item to show, and nothing when there is nothing to show, Dispatch answered its own `404 NOT_FOUND` "no route for …" (`isNoAnswersRoute`, which `dispatch answers` cannot read past either; the hook then writes the read's `notice`, naming that Dispatch, to stderr, one line per process), or Dispatch is not configured; a proxy's 404 page or a 200 that is no answers list is a failed read. Its bound is counted from its process's start, Bun's start-up included: it prints by `ANSWERS_BY_MS` (800 ms, `SESSION_ANSWERS_DEADLINE_MS`), or `ANSWERS_LEAST_WAIT_MS` (150 ms) after its code begins when the start-up took longer, carrying the last list read from `answers.json` in the session's Dispatch state directory (`readSessionAnswers` works it out from `env`) when Dispatch has not answered, then waits on `sessionAnswersSettled` for `ANSWERS_SETTLE_MS` (50 ms) more so a read that answered late still writes that file for the next hook, and calls `process.exit(0)` whatever happened, a malformed input included (stderr names why), so nothing still in flight holds it. Dispatch is resolved with `commands: "kept"` first; a fresh process has kept no `!` command's outcome, so when that resolution is off with an error the mode starts the refresher: this bundle again (`process.execPath`, `Bun.main`) in mode `answers-refresh` with the session id and project directory, detached (its own session, so a signal to the hook's process group does not reach it), stdio ignored (so it holds none of the hook's pipes) and `unref()`ed, unless `answers-refresh.lock` in the session's state directory names a live refresher (its pid; a lock older than 30 s, or whose pid is gone, is stale). The refresher takes that lock (`wx`; a second one exits at once, so a burst of `SubagentStart` hooks runs the command once), resolves Dispatch with commands run, so `envoy-config`'s run of the command ends in its own process with its own clean-up (the group killed at the 10 s timeout, the stdout file's directory removed), reads with a 12 s deadline (which writes `answers.json`), writes `{finished_at, dispatch_url, text, notice}` to `answers-refresh.json` and removes the lock. The hook polls that file every 20 ms until its print deadline and carries its `text`, and reports its `notice` on stderr, when it finished after the hook started; otherwise it carries the last list read from the Dispatch URL envoy.json names now (`sessionDispatchUrl`, resolved before the wait without reading the token, passed as `readSessionAnswers`' `dispatchUrl`; a list another Dispatch answered never stands in) or the failure line naming that the command had not finished, and exits, so the next hook carries the list the refresher read. Its `timeout` is 5. Measured with Dispatch hanging: about 870 ms wall from spawn to exit; healthy, about 220 ms. `dist/session-title-hook.js` (every `UserPromptSubmit`, no argument) records the payload's `session_title` — the name a person gave the session, which Claude Code hands a hook and the plugin nowhere else — under `${CLAUDE_PLUGIN_DATA}/titles/`, and removes that file when the field is absent or blank, which is how a rename is undone. It writes nothing to stdout and exits 0 whatever happens, a payload that is not JSON included (`open-asks` and `dispatch-first` still fail loudly on one), because Claude Code adds a `UserPromptSubmit` hook's stdout to the model's context and reads its exit code 2 as a block of the prompt, and a cosmetic title may do neither; a failure goes to stderr. Claude Code blocks the user's prompt on it, so it is a bundle apart from the other two and keeps zod, the Dispatch client and `session-settings` out of its import graph (`src/session-state.ts` holds the file helpers for that reason, `src/session-identity.ts` imports zod and is not reachable from here): 5 kB and ~68 ms against the shared bundle's 811 kB and ~165 ms, the latter all but 3 ms of it bare `bun` start-up. It checks the payload itself — a non-empty string `session_id`, an optional string `session_title` — rather than carry a schema library. Its `timeout` is 5, not the 20 the `open-asks` and `dispatch-first` hooks carry. |
119
125
  | Real Claude Code test | `tests/answers-claude.test.ts`, `/.claude-code-pin`, `/.github/actions/install-claude-code` | The `answers` hook on the real binary: Claude Code runs with this package as `--plugin-dir`, an isolated HOME and `CLAUDE_CONFIG_DIR`, and one stand-in as its Messages endpoint (`ANTHROPIC_BASE_URL`, server-sent events as the API streams them) and Dispatch (`DISPATCH_URL`); each case reads the list off the Messages request after the moment: startup (`claude -p --session-id`), `--resume <id>`, `/compact` sent in stream-json as `legion claude-bridge` sends turns, and a subagent (the stand-in answers with an `Agent` tool call, the API name of Claude Code's Task tool). `LEGION_TEST_CLAUDE` names the binary; the test skips without it, except on GitHub Actions, where the claude-envoy job installs the release `.claude-code-pin` pins (`bun add` into the runner's temp directory). A pin bump that changes a request shape is fixed in that file. |
120
126
  | Inbound channel server | `src/envoy-channel-server.ts`, `src/envoy-off.ts` | MCP capability, notification queue, NATS registration, session-id handoff, role persistence, rejected-frame replies, and the Envoy tools, on the envoy.json Claude Code's rule picks (`@legion/envoy-client/session-settings`); why a section is off, and that inbound messages are off, goes to stderr once as `envoy-channel: <notice>`. `runEnvoyChannelServer` chooses between that server and the two reduced modes of `src/envoy-off.ts`. With Envoy off it runs `serveEnvoyOff` instead: an MCP server that lists no tool, refuses every call with `Envoy is off for this session: <why>` (the section's notice or error), opens no NATS connection and calls no listener, and exits 0 when stdin ends or on SIGTERM, SIGINT or SIGHUP. With Envoy on and inbound messages off (`inbound.off`: no NATS server, from `envoy.natsUrls` or `ENVOY_NATS_URL`, or its seed refused), it serves the tools on `inboundOffChannelSession`: no NATS connection, nothing registered with the listener, an empty inbox, `envoy_subscribe` refused naming why, and a role `envoy_role_set` claims still persisted. |
121
- | Channel gate | `src/claude-channel.ts` | Whether Claude Code loaded this server as a channel, which it never tells the server: the command line of the nearest ancestor whose program is `claude` (`claudeCommandLine`; Claude Code may run the server through a shell prefix or a `bun` shim), read from `/proc` (exact, with its stdout's terminal and its directory) or `ps` (split on whitespace), against Claude Code's own gate as of 2.1.301: a `plugin:claude-envoy@…` entry when `CLAUDE_PLUGIN_ROOT` says it runs as a plugin, any `server:` entry from a bare config; `--channels` entries before development ones; `--dangerously-load-development-channels` only in an interactive session (`asksForPrintMode`, the arguments skipped as Claude Code's scanner skips them, and a terminal on stdout where `/proc` shows it); the marketplace it is loaded from (`loadedMarketplace`: `inline` for `--plugin-dir`, else the cache's, compared after Claude Code's cache-name sanitizing); for `--channels`, `allowedChannelPlugins` (object or `plugin@marketplace` entries, each invalid one dropped alone) from the managed source that owns it (`remote-settings.json`, else `managed-settings.json` with its non-dot drop-ins; under `managedSourcesBehavior: "merge"` the highest that sets it). Not loaded, the session registers `capabilities: []`. `ENVOY_CHANNEL_LOADED=1`/`0` overrides it. A command line it cannot read, or no `claude` ancestor, counts as loaded, and so does one Node overwrote with `process.title = "claude"` (on Linux, the title alone followed by NUL padding, whatever the executable's name; elsewhere one word from a program named `node…`). |
127
+ | A person's message | `src/user-turn-router.ts`, `src/mod-session.ts`, `src/user-turn-state.ts`, `src/claude-rows.ts` | What a frame from Dispatch's Agents page becomes (INFRA-1026). The accept step is `acceptUserTurn` (`@legion/pi-shared/accept-user-turn`), the one step both Envoy plugins take — this router and `acceptedUserTurn` in `packages/pi-envoy/extensions/envoy.ts` — so a fix to the rule lands in both: a candidate frame (`isUserTurnCandidate`), an attempt this session has not handled, the attempt recorded **before** Dispatch is asked (a 404 included), the accept, and the stored body it answers — never the frame's own text. This host supplies the record, the Dispatch client, the session id and the wording of each refusal. The record lives in `turns/<url-encoded session id>.json` and outlives the process, so no restart spends an attempt twice; it also maps each delivered row to the Dispatch message it is, which tags that row on the live view. A row is matched to a message by the shared line rule (`comparableLines`, `@legion/contracts`), which the Agents page reads a stored body by too, so the two cannot drift and show a person's message twice. A terminal session takes the body through the mod (`$.session.append` during a turn, `$.prompt.submit({asUser:true})` while idle, one submit in flight at a time, and one plain submit at a row the turn never read); a pane's goes to the bridge's inbox, with its pictures as `blocks`. That plain submit carries each unread message's own words and its Dispatch id and never points at the row (`unreadRowPrompt`): driven live, a prompt saying "Read the message above" was answered `I don't see an actual Dispatch message content above` and `it looks like an injection and I'm not going to act on it`. A `/` message runs through `$.command.run` and its output is the reply — the result's `text`, else its `context` entries, which is where a terminal `/context` puts its table and the only place it puts it, else the `<local-command-stdout>` row Claude Code wrote for that run (`src/claude-rows.ts`, which the live view parses the same rows with), waited for two seconds since the row and the result reach this process on separate posts and cross — with a waiting reply after ten seconds, and a reply saying so when the command printed nothing, since `typedInputReply` has nothing to say for an empty output; `/model` and `/effort` are picks (`$.config.set`, `set-effort`), answered with `typedInputReply`'s text once the settings frame is refreshed; a BTW is `$.model.fork`. A row of the `command` door is a run's by when it arrives and by nothing else: a run claims the first echo row to arrive inside one second of its own answer (`runsCommandRow`), and the `<local-command-stdout>` row directly behind that claimed echo is that run's output, while an echo no run claimed — and the output row behind it — is a command the person typed at their own terminal and belongs to no run. Never by the command the row names: Claude Code runs `/cost` as `/usage` and `/model anthropic/haiku` as `/model haiku`. `origin` separates nothing, measured on live Claude Code 2.1.310 (INFRA-1026): a `/model haiku` this session ran answered at 08:14:06.839 and wrote its caveat (`{"kind":"plugin"}`, `isMeta`) at .844, its echo (`{"kind":"plugin"}`) at .858 and its stdout (`{"kind":"engine"}`) at .888, a `/config` a person typed at a terminal wrote its echo and its stdout with `{"kind":"engine"}` too, and a Dispatch `/cost` whose dialog waited for the person had written no row of its own when that `/config` arrived 36 seconds later. The live view draws nothing of a claimed echo row — the person's own line reaches the page as the message Dispatch stored beside the stream — and nothing of any row Claude Code marks `isMeta`, such as the `<system-reminder>` naming the session that a terminal `/rename` leaves behind its own two rows. |
122
128
  | Session identity | `src/session-identity.ts`, `src/session-state.ts`, `src/claude-session.ts` | `SessionIdentity` is the one mutable id holder; handoff file `sessions/<claude-pid>/session-id`; role file `roles/<url-encoded session id>.json` (`readPersistedRole`, `writePersistedRole`; the suffix is what keeps a session id of `.` or `..` inside `roles/`, since `encodeURIComponent` leaves a dot alone), whose record carries the writing process's `client_instance_id` beside the session id and the role, optional so a record written before the field loads unchanged. `CLAUDE_CODE_SESSION_ID`, or explicit `ENVOY_SESSION_ID` for QA. `src/session-state.ts` holds what the state files share and nothing that imports zod: `writeAtomicStateFile`, and `pruneStateDirectory(directory, isStale)`, the one ENOENT-tolerant sweep behind all three of them — handoffs by liveness (a `sessions/<pid>` whose Claude process is gone, anything else there left alone), titles and roles by age (`writtenLongAgo`, `STATE_FILE_MAX_AGE_MS`, thirty days). The role sweep exists because exactly one path removes a role file by hand — a soft `setRole` the listener refuses naming a session that is **not** the one the record is for, which says that session no longer holds the role. A refusal naming the record's own session keeps the file: the role is then held under that id by another process sharing this state directory, and the refused process goes on pressing its claim, refreshing the record's mtime on each heartbeat as a confirmed holder does, so the age sweep never reaches a record a process is still claiming for. The handoff sweep cannot reach either, keyed as it is by session id with no process left to ask about. All three run at startup in `startChannelSession`, each under its own `.catch` that logs and swallows: the heartbeat timer is not armed yet, so housekeeping must never cost a session its channel. |
123
129
  | Session title | `src/session-title.ts`, `src/session-state.ts` | The title every registration sends, which Dispatch's Agents page shows in place of the id: `ENVOY_SESSION_TITLE` on one line when it holds a character that shows; else the name the person running the session gave it (`claude -n`, `--name`, `/rename`), alone, with no short id after it, since a name a person chose needs nothing to tell it apart; else a name and the current id's first eight characters (a function of the id, so it follows a `/clear` handoff, and keeps two sessions in one tmux window apart). The chosen name is read, through `readChosenTitleSync`, from the `titles/<url-encoded session id>.txt` file the hooks write under `CLAUDE_PLUGIN_DATA` (keyed by id, like the role file, so `claude --resume <id>` finds it and a `/clear`'s new id starts without one; the suffix keeps a session id of `.` or `..` inside `titles/`, since `encodeURIComponent` leaves a dot alone), and every writer and reader goes through `sessionTitleFile` so the file the hook writes is the file the server reads; that read is synchronous and swallows every error, because a cosmetic title must leave the session on its fallback rather than fail the registration carrying it, and the name goes through the same `oneLine`/`visible` pair the other sources do, so a blank or invisible one falls through to the fallback while a multi-line one is put on one line and kept. The fallback's name is the pane's tmux name (`TMUX` and `TMUX_PANE`, one bounded `tmux -u -S <socket> display-message` at startup, used only when the answering server's pid is the one in `TMUX` and the pane's process is one of this process's ancestors (`isAncestor`); `session:window` when the window's `automatic-rename` is off, else the session, never a window index; for grouped sessions the group's name), else the project directory's name (outside tmux, a refused or missing answer, or a session named only by digits in a window nobody named). That name is worked out once as the server starts; the chosen name is asked for on every call instead, so a rename reaches the page on the session's next prompt plus a heartbeat, with no watcher anywhere. `pruneStaleSessionTitles` drops title files untouched for thirty days at startup, through the sweep the handoff and role prunes share and under the same best-effort guard, since nothing else deletes a file keyed by a session id. The listener replaces the stored title on every registration, and `register` also stamps it (`stampTitle`: `writeSessionTitle` in the session's Dispatch state directory) once per id, where the `dispatch` command reads it for the `session_title` on its writes; with inbound messages or Envoy off, nothing registers, so `runEnvoyChannelServer` stamps it once for the starting id (a `/clear` there leaves the new id unstamped); nothing is stamped when the session starts with Dispatch off or under `ENVOY_SESSION_ID`. |
124
130
  | NATS topic consumer | `src/channel-forwarder.ts` | Dedupe through the shared `createDeliveryDedupe`: a re-send by its `dedupe_key` (never by `event_id` alone, which the listener mints fresh for every send, so a Dispatch Retry would pass as new), and a second copy of one publish arriving on overlapping subscriptions by its `event_id`; surface the envelope's topic, and drain safely at shutdown. The promise the dedupe keeps is stated on `DELIVERY_DUPLICATE_WINDOW_MS` in `packages/contracts`. |
125
131
  | Consumer delivery | `src/envoy-channel-server.ts` (`openRelay`, `deliverRelayed`, `renewRelayEpoch`, `relaysCoreTopic`), `src/replay-state.ts` (the `consumer` part of `replay-<session id>.json`), `../envoy-client/src/consumer-delivery.ts`, `../../scripts/e2e/envoy-session-delivery.sh` | The listener's relay of the session's durable consumer ("Overview"): the relay subscription and epoch, each frame's `received`, `done` and `nak`, and the persisted resume point. The repository's `scripts/e2e/envoy-session-delivery.sh` is the live proof under `legion claude-bridge` (`scripts/e2e/README.md`). |
126
132
  | Outbound transport | `src/envoy-client.ts`, `bin/envoy-send.ts` | Envoy listener HTTP client for `envoy-send`, which sends to the listener and with the bearer `envoySettings` resolves over the envoy.json `envoyConfigPath("command")` chooses. |
127
133
  | Send CLI parsing | `src/send-arguments.ts` | Validates destination and message arguments. |
128
- | Manual channel smoke | `scripts/smoke-channel.sh` | Real smoke, unattended (answers every startup dialog, captures the pane); documented and deliberately excluded from CI. |
129
134
  | Manual `/clear` smoke | `scripts/smoke-clear-rebind.sh` | Real `/clear` against the live listener with the build in `CLAUDE_PLUGIN_DIR` (default: a `--out` build of this tree in the smoke's scratch directory); checks the registry entry moves to the new id without the old direct subject. Sends no prompt to the model; excluded from CI. |
130
135
  | Manual session-title smoke | `scripts/smoke-session-title.sh` | Real nats-server, a listener built from this tree, and the `dist/` of its own `--out` build of this tree for both the hook and the channel server: a chosen name registers, a rename replaces it, taking the name away restores the folder-and-short-id fallback, and the `dispatch` command's stamp carries the same name, each read back through the listener's `GET /v1/sessions`; the base revision's committed bundle is the negative control. Claude Code is not installed in an issue pod's image, though it runs there from `bunx --package @anthropic-ai/claude-code@<version> claude`, as `scripts/e2e/envoy-session-delivery.sh` runs it; this smoke writes the two hook payloads to their documented shape instead. Excluded from CI. |
131
136
  | NATS stall test | `tests/envoy-channel-stall.test.ts`, `../envoy-client/test/stall-proxy.ts`, `../envoy-client/test/envoy-stack.ts`, `../envoy-client/test/stall-scenario.ts` | Runs only with `LEGION_E2E_ENVOY_STACK=1` (CI's `envoy-session-stall` job), and needs `go` on `PATH` to build natsd and the listener from `packages/envoy`: with the gate set, a missing `go` or a failed build fails it rather than skipping it. The real channel server process registers against that listener (its row shows `plugin` `@sjawhar/claude-envoy` and `plugin_version` `source`), which answers `consumer`, and the proxy freezes its NATS path. Within 60 s the connection nats.js dials in place of the frozen one subscribes the relay subject again (natsd's `/connz`), the session registers a new epoch (its `envoy_sessions` entry), and the message sent during the freeze and one sent after it are each handed over once, neither as a replay: the freeze and what it checks are `runFreezeScenario`, which pi-envoy's stall row runs too. |
132
- | Legion phase worker on Claude Code | `src/legion-claim.ts`, `src/legion-tool.ts`, `src/legion-inbox.ts`, `src/legion-phase-hook.ts` | Active only when `legion claude-bridge` launched the pane (`LEGION_BOOT_TOKEN_FILE`, `LEGION_DAEMON_URL`, `CLAUDE_CONFIG_DIR` set). The claim waits for the session transcript, then boots through the claim boot `@sjawhar/pi-legion` shares (`bootLegionClaim`, `@legion/pi-shared/legion-claim-core`): registers with the daemon, claims the issue's Envoy role soft and calls ready, again on every `onRoleRegained`. The `legion` MCP tool is the phase ops `@sjawhar/pi-legion`'s tool serves too (`@legion/pi-shared/legion-phase-ops`: one schema, one executor, and the shared description template with its `read_record` and `request_backward_move` sentences, naming `mcp__legion__envoy_publish`). `src/legion-phase-hook.ts` is the endless phase reminder's Claude Code host (the section below). With `LEGION_CLAUDE_INBOX` set, deliveries go to the bridge's socket instead of channel notifications. `package.json` `legion.daemonApiVersion` is pinned by `tests/daemon-api-version.test.ts`. |
137
+ | Legion phase worker on Claude Code | `src/legion-claim.ts`, `src/legion-tool.ts`, `src/legion-inbox.ts`, `src/legion-phase-hook.ts` | Active only when `legion claude-bridge` launched the pane (`LEGION_BOOT_TOKEN_FILE`, `LEGION_DAEMON_URL`, `CLAUDE_CONFIG_DIR` set). The claim waits for the session transcript, then boots through the claim boot `@sjawhar/pi-legion` shares (`bootLegionClaim`, `@legion/pi-shared/legion-claim-core`): registers with the daemon, claims the issue's Envoy role soft and calls ready, again on every `onRoleRegained`. The `legion` MCP tool is the phase ops `@sjawhar/pi-legion`'s tool serves too (`@legion/pi-shared/legion-phase-ops`: one schema, one executor, and the shared description template with its `read_record` and `request_backward_move` sentences, naming `mcp__legion__envoy_publish`). `src/legion-phase-hook.ts` is the endless phase reminder's Claude Code host (the section below). With `LEGION_CLAUDE_INBOX` set, deliveries go to the bridge's socket instead of the mod's `$.session.append`. `package.json` `legion.daemonApiVersion` is pinned by `tests/daemon-api-version.test.ts`. |
133
138
  | Generated Legion plugin | `legion/`, `scripts/build.ts` | Never edit by hand, and never committed (convention below): `scripts/build.ts` stages pi-legion's skills, converts `packages/pi-legion/agents` (tool names mapped to Claude Code's, `glob`/`grep`/`todo` dropped) and writes the phase reminder's five hook entries (`LEGION_HOOKS` → `legion/hooks/hooks.json`, the section below). `scripts/verify-build.sh` builds the tree twice and fails when the two builds differ, so the release publishes what the source builds to; `tests/dist-fresh.test.ts`, `tests/legion-plugin-fresh.test.ts` and `tests/build-out.test.ts` build fresh and read no file of the package. `tests/legion-pane-mcp.test.ts` runs a Legion pane's server end to end against stand-ins. |
134
139
  | The phase reminder on the real Claude Code binary | `tests/legion-phase-hook-claude.test.ts` | The one check that Claude Code arms the generated `legion/hooks/hooks.json` entries at all: it runs the real binary under Legion's own agent command (`claudelaunch.Argv`, `--debug` added for the log it reads), with a fresh `scripts/build.ts --out` build of this tree — its generated `legion/` tree, and its `dist/envoy-channel.js` as the `legion` MCP server — against stand-ins for the Anthropic API, the daemon, the listener and NATS. `LEGION_TEST_CLAUDE` names the binary — in a worker pod, the one installed from `packages/daemon/docker/claude-code/package-lock.json`, at `node_modules/@anthropic-ai/claude-code-linux-x64/claude` (the `node_modules/.bin/claude` symlink is a `.exe` shim that does not exec on Linux): `cd packages/claude-envoy && LEGION_TEST_CLAUDE=<that path> bun test tests/legion-phase-hook-claude.test.ts`. A run without it skips the file, and nothing in CI installs the binary. |
135
140
 
@@ -232,10 +237,13 @@ above says how to run it).
232
237
 
233
238
  - `renderInbound` from `@legion/envoy-client/delivery` is the sole inbound renderer. Never add an
234
239
  adapter-specific renderer or emit raw envelope bytes.
235
- - Each channel meta key must contain only letters, digits, and underscores and every value must be
236
- a string. `producer`, `topic`, `event_id`, `dedupe_key`, `urgency`, `from_session`,
237
- `expects_reply`, and `in_reply_to` are the current contract. Never emit a `source` key: Claude
238
- Code stamps its own `source="plugin:claude-envoy:envoy"` attribute on the `<channel>` tag.
240
+ - A frame's identity fields are `event_id`, `dedupe_key`, `source`, `source_event_id` and
241
+ `topic` (`deliveryIdentity` in `src/channel-forwarder.ts`), each parsed on its own and kept
242
+ only when valid, so one field the rule refuses still leaves its sibling able to recognise a
243
+ repeat.
244
+ Nothing else of an envelope leaves the renderer: what reaches the session is the rendered
245
+ text, and, where a line carries more than text (a person's pictures), Claude Code's own
246
+ content blocks for it.
239
247
  - Subscribe NATS before registering the self-subscribed session route — on startup, on a session-id
240
248
  handoff (new direct subject first, then deregister the old id, drop the old subject, register,
241
249
  move the role by soft claim; a handoff that fails before the id switch drops the new subject
@@ -272,18 +280,134 @@ above says how to run it).
272
280
  - Pass a `ChannelInboundMessage` built field by field; a nats.js `Msg` exposes `subject`, `data`, and
273
281
  `reply` through prototype getters that an object spread silently drops. The forwarder decodes
274
282
  `data` once into `raw`; downstream code reads `raw`, never re-decodes.
275
- - Register the capabilities the delivery path honours, never `btw`: `CHANNEL_CAPABILITIES`
276
- (`["aside"]`) for channel notifications, which queue for the next turn, so a steer at the next
277
- tool boundary cannot be honoured, and none when Claude Code did not load the channel;
278
- `INBOX_CAPABILITIES` (`["aside", "steer"]`) when `LEGION_CLAUDE_INBOX` routes deliveries to the
279
- bridge, which needs no channel. Dispatch refuses a targeted delivery in a mode the session does
283
+ - Register what the session can take now, never a constant: `sessionCapabilities`
284
+ (`src/envoy-channel-server.ts`) reads whether this is a pane and whether the mod is connected,
285
+ and `ChannelSession.capabilitiesChanged()` registers again when that changes. Dispatch refuses
286
+ a targeted delivery in a mode the session does
280
287
  not advertise, and a message with no command goes in the mode the addressed sessions take
281
288
  (`plainDelivery`), as a broadcast does (`broadcastDelivery`).
282
- The direct NATS receipt occurs immediately after notification enqueue; it cannot imply model
283
- processing because Claude Code does not acknowledge channel notifications. A relayed frame's
284
- `done` waits for the write instead: the resolved notification write in channel mode, which is
285
- the last signal Claude Code gives there, and the bridge's inbox answer in a Legion pane, which
286
- comes once Claude Code re-emits the delivery (`--replay-user-messages`).
289
+ The direct NATS receipt occurs immediately after the write is enqueued; it cannot imply model
290
+ processing, because nothing in the mod protocol says the model read a row.
291
+ A relayed frame's `done` waits for the write instead: the mod's write into the conversation in
292
+ a terminal session, and the bridge's inbox answer in a Legion pane, which comes once Claude
293
+ Code re-emits the delivery (`--replay-user-messages`).
294
+ - Write every message into a terminal session through the one call `hooks/mod.ts` serves,
295
+ whether a person sent it or Envoy delivered it: `session.message` hands the mod the message,
296
+ and the mod decides what it becomes (`hooks/mod-writer.ts`, which holds these rules while
297
+ `hooks/mod.ts` holds the socket, the event queue and the hooks) — the prompt of a new turn
298
+ while nothing is running
299
+ (`$.prompt.submit`), a row of the turn running (`$.session.append`), and one prompt of its own
300
+ quoting such a row in full when the turn ends without a request having read it
301
+ (`unreadRowPrompt`, `hooks/mod-protocol.ts`). The mod decides because the mod is the one
302
+ process where the turn's events and this plugin's writes arrive in one order; three review
303
+ rounds found a race in deciding it in the server, each from two posts whose order was not the
304
+ order the session saw (INFRA-1026 review, rounds nine to eleven). An appended row starts
305
+ nothing, so a card appended to an idle session would wait for the person to type and one
306
+ appended after a turn's last request is never read at all. A person's message goes with
307
+ `asUser`, so Claude Code frames it as their own words; a card goes without it, so Claude Code
308
+ frames it as the plugin speaking, since nobody at the session typed it.
309
+ - Put a card in front of the model once. While the session is idle the card is the prompt
310
+ itself and nothing is stored for it — no `$.session.append`, and no second prompt quoting
311
+ one — since a row beside a prompt that quotes it is the same message twice (INFRA-1026,
312
+ handoff v6: rows 8 and 10 of session 225642d4 carried the one card, and two subtests of the
313
+ `envoy-session-stall` lane read it twice). A card that arrives during a turn keeps the row
314
+ and the unread-row prompt behind it.
315
+ - Answer a card's Envoy frame on the write the mod reports - a row appended, or a prompt the
316
+ engine took - and on nothing else: that answer is the proof the session has the message.
317
+ Nothing else is: a `$.prompt.submit` can wait for a
318
+ turn already running before the engine takes it, and a request handed to a poll is a write
319
+ into a socket buffer that a SIGSTOPped process never reads (INFRA-1026 review, round eleven,
320
+ repro `R-A`). `show` waits up to `SHOW_TIMEOUT_MS` and rejects without an answer, which leaves
321
+ the frame unanswered and has the listener send the card again. Driven live (INFRA-1026, k7's
322
+ second clause — Claude Code SIGSTOPped, an Envoy message sent to it, then SIGKILL and `claude
323
+ --resume <id>`), a branch that answered on the submit held that message zero times in the
324
+ resumed conversation. A person's message is the same, except that a prompt the session
325
+ *dropped* reaches them as a reply on Dispatch (`failed`): their own words were refused, and
326
+ Dispatch is where they are waiting.
327
+ - Make the mod's write idempotent by the Envoy event id, since the server is at-least-once.
328
+ Every request for a frame of Envoy's carries its `eventId` — a card and a person's own
329
+ message alike (`show` and `deliver`, `src/mod-session.ts`), since a person's message whose
330
+ answer is late is sent again and arrives as a card, the accept having spent its Dispatch
331
+ attempt, so the two writes are one write or the model reads their words twice. Before it
332
+ appends or submits, the mod looks that id up in `$.store` and answers a copy it already
333
+ wrote with the recorded write, writing nothing (`writeOnce`, `hooks/mod-writer.ts`). Every
334
+ write is recorded, a prompt included, which has no row to record: a prompt the engine took is
335
+ in the session, and answered as a drop instead it was recorded nowhere, so its resend
336
+ submitted the message a second time and a person's own message had Dispatch tell them their
337
+ session dropped a prompt it took (INFRA-1026 review).
338
+ The store has no compare-and-set — two `get`s of an unset key
339
+ both answered undefined, 0 ms and 2 s apart, in one session and across two
340
+ (`/legion/scratch/tmp/infra-1026-store`) — so the mod chains the look-up and the write per
341
+ event id, which holds inside the one process, and the store covers what crosses a restart:
342
+ `set` had written the file before it resolved, and a later session on the same configuration
343
+ directory read it back after a `kill -9` taken at that moment. Keep the record capped and
344
+ evicted (`CARD_RECORD_MAX`, `keys()` in insertion order): every call parses the whole file,
345
+ so at 3.9 MiB a `get` cost 7–20 ms and a `set` 44 ms against about 1 ms on an empty store, and
346
+ `set` refuses past 4 MiB outright. Two things can still show a card twice: this process
347
+ ending between the write and the record, the crash case
348
+ `docs/site/src/content/docs/dispatch/envoy.md` states, and a store that refuses the `set`,
349
+ which leaves the write standing with no record of it (`remember`, where a refusal costs
350
+ nothing a session could report to).
351
+ - Nothing of a subagent is the session's turn. Every hook whose event carries `agentId`
352
+ returns `next(e)` first when it is set and does nothing else (`ofSubagent`, `hooks/mod.ts`):
353
+ the three turn hooks and `session.append`, which is the registration that keeps a subagent's
354
+ rows out of the live view. Measured on 2.1.310
355
+ (`/legion/scratch/tmp/infra-1026-turn`), a subagent's `turn.start`, `turn.step`,
356
+ `turn.complete`, `tool.call`, `tool.check`, `prompt.attachment`, `session.receive` and
357
+ `session.append` on every door carry `agentId` while the main loop's carry no such key at
358
+ all, a foreground subagent's whole turn opens and closes inside the main turn between two of
359
+ its steps, and a background subagent's turn can still be open after the main turn completed.
360
+ Counted as the main turn's, that turn's `turn.complete` cleared the turn a message joins, its
361
+ steps raised the request count a row's unread check reads, and its rows and steps reached the
362
+ live view as the main conversation's, which the spec puts out of scope ("their work shows as
363
+ the parent's tool call"). The rule is the key and never a turn this mod watched start: when
364
+ the plugin itself spawns a subagent, that loop's `turn.start` and `turn.step` never reach the
365
+ spawning plugin and only its `turn.complete` does.
366
+ - Report from the mod every row the mod itself appends: Claude Code does not re-enter a mod's
367
+ own `session.append` hook for such a row (measured in the issue's spike —
368
+ `logs/mod3-q1q3q5.jsonl:36,64`, two calls whose uuids no hook ever carried), so a card
369
+ delivered during a turn reached no mapper and the live view drew nothing for it. `hooks/mod.ts`
370
+ sends an `append` event built from what `$.session.append` answered (`{message, uuid}`), by
371
+ the `note` door under a `plugin` origin naming this plugin (`MOD_PLUGIN_NAME`), and the
372
+ server ignores an `append` event whose row id it has already read
373
+ (`AGENT_STREAM_SEEN_ROWS_MAX`, `src/agent-stream-runner.ts`) in case a host fires the hook as
374
+ well.
375
+ - Claim every row of the conversation this session wrote by its uuid, never by what the row says
376
+ (`src/mod-session.ts`, `appendedRow`, `runsUnreadPrompt`, `runsCommandRow`). Draw a row
377
+ Claude Code marks `isMeta` only where this session appended it: the engine marks every row
378
+ `$.session.append` writes, so a card delivered during a turn — which is that row and nothing
379
+ else — is the one `isMeta` row the page must show, while a terminal `/rename`'s
380
+ `<system-reminder>` and the `<local-command-caveat>` row of a run stay model-facing and are
381
+ drawn nothing for. The claim rides on the stamp the mod puts on the row's own event
382
+ (`ModEvent`'s `wrote`: `message` for the row a message became, `pointer` for the prompt at
383
+ unread rows), never on the answer of the call that wrote the row — the two cross, since the
384
+ mod holds an event behind a post already in flight while a result goes out on a fetch of its
385
+ own, and a claim made from the answer landed after the view had read the row and dropped it
386
+ (INFRA-1026 k2, measured live on 2.1.310). The server hands the session every event before the
387
+ view records it (`src/envoy-channel-server.ts`), so the stamp is in place for the row being
388
+ drawn. The stamp is the whole signal: another installed mod's appends reach this mod's hook
389
+ under a `plugin` origin of their own, so a rule on the origin drew another mod's context as
390
+ the person's words. Of the two prompts this plugin submits without `asUser`, the card an idle
391
+ session took is drawn, because it is the card, and the prompt at unread rows is not, because
392
+ it quotes rows the view has already drawn — told apart by the stamp, since a card may quote
393
+ that prompt's own words.
394
+ - Keep the mod's socket where only its own user can create anything: `resolveModSocketPath`
395
+ (`hooks/mod-protocol.ts`) puts `claude-envoy/ce-<hash of the session id>.sock` under
396
+ `XDG_RUNTIME_DIR`, else Claude Code's own configuration directory (`CLAUDE_CONFIG_DIR`, else
397
+ `$HOME/.claude`), the way ssh's `ControlPath` and gpg-agent place theirs. `TMPDIR` and `/tmp`
398
+ are no bases: no co-user can get into a home or a run directory to bind the path first, which
399
+ is why the mod needs no owner check — and could make none, since `$.fs.stat` answers
400
+ `{kind, size, mtimeMs, isLink, realPath?}` and carries neither owner nor mode. `startModLink`
401
+ still refuses a directory that is not this user's own `0700` one rather than binding in it. A
402
+ session with no base of its own gets no link, the reason on stderr. The whole path has 100
403
+ bytes (Claude Code's bound on `HttpInit.socketPath`), which is why the name is the id's hash:
404
+ a macOS `/Users/<name>/.claude` makes 62 bytes and a Legion pane's `CLAUDE_CONFIG_DIR` 78,
405
+ while the id spelled out passed 100 on a macOS temp directory. A path past the bound is
406
+ refused with that reason, never served out of a shared base.
407
+ - One link serves one mod: the first instance to name itself in `MOD_INSTANCE_HEADER` holds it
408
+ and every other post is refused with `MOD_STATUS_TAKEN`, which stops that mod. Two Claude Code
409
+ processes can boot on one session id (`claude -c`, `claude --resume <id>` in a second
410
+ terminal), and the one that got there first owns the session's live view and its Send.
287
411
  - Preserve the held role at `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json` (keyed by session id so
288
412
  `claude --resume <id>` finds it), with this process's client instance id in the record beside
289
413
  the session id and the role, since two `claude` processes of one user read one state directory
@@ -337,12 +461,13 @@ above says how to run it).
337
461
  dropped — never shown to the model.
338
462
  - `envoy_inbox` is bounded to 50 metadata-only entries. Keep full payloads in neither the tool
339
463
  output nor plugin data.
340
- - Deployment is `--channels plugin:claude-envoy@legion-plugins` under managed settings
341
- (`channelsEnabled`, `allowedChannelPlugins`); `--dangerously-load-development-channels` is for
342
- entries outside the allowlist and cannot be combined with any `--channels` entry naming the same
343
- plugin: Claude Code matches the `--channels` entry first, which then needs the allowlist and its
344
- own marketplace. `claude -p` has no interactive plan approval, multiple-choice,
345
- or consent surfaces; workers need pre-approved configuration.
464
+ - Deployment needs nothing enabled on the machine: Claude Code 2.1.310 or newer loads the mod
465
+ from the plugin's `hooks.json`.
466
+ A session takes no message and shows no conversation when its Claude Code is older, when it
467
+ starts with `--bare` or `--safe-mode`, or when settings turn mods off (`disableAllHooks`, or
468
+ `allowManagedModsOnly` or `allowManagedHooksOnly` in managed settings).
469
+ `claude -p` has no interactive plan approval, multiple-choice, or consent surfaces; workers
470
+ need pre-approved configuration.
346
471
  - Nothing commits `dist/` or `legion/`, and `.gitignore` names both. `release.yaml`'s
347
472
  `claude_envoy_build` builds them after each merge that bumps this plugin's version, packs them
348
473
  into the tarball with the rest of the package, and the `publish` job uploads that tarball to