@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.
- package/.claude-plugin/plugin.json +2 -2
- package/.omp-plugin/plugin.json +2 -2
- package/AGENTS.md +167 -42
- package/README.md +95 -177
- package/dist/dispatch.js +59 -8
- package/dist/envoy-channel.js +2418 -877
- package/dist/session-hook.js +59 -8
- package/hooks/claude-mod.d.ts +500 -0
- package/hooks/hooks.json +2 -1
- package/hooks/mod-protocol.ts +728 -0
- package/hooks/mod-writer.ts +506 -0
- package/hooks/mod.ts +716 -0
- package/legion/skills/legion-architect/references/acceptance.md +7 -7
- package/legion/skills/legion-controller/references/daily-report.md +1 -1
- package/legion/skills/legion-worker/references/merge-gate.md +5 -4
- package/package.json +2 -2
- package/scripts/smoke-clear-rebind.sh +1 -1
- package/scripts/smoke-session-title.sh +1 -1
- package/src/agent-stream-claude.ts +558 -0
- package/src/agent-stream-runner.ts +340 -0
- package/src/bounded-set.ts +54 -0
- package/src/channel-forwarder.ts +1 -1
- package/src/claude-rows.ts +195 -0
- package/src/envoy-channel-server.ts +394 -136
- package/src/envoy-off.ts +6 -1
- package/src/installed-build.ts +26 -0
- package/src/legion-inbox.ts +22 -11
- package/src/mod-link.ts +562 -0
- package/src/mod-session.ts +499 -0
- package/src/replay-state.ts +2 -18
- package/src/state-file.ts +39 -0
- package/src/user-turn-router.ts +496 -0
- package/src/user-turn-state.ts +134 -0
- package/scripts/smoke-channel.sh +0 -178
- package/src/claude-channel.ts +0 -705
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-envoy",
|
|
3
|
-
"description": "
|
|
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.
|
|
5
|
+
"version": "1.19.0"
|
|
6
6
|
}
|
package/.omp-plugin/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-envoy",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
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
|
|
3
|
+
Claude Code plugin package for Legion's Envoy subsystem.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
One MCP stdio process is the
|
|
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
|
|
12
|
-
`
|
|
13
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
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/
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
|
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
|
|
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
|
-
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
276
|
-
(`
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
283
|
-
processing because
|
|
284
|
-
`done` waits for the write instead: the
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
or consent surfaces; workers
|
|
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
|