@sjawhar/claude-envoy 0.0.0 → 1.0.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.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
package/README.md ADDED
@@ -0,0 +1,639 @@
1
+ # claude-envoy
2
+
3
+ Claude Code channel plugin for Legion's Envoy subsystem. One MCP stdio server consumes Envoy NATS
4
+ events and sends them to the current Claude Code session as supported
5
+ `notifications/claude/channel` notifications.
6
+
7
+ ## Architecture
8
+
9
+ - `.mcp.json` launches the built bundle `dist/envoy-channel.js` (built from
10
+ `bin/envoy-channel.ts`; see "The bundle" below). It declares the experimental `claude/channel`
11
+ capability, so Claude Code accepts its event notifications; it also exposes the Envoy messaging
12
+ tools through that same MCP server.
13
+ - `bin/dispatch` is the `dispatch` command, which Claude Code puts on the Bash tool's `PATH` while
14
+ the plugin is enabled; it runs the built bundle `dist/dispatch.js`. Agents reach Dispatch
15
+ through it (see "Dispatch" below).
16
+ - `.omp-plugin/plugin.json` declares an empty `mcpServers`, so Oh My Pi starts no server from this
17
+ plugin: the channel server needs Claude Code's session identity and exits without it, and an omp
18
+ session already gets Envoy and Dispatch from `@sjawhar/pi-envoy`. omp reads that manifest
19
+ before `.claude-plugin/plugin.json` and a manifest `mcpServers` replaces `.mcp.json` instead of
20
+ merging with it; Claude Code reads only `.claude-plugin/plugin.json`, so it still launches the
21
+ server. Omitting the key would not work — omp would fall through to `.mcp.json`. Skills are
22
+ unaffected: both harnesses resolve them from `.claude-plugin/plugin.json`.
23
+ - `hooks/hooks.json` runs `dist/session-hook.js open-asks` on every `SessionStart` (startup,
24
+ resume, clear, compact, fork). Each of its hooks that resolves Dispatch with commands run has a
25
+ 20 s `timeout` (Claude Code's field is in seconds): a `!` `dispatch.token` may run for up to 10 s
26
+ in the hook's own process after Bun starts, so the hook outlasts it and says why rather than being
27
+ killed silently. It writes `export DISPATCH_HOST=claude` and
28
+ `export ENVOY_CONFIG_FILE='<path>'` (the session's envoy.json, below) to `CLAUDE_ENV_FILE` once
29
+ each, which Claude Code sources into every later Bash command of the session, records the current
30
+ session id for the channel server, and puts the session's open Dispatch asks into the model's
31
+ context (`Dispatch authored-ask summary:`; `unavailable: <reason>` when Dispatch cannot be
32
+ reached; nothing when Dispatch is not configured or a file sets `dispatch.enabled` to false,
33
+ unless the session's missing file is what turns it off while `~/.config/opencode/envoy.json`,
34
+ which is not read, is there: then `unavailable: <notice>`, the notice naming what to do with
35
+ it, after the lost-`DISPATCH_URL` text when `DISPATCH_TOKEN_FILE` is set). Before that, whether
36
+ Dispatch is on or off, a checkout's
37
+ `.opencode/envoy.json`, which is not read, is one `envoy: <file> is not read: mkdir -p … && mv …`
38
+ line while the checkout has no `.envoy/envoy.json`. The `dispatch` command reads the file
39
+ `ENVOY_CONFIG_FILE` names whenever `DISPATCH_HOST` is set. A subagent's Bash can lack what the
40
+ hook writes there (anthropics/claude-code#46696); its `dispatch` command, finding no
41
+ `DISPATCH_HOST`, then takes host `claude` from the `CLAUDE_CODE_CHILD_SESSION=1` Claude Code sets
42
+ in every Bash, and reads `CLAUDE_CONFIG_DIR` itself. With Dispatch configured,
43
+ `dist/session-hook.js dispatch-first` puts the `dispatch-first` skill into the context of every
44
+ session on the same `SessionStart` events and of each subagent (`SubagentStart`), as a hook of
45
+ its own so neither output crowds the other past Claude Code's 10,000-character hook limit. On a
46
+ resume or fork Claude Code adds it only when the transcript does not already hold the same text,
47
+ so a session keeps one copy, and one opened before the skill shipped gets it. On the same
48
+ `SessionStart` events and at each `SubagentStart`, `dist/session-hook.js answers`, a third hook of
49
+ its own, puts what people wrote to the session in the last 72 hours (answers to its asks, replies
50
+ to its comments, comments on documents it wrote, messages to it) into the
51
+ context, read from `GET /api/v1/agents/{session_id}/answers` for the hook input's `session_id`,
52
+ which at `SubagentStart` is the parent's: the same list, under 8,000 characters, that every
53
+ Oh My Pi model request carries (`@legion/envoy-client/session-answers`). It prints within 800 ms
54
+ of its start, Bun's start-up included, or 150 ms after its own code begins when the start-up alone
55
+ took longer, carrying the last list read (`answers.json` in the session's Dispatch state
56
+ directory) when Dispatch has not answered by then, or one line saying the list could not be read
57
+ and why when no list read before has an item to show, and exits 50 ms after that, always with
58
+ status 0 and with no output when there is nothing to show; its `timeout` is 5 s. A Dispatch build
59
+ older than the answers route answers its own `404 NOT_FOUND` "no route for …": the hook then adds
60
+ no line (a list read before from it is still carried, its left-out line naming no command) and
61
+ writes one line to stderr naming that Dispatch. A proxy's 404 page or a 200 that is no answers
62
+ list is a failed read like any other. A `!` command
63
+ in envoy.json runs in a process of its own that the hook starts detached, one per session at a
64
+ time, and waits on until that same deadline: a slower command costs that hook the fresh list,
65
+ never the bound, and still runs to its end (or its 10 s timeout, its output file removed either
66
+ way) in that process, which reads the list with its token for the next hook; until then only a
67
+ list read from the Dispatch URL envoy.json names now stands in, never one another Dispatch
68
+ answered. On every `UserPromptSubmit`, `dist/session-title-hook.js` records the name a person
69
+ gave the session (below, "Session title"); its `timeout` is 5 s, and it is a bundle of its own
70
+ because Claude Code holds the prompt until it returns.
71
+ - The channel server subscribes directly to `notifications.agent.<session_id>` and to every topic
72
+ followed by `envoy_subscribe`; those subscriptions carry the lane receipts, the live team topics
73
+ and the core fallback, never the session's own messages. It renders every envelope with the
74
+ shared `@legion/envoy-client/delivery` renderer and never exposes raw envelope bytes.
75
+ - The session's messages come from a durable consumer the listener keeps for it on
76
+ Envoy's notification stream and relays on `notifications.envoy.deliver.<session_id>`, which
77
+ the server subscribes before it registers: the session's own subject, the stored copies of role
78
+ messages to a role it holds, and every followed topic the stream stores, in stream order. The
79
+ server answers each frame `received` as it decodes and `done` once Claude Code has it: in a
80
+ Legion pane once the bridge reports that Claude Code took the delivery, and in channel mode once
81
+ the channel notification is written, the last signal Claude Code gives there, so a notification
82
+ Claude Code drops after that (a channel it did not load) is not sent again. A frame not taken
83
+ yet when Claude Code stops, or published while the session was not running or disconnected,
84
+ comes again and is delivered once, for up to 72 hours. Team and other ad-hoc topics, which the
85
+ stream does not store, stay live only. The resume point is kept in `replay-<session id>.json`
86
+ with the replay state below. `packages/envoy/AGENTS.md`, "Session consumers", states the rule.
87
+ - The core path is the rollout fallback dispatch://INFRA-771 removes (a listener that answers
88
+ `core`, or one of an earlier release). There a message
89
+ published while those subscriptions were down (NATS restarting, the connection dropped, the
90
+ session not running) is recovered from Envoy's notification stream, which keeps
91
+ messages for 72 hours: the server asks its listener (`POST /v1/replay`) for what the stream
92
+ stored on the session's own subject and on each topic it follows, at start (a resume included),
93
+ after a reconnect, on every heartbeat and after a new follow, and delivers each missed message
94
+ once, the same way as a live one, with a line saying when it was published while the connection
95
+ was down. It runs nothing: a Dispatch delivery recovered this way is that event and no more.
96
+ The server pings NATS every 10 seconds and drops a connection that leaves two pings unanswered,
97
+ so a connection that stops answering without closing is left within 30 seconds and redialed.
98
+ On this path, role messages and team or other ad-hoc topics are not recovered, and neither
99
+ is anything older than 72 hours, from before the session followed the topic, or from before it
100
+ first ran this version of the plugin; a `/clear` starts afresh, as a new session does. What the
101
+ session was shown and where it read to are kept in `replay-<session id>.json` in the plugin's
102
+ data directory, so a resume skips nothing the stream still holds and delivers nothing again (up
103
+ to 20,000 messages shown in 72 hours); a crash between handing a message over and recording it
104
+ can deliver it twice, never lose it. A message that fails three times is replaced by one notice
105
+ naming it. Against a listener without the route, the server says once on stderr that missed
106
+ messages are not recovered, and live delivery goes on as before.
107
+ - A forwarded role-lane envelope (one that still names its `notifications.role.<role>` topic while
108
+ arriving on the direct subject) receives its empty receipt immediately after the server accepts
109
+ the event into its ordered MCP notification queue. That confirms adapter acceptance, not that
110
+ Claude Code or the model processed the event: the channel protocol has no processing ack. A plain
111
+ direct send (`/v1/messages/send`) is a JetStream publish and gets no receipt: its reply subject is
112
+ the publisher's acknowledgement inbox, and an empty receipt there fails the publish.
113
+ - The server registers the session as self-subscribed with `capabilities: ["aside"]`, or
114
+ `["aside", "steer"]` in a Legion pane whose deliveries go to the bridge's inbox (below). It never
115
+ advertises `btw`, and a plain session does not advertise `steer` (see "Intentionally omitted").
116
+ A plain session whose channel Claude Code did not load registers no capability. Claude Code never
117
+ tells a server whether it loaded its channel: the MCP handshake is the same either way, and a
118
+ notification sent while it is not loaded is dropped without a word, so Dispatch would record an
119
+ Aside to that session as sent while the model never saw it. The server therefore reads the
120
+ command line of the `claude` process that runs it (`src/claude-channel.ts`; the nearest ancestor
121
+ whose program is `claude`, since Claude Code may run the server through a shell prefix or a
122
+ `bun` shim) and applies Claude Code's gate as of 2.1.301: an entry naming this plugin (from a
123
+ bare MCP config, any `server:` entry) in `--channels`, or in
124
+ `--dangerously-load-development-channels` in an interactive session (no `-p`, `--print`,
125
+ `--init-only` or `--sdk-url` option, and, where `/proc` shows it, a terminal on stdout), the
126
+ arguments read as Claude Code's own scanner reads them; the marketplace the plugin is loaded from
127
+ (`inline` for `--plugin-dir`, else the plugin cache's, compared as Claude Code names its cache
128
+ directory); and, for `--channels`, an `allowedChannelPlugins` entry (an object, or a
129
+ `plugin@marketplace` string; an invalid entry is dropped alone) from the managed settings
130
+ source that owns it: the cached `remote-settings.json`, else `managed-settings.json` with its
131
+ `managed-settings.d/` drop-ins (`.json` names not starting with a dot), and under
132
+ `managedSourcesBehavior: "merge"` the highest of them that sets the key. With no capability the
133
+ listener refuses every targeted delivery (`session … does not advertise aside`), Dispatch shows
134
+ the session as taking no messages, and the server says why on stderr and in its MCP
135
+ instructions. `ENVOY_CHANNEL_LOADED=1` or `0` overrides the check, for an allowlist an MDM
136
+ profile delivers. A command line it cannot read, or no `claude` among the server's ancestors,
137
+ counts as loaded (a Claude Code run under another program name inside another `claude`
138
+ session's shell reads as the outer one), and so does the command line of a Claude Code that runs
139
+ on Node, whose `process.title = "claude"` overwrites the arguments (on Linux, the title and NUL
140
+ padding, whatever the executable's name; elsewhere, one word from a program named `node…`). A
141
+ Legion pane whose deliveries go to the bridge's inbox needs no channel, so the check does not
142
+ apply there.
143
+
144
+ The check cannot see every reason Claude Code refuses a channel. Claude Code also turns channels
145
+ off for the account (not signed in, or an account without the channels feature), for a
146
+ third-party provider (Bedrock, Vertex or Foundry), and when the managed settings do not set
147
+ `channelsEnabled: true`; outside Linux it cannot tell whether stdout is a terminal;
148
+ Claude Code skips a channel on a connection that negotiated an MCP protocol revision with no
149
+ unsolicited notifications. Claude Code then shows
150
+ `--dangerously-load-development-channels ignored` (or `--channels ignored`) with the reason when
151
+ the session starts, and drops every notification. The server still advertises Aside, so Dispatch
152
+ records a message to that session as sent while the model never sees it. If the session shows
153
+ that warning, start it with `ENVOY_CHANNEL_LOADED=0` so it advertises no delivery mode.
154
+
155
+ Two more cases turn on what Claude Code has cached in the profile, which the check does not
156
+ read (both measured on Claude Code 2.1.309, 2026-10-23). Read from Claude Code's code: it checks
157
+ each channel notification as it arrives, against the usage policy it fetches from Anthropic
158
+ (`policy-limits.json` in the profile) and its feature flags (`cachedGrowthBookFeatures` in the
159
+ profile's `.claude.json`), and draws the channel banner once, at startup.
160
+ - **A new profile's first launch with the key in `ANTHROPIC_API_KEY`** shows
161
+ `--channels waiting for your organization's policy` and "Messages are dropped until it loads",
162
+ even with the channel allowlisted in managed settings, and the banner does not clear once the
163
+ policy loads. That policy is Anthropic's, not `managed-settings.json`. The profile had cached
164
+ it a second after the prompt appeared, and an Aside sent 45 s in, under that banner, reached
165
+ the model. With `policy-limits.json` in the profile, messages arrive whatever the banner says,
166
+ and the next launch shows the channel loaded.
167
+ - **A new profile whose key comes only from an `apiKeyHelper`** had no feature flags on its
168
+ first launch in all four profiles tried, and showed `--channels ignored` and "Channels are not
169
+ currently available". The server still advertises Aside, and an Aside Dispatch recorded as sent
170
+ never reached the model. One profile loaded the channel on its second launch; another on its
171
+ third, its second (with the key in `ANTHROPIC_API_KEY`, quit 13 s in) still ignored; a third
172
+ was ignored on its second launch too, and the fourth was not launched again. The launch that
173
+ loaded the channel fetched the flags at startup, and an Aside then reached the model. With the
174
+ key in `ANTHROPIC_API_KEY` instead, two new profiles fetched the flags during their first
175
+ launch.
176
+
177
+ The other way round, the check runs once at startup, so a channel an SDK host turns on later
178
+ (`enableChannel`) is not seen: that session advertises no mode and Dispatch refuses messages
179
+ Claude Code would pass on. Start such a session with `ENVOY_CHANNEL_LOADED=1`. And because the
180
+ install path holds a marketplace name as Claude Code writes it into its cache (every character
181
+ outside `[A-Za-z0-9_-]` made `-`), a flag naming `legion-plugins` for a plugin installed from
182
+ `legion.plugins` counts as a match, though Claude Code refuses it.
183
+ - A targeted Dispatch delivery the shared renderer rejects is never shown to the model: when it
184
+ names a message, the server posts `Invalid Dispatch targeted delivery frame` to
185
+ `POST /api/v1/messages/{id}/reply` so the attempt fails visibly; otherwise it is logged and dropped.
186
+ - A resumed server rebuilds the interests its session id already registered. `envoy_list` reports
187
+ the union of live NATS subscriptions and registry interests with each topic's `source` (`live`,
188
+ `registry`, `both`).
189
+ - `envoy_role_set` persists the held role in `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json`.
190
+ `claude --resume <session-id>` finds that file and soft-reclaims the role with the saved
191
+ `previous_session_id`; a plain relaunch is a new session and inherits nothing. The listener
192
+ moves the role off that predecessor only when the predecessor has no live registration, when
193
+ its live registration carries this process's own client instance id, or when either side
194
+ carries no instance id. A predecessor another process still runs keeps the role, and the claim
195
+ is refused naming it: this session goes on running and registered without the role, claims it
196
+ again only on an explicit claim, and keeps the predecessor's role file, since that record
197
+ belongs to the live holder. Every healthy registration heartbeat checks and reasserts the role
198
+ if the listener lost it. Shutdown (stdin end, SIGTERM, SIGINT, or the SIGHUP a closing pane
199
+ sends the whole process group) drains NATS and deregisters the session, naming this process's
200
+ client instance id, so the listener removes the registry entry only while it still carries
201
+ that id or none.
202
+ - `/clear` mints a new Claude session id, but a stdio MCP server keeps the `CLAUDE_CODE_SESSION_ID`
203
+ it was spawned with. The SessionStart hook writes the current id to
204
+ `${CLAUDE_PLUGIN_DATA}/sessions/<claude-pid>/session-id` (keyed by the `claude` process both the
205
+ hook and the server are children of, so concurrent sessions never share a file). The server
206
+ polls that file every 250 ms and follows a changed id within that time, with the heartbeat as the
207
+ fallback: it subscribes the new direct subject first, deregisters the old id under this
208
+ process's client instance id, drops the old subject, registers the new one, and moves a held
209
+ role by soft claim. The listener removes the old entry only while it still carries this
210
+ instance's id, so a process that resumed that id keeps both its registration and its role.
211
+ The listener merges registered topics into the entry and never removes one, so the old subject
212
+ goes before the registration, and the server sends its registry writes one at a time, each
213
+ registration reading the id and topics when it runs.
214
+ Asks authored before `/clear` carry the old Dispatch actor id and drop out of the open-asks
215
+ summary (the same shape as OMP on fork). `ENVOY_SESSION_ID` (QA override) disables the handoff.
216
+ - `envoy_inbox` is local recovery state: the most recent 50 event summaries, with no envelope
217
+ body, ordered newest first.
218
+ - `bin/envoy-send.ts` sends direct messages through the Envoy listener's HTTP API, to the listener
219
+ and with the bearer a command resolves: `envoySettings` over the envoy.json
220
+ `envoyConfigPath("command")` chooses (the `ENVOY_CONFIG_FILE` the `open-asks` hook hands the
221
+ session's Bash first), with `ENVOY_URL` and `ENVOY_TOKEN_FILE` or `ENVOY_TOKEN` over its `envoy`
222
+ section. With Envoy off it sends nothing and exits 1, saying why.
223
+
224
+ ## Channel metadata
225
+
226
+ Each notification has rendered Envoy content and only identifier-safe, string-valued metadata.
227
+ Claude Code drops invalid meta keys, so the server filters them before writing to stdio.
228
+
229
+ | Key | Meaning |
230
+ | --- | --- |
231
+ | `producer` | Envoy producer, or `unknown` for a malformed envelope (`source` is Claude Code's own attribute, naming the channel). |
232
+ | `topic` | NATS subject that delivered the event. |
233
+ | `event_id` | Envoy event identity of this publish; the listener mints a new one for every send. The channel uses it to deliver one copy of a publish that arrives on two overlapping subscriptions. |
234
+ | `dedupe_key` | Identity a re-send shares with the first delivery; the channel uses it to drop the re-send. |
235
+ | `urgency` | Optional: how soon the sender asks the reader to act. Informational only: it does not change when or in what order deliveries arrive. |
236
+ | `from_session` | Optional source session identifier. |
237
+ | `expects_reply` | Optional Envoy reply expectation. |
238
+ | `in_reply_to` | Optional correlated inbound event id. |
239
+
240
+ ## Envoy tools
241
+
242
+ The server exposes the shared Envoy messaging contract, every tool of `envoyToolSpecs`: the ten
243
+ `envoy_*` tools, `envoy_inbox` and `envoy_role_get` among them, and `slack_post`, a reply in a Slack
244
+ thread. It exposes no Dispatch tool: a `dispatch_*` call is an unknown
245
+ tool.
246
+
247
+ ## Dispatch
248
+
249
+ Agents reach Dispatch through the `dispatch` command in Bash: `dispatch --help` lists the
250
+ commands, `dispatch <command> --help` each one's flags, and the `dispatch` skill teaches them. The
251
+ command runs as the Claude Code session that called it: the `open-asks` hook names the host
252
+ (`DISPATCH_HOST=claude`), and the command reads the session id from `CLAUDE_CODE_SESSION_ID`,
253
+ which Claude Code sets fresh in every Bash command and moves to the new id on `/clear`, so a
254
+ `DISPATCH_SESSION_ID` inherited from a parent session is ignored. A write that makes the session
255
+ follow an ask prints `Following ask <id> on <issue>: …` once per ask, after its result; a result
256
+ longer than 25,000 characters is written to a file the output names. It reads the envoy.json
257
+ below on every call.
258
+
259
+ Dispatch asks remain on native Dispatch. Channel notifications are one-way ingress, not a remote
260
+ human approval surface.
261
+
262
+ ## Skills
263
+
264
+ The plugin ships eight skills, `claude-envoy:envoy`, `claude-envoy:dispatch`,
265
+ `claude-envoy:dispatch-first`, `claude-envoy:dispatch-writing-specs`, `claude-envoy:asd-ste100`,
266
+ `claude-envoy:outside-message`, `claude-envoy:plain-words` and `claude-envoy:agent-secrets`.
267
+ The first four teach the Envoy tools, the `dispatch` command and how to write a spec; `asd-ste100` gives the
268
+ ASD-STE100 rules for text a person reads; `outside-message` holds the steps and the approval before text
269
+ reaches a person outside the company; `plain-words` the empty verb phrases to delete from any text a
270
+ person reads, and `agent-secrets` the secrets broker's client, which gives a credential to one command
271
+ in that command's environment, so no value reaches your shell or your transcript.
272
+ `skills/` holds one relative symlink per skill into the repository-root `skills/`, which
273
+ stays their only source; the pack resolves each link into the skill's own files, since a published
274
+ package carries no link of this kind (`package.json`'s `prepack`, `scripts/pack-skills.sh`). With
275
+ Dispatch configured, the `dispatch-first` hook also puts `dispatch-first` into the model's context
276
+ on every `SessionStart` (startup, resume, clear, compact, fork) and `SubagentStart`, as the
277
+ `hooks/hooks.json` bullet above describes; it sends a spec, or an ask, comment or message a person
278
+ reads, to `dispatch-writing-specs`, which the session loads by name.
279
+
280
+ `envoy`, `dispatch`, `dispatch-first`, `dispatch-writing-specs`, `asd-ste100`, `outside-message`, `plain-words` and `agent-secrets` are the eight a standalone Claude Code session uses. The other root skills
281
+ stay out of this plugin: they belong to Legion's roles (the architect, controller, oracle and retro
282
+ skills, and the phase workers' `legion-worker` and `ce-simplify-code`) and to the reviewer's
283
+ `thermonuclear-*` pair (`skills/AGENTS.md`). A phase worker on Claude Code gets them from the
284
+ generated `legion/` plugin (below). Link another root skill here only when a standalone Claude
285
+ Code session needs it.
286
+
287
+ ## Legion phase workers
288
+
289
+ A Legion daemon can run a phase worker (planner, implementer, tester, reviewer or merger) on
290
+ Claude Code when `legion.yaml` sets `worker_backends: {<role>: claude}` (tmux runtime only; see
291
+ `docs/deployment.md`, "Phase workers on Claude Code"). The daemon launches
292
+ `legion claude-bridge -- claude -p …` with two plugin directories, this package and its generated
293
+ `legion/`, and a per-claim `CLAUDE_CONFIG_DIR`. In that pane the MCP server:
294
+
295
+ - registers the session with the daemon once its transcript exists, claims the issue's Envoy role
296
+ and reports ready (`src/legion-claim.ts`, over the claim boot `@sjawhar/pi-legion` shares);
297
+ - serves the `legion` tool: `read_record`, `handoff_complete` and `request_backward_move`, the
298
+ phase ops `@sjawhar/pi-legion`'s tool serves, on the same schema and executor
299
+ (`src/legion-tool.ts`);
300
+ - hands each Envoy delivery to the bridge's socket (`LEGION_CLAUDE_INBOX`), which sends it as a
301
+ user turn, since print mode delivers no channel notification (`src/legion-inbox.ts`): the bridge
302
+ starts a turn with it when Claude Code is idle, and sends it as the next user message of the
303
+ running turn otherwise. So the session advertises `steer` (Dispatch's Send) beside `aside`, and
304
+ the bridge handles both alike. The bridge runs Claude Code with `--replay-user-messages` and
305
+ answers a delivery only once Claude Code re-emits it as the user message it took, or with an
306
+ error once Claude Code ended a step without taking it or exited; a relayed frame is `done` on
307
+ that answer.
308
+
309
+ `legion/` is generated by `scripts/build.ts` from Legion's worker skills and task agents; the
310
+ release builds it with the bundle and packs it into the published package (below), and nobody
311
+ edits it by hand. A standalone Claude Code session doesn't load it.
312
+
313
+ ## The bundle
314
+
315
+ The marketplace installs this package from npm, as `@sjawhar/claude-envoy`, into Claude
316
+ Code's plugin cache with no `node_modules`: Claude Code never runs an install script, and it
317
+ installs dependencies only when a supported lockfile sits beside `package.json` in the tarball,
318
+ which this one does not ship (the monorepo's lockfile lives at the repository root). So the
319
+ package declares no runtime dependency at all — every one of them is a `devDependency`, as
320
+ `@sjawhar/pi-envoy`'s are — and the four executables ship as built single-file Bun
321
+ bundles,
322
+ `dist/envoy-channel.js`, `dist/session-hook.js`, `dist/session-title-hook.js` and
323
+ `dist/dispatch.js` (the `dispatch` command, built from `@legion/envoy-client`'s
324
+ `bin/dispatch.ts`), with every dependency inlined (`@legion/contracts`,
325
+ `@legion/envoy-client`, `@modelcontextprotocol/sdk`, `nats`, `zod` and theirs; the package version
326
+ is inlined from `package.json`, so the MCP server reports `package.json`'s version, which
327
+ `.omp-plugin/plugin.json` repeats). `dist/THIRD_PARTY_NOTICES` beside them carries the license of
328
+ every third-party package they inline, written from Bun's metafile by
329
+ `scripts/third-party-notices.ts` at the repository root.
330
+
331
+ - The published tarball carries the bundles and `legion/`; nothing commits them, and `.gitignore`
332
+ names both. `release.yaml`'s `claude_envoy_build` builds them after each merge whose commits
333
+ touch one of `$CLAUDE_ENVOY_VERSION_ARGS`' paths (the paths of `scripts/build-inputs.txt`; a
334
+ merge that touches none releases nothing and leaves the newest release standing), packs them
335
+ into the tarball with the rest of the package, and the `publish` job uploads that tarball to
336
+ npm with provenance, tags the release `claude-envoy-v<version>` and creates its GitHub release.
337
+ Nothing is pushed to `main`, so a release resets no merge queue. The hand-written plugin files
338
+ (`hooks/hooks.json`, `.mcp.json`, `bin/` and `.claude-plugin/`) are packed from the same tree
339
+ as the build beside them, so a change that makes one of them name a new or renamed bundle ships
340
+ with that bundle in one release.
341
+ `scripts/build.ts` takes its one mode and refuses a run that names none (exit 2); it never
342
+ writes the package's own `dist/` or `legion/`. `--out <dir>` makes a new
343
+ or empty directory outside the repository a runnable copy of the plugin built from the tree (its
344
+ manifests, `.mcp.json`, `bin/`, `hooks/`, `package.json` and skills, the skill links
345
+ dereferenced, with a fresh `dist/` and `legion/`), which is how to run an unmerged tree.
346
+ - `bun run verify-build <tar>` (`scripts/verify-build.sh`) is that job's build step: it builds
347
+ the plugin twice into a scratch directory outside the repository and says so when the two builds
348
+ differ, runs the private-name check over the first build's `dist/` and `legion/`,
349
+ packs the two directories into `<tar>`, which must be outside the repository, and runs
350
+ `release-push.sh check-built` over it, the member checks a build must pass before the job
351
+ extracts it into the checkout it packs. It writes no file of the checkout, so running it on a
352
+ pull request's tree changes
353
+ nothing jj records; nothing a person runs locally writes `dist/` or `legion/`. The jobs that
354
+ run it are named in its header, and the jobs that extract the checked tar into their own CI
355
+ checkout in `release-push.sh`'s `extract-built` paragraph. Pull-request CI runs it, so a skill
356
+ file that is executable, a symlink or named with a space, or a bundle `BUNDLE_ENTRYPOINTS` no
357
+ longer names, fails there instead of failing every release on main.
358
+ - The build is `Bun.build`, target `bun`, `minify: false`, no sourcemap, notices included. Minification is off entirely, not case by case: the partial-disable form (`{whitespace: false, identifiers: false, syntax: false}`) still routes through Bun 1.3.14's minifying code-generation path, which picks a non-deterministic CJS/ESM interop check on repeated builds of this exact module graph — on top of the syntax minifier's own separate bug, truncating constant-folded multi-operand string concatenation in CI builds, that made the partial form necessary before this. A plain `false` bypasses that whole path; whitespace and identifiers stay readable as a side effect, so independent changes still merge at line level.
359
+ - The build runs only on the Bun version pinned in the repo-root `.bun-version`, because
360
+ the bundler's own output depends on the exact Bun build, not only the declared version. Two
361
+ separate, compounding causes made two builds of one tree disagree: the minify setting above (a
362
+ non-determinism *within* one Bun build's own bundler) and a second, independent bug
363
+ (LEGION-568) — `bun run verify-build` spawns a new "bun" process to run
364
+ `scripts/build.ts`, and that spawn resolves "bun" from PATH rather than reusing whichever binary
365
+ the caller invoked `bun run` with, so a devbox whose default Bun (a version manager's active
366
+ version) differs from the pin would otherwise silently bundle with that *other Bun version*
367
+ instead — confirmed directly between a devbox and the CI runner, same source, same lockfile,
368
+ same `.bun-version`. Either alone could produce this symptom; the first investigation (LEGION-548
369
+ round 5) found the first and mistakenly attributed a failure actually caused by the second to
370
+ the build machine itself. The build now refuses outright when the running Bun does not match
371
+ the pin, rather than publish whatever that other build produced: invoke the pinned binary's own
372
+ path directly — not `bun run` — when a version manager's default differs from it.
373
+ - pi-envoy solves the same problem with `scripts/pi-plugin-prepack.sh` (at the repository root, the
374
+ prepack of both Oh My Pi plugins). This plugin is a third released package of the same build and
375
+ publish split, beside `@sjawhar/pi-envoy` and `@sjawhar/pi-legion`, versioned and tagged from
376
+ the tags as they are. A commit on `main` carried the build before, and every push to `main`
377
+ resets the merge queue; publishing to npm takes that commit away without putting the build on a
378
+ branch of its own, which was the design before this one and was rejected.
379
+
380
+ ## Updates
381
+
382
+ The marketplace entry declares no `version` of its own, and the committed
383
+ `.claude-plugin/plugin.json` declares none either; the entry's `npm` source declares
384
+ `version: ">=1.0.0"`, a range that keeps an install off the 0.0.0 placeholder a person published
385
+ so that trusted publishing could attach to the package, and otherwise takes `latest`, which the
386
+ resolver prefers while it satisfies the range.
387
+ Claude Code names an installed plugin by the `version` of the `.claude-plugin/plugin.json` it
388
+ installed and never by the version the registry served, so `release.yaml`'s `claude_envoy_build`
389
+ writes the release's version into the `.claude-plugin/plugin.json` it packs, beside the one it
390
+ writes into `package.json`, and its
391
+ tarball check refuses a tarball without it: a package published with none is cached under the
392
+ name `unknown` and no later release reads as an update (measured on Claude Code 2.1.311). The
393
+ version a session reports is therefore the version that was published, which is what makes this
394
+ plainer than the derivation before it, where it was the first 12 characters of the commit the
395
+ plugin was installed from.
396
+ `claude plugin marketplace update legion-plugins` then
397
+ `claude plugin update claude-envoy@legion-plugins` installs the newest release, which the
398
+ Claude Code session loads. Claude Code's background auto-update does the same once it is on for
399
+ `legion-plugins`, which it is not by default: **Enable auto-update** on the marketplace under
400
+ `/plugin` **Marketplaces**, or set `"autoUpdate": true` beside `source` on the marketplace's
401
+ `extraKnownMarketplaces` entry, in whichever settings file declares it. A `version` committed in
402
+ either file would hold every install at that string until someone changed it, so a test holds
403
+ both committed files to none; the range is a field of the entry's source, not a version of the
404
+ entry.
405
+ A checkout's `package.json` version names no release either, for the same reason: the build job
406
+ writes the release's version into the manifest it packs.
407
+ Each release publishes its own changelog entries into its GitHub release body, rendered from the
408
+ fragments its own commits added, and each fragment stays under `.changes/unreleased/`.
409
+ `CHANGELOG.md` is the history before that and stops where the fragments begin.
410
+
411
+ ## Enable the channel
412
+
413
+ Channels are gated by Claude Code's managed settings: whoever administers the machine writes
414
+ `/etc/claude-code/managed-settings.json` (on macOS,
415
+ `/Library/Application Support/ClaudeCode/managed-settings.json`), which turns channels on and
416
+ allowlists the plugin, and the user opts the plugin into each session with `--channels`. An
417
+ account that receives server-managed settings (cached as `remote-settings.json` in the Claude Code
418
+ config directory) takes these keys from them instead, and under the default
419
+ `managedSourcesBehavior` the local file then changes nothing:
420
+
421
+ ```json
422
+ {
423
+ "channelsEnabled": true,
424
+ "allowedChannelPlugins": [
425
+ { "marketplace": "legion-plugins", "plugin": "claude-envoy" }
426
+ ]
427
+ }
428
+ ```
429
+
430
+ ```bash
431
+ claude --channels plugin:claude-envoy@legion-plugins
432
+ ```
433
+
434
+ Restart Claude Code once after its first launch on a new profile (a fresh `~/.claude` or a new
435
+ `CLAUDE_CONFIG_DIR`), with the same flag, and read the banner it shows at startup.
436
+ "Channels (experimental) messages from plugin:claude-envoy@legion-plugins inject directly in this
437
+ session" means the channel is loaded. `--channels ignored` and "Channels are not currently
438
+ available" mean it is not: the session drops every channel message while it still advertises
439
+ Aside, so restart again. A first launch that says `--channels waiting for your organization's
440
+ policy` keeps saying so after the policy has loaded, and its messages arrive (the channel check's
441
+ limits, above).
442
+
443
+ For a plugin outside the allowlist (a development marketplace, a bare `server:` entry) use
444
+ `--dangerously-load-development-channels <entry>`; it bypasses the allowlist only, so
445
+ `channelsEnabled` still applies. Do not combine it with any `--channels` entry that names the
446
+ same plugin: Claude Code matches the `--channels` entry first, so that entry then needs the
447
+ allowlist and the installed plugin's marketplace, and otherwise Claude Code skips the channel
448
+ (`Channel notifications skipped: you asked for plugin:…@legion-plugins but the installed
449
+ claude-envoy plugin is from …`).
450
+
451
+ The plugin reads one envoy.json: `$CLAUDE_CONFIG_DIR/envoy.json` when `CLAUDE_CONFIG_DIR` is set,
452
+ else the file `ENVOY_CONFIG_FILE` names, else `${XDG_CONFIG_HOME:-~/.config}/legion/envoy.json`,
453
+ with the `dispatch` section of a `<cwd>/.envoy/envoy.json` merged over it: its server only with the
454
+ token beside it, and its token only beside its own server. An `envoy` section in that checkout's
455
+ file is not read, which the channel server's notice says, and a checkout's file that is not valid
456
+ refuses Dispatch alone, only while `DISPATCH_URL` is unset. The chosen file's `dispatch` section
457
+ (`enabled`, `serverUrl`, `token`) and `envoy` section (`enabled`, `url`, `natsUrls`, `token`,
458
+ `natsSeed`) are resolved each on its own; `enabled: false` turns one off, a missing file
459
+ contributes nothing (each section is then on only when its variables name it), and a `token` or
460
+ `natsSeed` starting with `!` is a command whose trimmed output is the value. `DISPATCH_URL`,
461
+ `DISPATCH_TOKEN_FILE`, `DISPATCH_TOKEN`, `ENVOY_URL`, `ENVOY_NATS_URL`, `ENVOY_TOKEN_FILE`,
462
+ `ENVOY_TOKEN`, `NATS_NKEY_SEED_FILE` and `NATS_NKEY_SEED` each override their own field; a file's
463
+ token or seed goes with a variable's server only when it is the file's own. The `open-asks` hook
464
+ hands the file on to the session's Bash as `ENVOY_CONFIG_FILE` beside `DISPATCH_HOST=claude`, and
465
+ the `dispatch` command reads the file `ENVOY_CONFIG_FILE` names whenever `DISPATCH_HOST` is set; a
466
+ Bash with neither, as a subagent's can be, has `CLAUDE_CODE_CHILD_SESSION=1` and takes this plugin's
467
+ rule. With Envoy off the channel server lists no tool, refuses every call with
468
+ `Envoy is off for this session: <why>` (the section's notice or error), subscribes nothing, and
469
+ exits 0 when stdin ends. With Envoy on and no NATS server (`envoy.natsUrls` or `ENVOY_NATS_URL`),
470
+ or with its NATS seed refused, it serves the Envoy tools, which call the listener, and takes no
471
+ inbound messages: it registers nothing with the listener, its inbox stays empty, and
472
+ `envoy_subscribe` is refused with `inbound messages are off for this session: <why>`. Why a section
473
+ is off, and that inbound messages are off, is written once to its stderr
474
+ (`envoy-channel: no envoy.json at <path>: Dispatch and Envoy are off`, the section's error, what a
475
+ checkout's file holds that is not read, or `Envoy's inbound messages are off: <why>; the Envoy tools
476
+ still work`, `<why>` being `Envoy has no NATS server (envoy.natsUrls, ENVOY_NATS_URL)` or the seed's
477
+ refusal). The variables are inherited from the environment that
478
+ launched `claude`
479
+ — `.mcp.json` passes through only `CLAUDE_PROJECT_DIR`, because an unset `${VAR}` in `.mcp.json`
480
+ is substituted as the literal text, which used to register a session as the literal id
481
+ `${ENVOY_SESSION_ID}`. Claude Code provides `CLAUDE_CODE_SESSION_ID` and `CLAUDE_PLUGIN_DATA`; set
482
+ `ENVOY_SESSION_ID` only to use a controlled identity for QA (the smoke does).
483
+
484
+ The session registers under a title, which Dispatch's Agents page shows in place of its session id:
485
+ `ENVOY_SESSION_TITLE` when it is set and holds a character that shows
486
+ (`ENVOY_SESSION_TITLE='review the listener' claude`), on one line. Otherwise it is the name the
487
+ person running the session gave it — `claude -n 'review the listener'`, `--name`, or `/rename` —
488
+ alone, with no short id after it, since a name somebody chose already tells its session apart.
489
+ Otherwise the title is a name and the first eight characters of the session id
490
+ (`work:review 3084299d`, `legion 3084299d`), so two sessions in one tmux window, or in one project
491
+ outside tmux, keep apart, and the id part follows the new id after `/clear`.
492
+
493
+ Claude Code hands the chosen name to a hook and to nothing else, as `session_title` on the
494
+ `SessionStart` and `UserPromptSubmit` payloads, so the plugin's hooks record it under
495
+ `${CLAUDE_PLUGIN_DATA}/titles/`, by session id, and the channel server reads that file every time
496
+ it registers. The `UserPromptSubmit` hook is a bundle of its own, `dist/session-title-hook.js`,
497
+ because Claude Code holds the prompt until it returns: it parses its payload and writes one file,
498
+ and costs about what starting `bun` at all costs. A rename therefore reaches the Agents page on
499
+ the session's next prompt and the registration heartbeat that follows it, within about two
500
+ minutes; nothing watches the file. Taking the name away removes the record, so the session
501
+ returns to the name-and-short-id title instead of wearing a name nobody chose any more, and a
502
+ name that is blank or invisible once cleaned falls back the same way; a name written on several
503
+ lines is put on one, not refused. Records untouched for thirty days are pruned as the channel
504
+ server starts — the titles, and the role files beside them — so a session resumed weeks later
505
+ still finds its name and its role, and neither directory grows for ever; a sweep that fails says
506
+ so on stderr and the session starts regardless.
507
+
508
+ Inside tmux that fallback name is `session:window` for a window whose `automatic-rename` is off
509
+ (one named with `rename-window` or `new-window -n`, one a program in it renamed through
510
+ `allow-rename`, or any window where the tmux configuration turns it off), else the tmux session's
511
+ name; in grouped sessions, the group's name, which tmux keeps even after the sessions are renamed
512
+ or end. A window index is never used, since tmux moves it under `renumber-windows`. The server
513
+ asks the tmux server `TMUX` names about the pane `TMUX_PANE` names, and uses the answer only when
514
+ that server's pid is the one in `TMUX` (a server started again on the socket gives out the same
515
+ pane ids) and the pane's process is one of the session's own ancestors (tmux hands a popup or
516
+ `run-shell` job a `TMUX_PANE` left over from elsewhere). Otherwise, and for a session named only
517
+ by digits (plain `tmux` names its first one `0`) in a window nobody named, the name is the project
518
+ directory's. The server works that name out once as it starts, with at most one
519
+ `tmux display-message` call bounded at one second (on a host without `/proc`, reading the pane's
520
+ process chain adds one `ps` call per step, each bounded the same way), so a later tmux rename
521
+ shows from the next start. The chosen name is read afresh on every registration instead, which is
522
+ why a rename needs no restart. The server also writes the title to the session's Dispatch state
523
+ directory, where the `dispatch` command reads it to stamp the session's Dispatch writes, so a
524
+ comment or message from the session carries the same name. With inbound messages or Envoy off it
525
+ writes it once, for the id the session starts with. It writes nothing when the session starts with
526
+ Dispatch off, or under the QA override `ENVOY_SESSION_ID`.
527
+
528
+ `claude -p` can run a channel session, but it disables features that need terminal input, including
529
+ multiple-choice questions and plan approval. Production Legion workers must have MCP and channel
530
+ consent preconfigured and must not depend on local dialogs.
531
+
532
+ ## Intentionally omitted
533
+
534
+ - **Targeted BTW:** no session advertises `btw`. A channel notification has no model or
535
+ Claude Code acknowledgement, so advertising BTW would promise an automatic correlated Dispatch
536
+ reply that does not exist. Add it only with a server-owned pending-delivery state and explicit
537
+ reply tool design.
538
+ - **Steer outside a Legion pane:** Claude Code queues channel notifications for the model's next
539
+ turn, so a steer cannot be honoured any differently from an aside; a session whose deliveries
540
+ are channel notifications does not advertise it.
541
+ - **Permission relay:** the server does not declare `claude/channel/permission`. It has no
542
+ sender-authenticated reply path or reply-ack design for remote permission decisions; declaring
543
+ one would let unauthenticated Envoy payloads approve tool use. Dispatch asks stay on Dispatch.
544
+
545
+ ## Manual smoke
546
+
547
+ `smoke-channel.sh` is a real manual smoke, never a CI step. It starts an isolated, interactive
548
+ Claude Code session, answers its startup dialogs unattended (folder trust, the development-channel
549
+ warning, first-use MCP consent — in whatever order they appear), waits for the Envoy registration,
550
+ sends an actual direct Envoy event, and asserts the model writes the unique payload before checking
551
+ teardown. It requires a live Envoy listener, NATS, Claude authentication, a configured model,
552
+ managed settings that enable channels and allowlist the plugin (see "Enable the channel"), and an
553
+ installed plugin entry:
554
+
555
+ ```bash
556
+ ENVOY_NATS_URL=nats://envoy-nats:4222 \
557
+ CLAUDE_CHANNEL_ENTRY=plugin:claude-envoy@legion-plugins \
558
+ bash -c 'cd packages/claude-envoy/scripts && ./smoke-channel.sh'
559
+ ```
560
+
561
+ `CLAUDE_CHANNEL_FLAG` defaults to `--channels` when the entry ends in `@legion-plugins` (the
562
+ allowlisted marketplace) and to `--dangerously-load-development-channels` for every other entry;
563
+ set it explicitly to override. The `PASS` line prints the registered session row (id, dir,
564
+ capabilities); a failure dumps the pane and the full `tmux pipe-pane` capture of everything Claude
565
+ printed. The session runs on the smoke's own tmux server, on a socket in its temp directory, so its
566
+ teardown can end only that session; the script prints the `tmux -S <socket> attach -t <session>` line
567
+ that watches it. Use `CLAUDE_BIN` or `ENVOY_URL` to select a different Claude binary or listener. For
568
+ source-tree diagnostics, pass a temporary bare-server config through `CLAUDE_MCP_CONFIG` and use
569
+ `CLAUDE_CHANNEL_ENTRY=server:envoy`; the marketplace command above is the plugin packaging check.
570
+
571
+ To exercise an unmerged checkout the way the marketplace would install it, register a development
572
+ marketplace with a `command` source in copy mode whose command builds a copy of the plugin with
573
+ `bun packages/claude-envoy/scripts/build.ts --out <dir>` (a new or empty directory outside the
574
+ repository, without `node_modules`) and prints it, install `claude-envoy@<dev-marketplace>`, and
575
+ run the smoke against that entry. Never `claude plugin marketplace add cybermonklabs/legion#<branch>`: the
576
+ marketplace name comes from the repository's `marketplace.json` and would replace the real
577
+ `legion-plugins` registration.
578
+
579
+ `smoke-clear-rebind.sh` checks the `/clear` handoff against the live listener. It starts Claude
580
+ Code with user settings left out (`--setting-sources project,local`) and the build under test from
581
+ `--plugin-dir` (`CLAUDE_PLUGIN_DIR`, default: a copy of the plugin it builds from this tree with
582
+ `scripts/build.ts --out` in its temp directory), sends `/clear`, and passes when the registry entry
583
+ has moved to the new session id, lists the new direct subject, and still lacks the old one two
584
+ heartbeats later. It also fails when the entry moves later than the handoff poll allows; with
585
+ `ENVOY_HEARTBEAT_MS=60000` that checks the poll rather than a heartbeat that happened to fall soon
586
+ after `/clear`. It needs `tmux`, `curl`, `jq`, `bun` (for the default build), an authenticated
587
+ `claude`, and the listener and NATS; it sends no prompt to the model and removes both ids' registry
588
+ entries when it exits. Like `smoke-channel.sh`, it runs its session on its own tmux server, on a
589
+ socket in its temp directory, and prints the `tmux -S <socket> attach -t <session>` line that
590
+ watches it. Pointed at an older build (an installed plugin cache directory), it reproduces the
591
+ stale subject that build leaves behind:
592
+
593
+ ```bash
594
+ ENVOY_NATS_URL=nats://127.0.0.1:4222 packages/claude-envoy/scripts/smoke-clear-rebind.sh
595
+ ```
596
+
597
+ `smoke-session-title.sh` checks that the name a person gave a session is what the plugin
598
+ registers. It starts a nats-server and an Envoy listener built from this tree, builds a copy of
599
+ the plugin from this tree in its temp directory (`scripts/build.ts --out`), runs that copy's
600
+ `dist/session-hook.js` and `dist/session-title-hook.js` on the payloads Claude Code's hooks
601
+ reference documents and its `dist/envoy-channel.js` as the session, and reads each title back
602
+ through the listener's `GET /v1/sessions`: the chosen name registers alone, a rename replaces it,
603
+ taking the name away brings the folder-and-short-id title back, and the `dispatch` command's stamp
604
+ in the session's Dispatch state directory carries the same name. Its negative control runs the
605
+ first case again against the committed `dist/envoy-channel.js` of `BASE_REV` (default
606
+ `0306bcbe16df-`, the last revision without the fallback), which registers the folder name instead.
607
+ It needs `bun`, `curl`, `jq`, `go` and `nats-server`
608
+ (`go install github.com/nats-io/nats-server/v2@v2.11.4`), and no Claude authentication: the two
609
+ hook payloads are written by the script, and that the field arrives at all rests on Claude Code's
610
+ documentation. Claude Code is not installed in an issue pod's image, though it runs there from
611
+ `bunx --package @anthropic-ai/claude-code@<version> claude`, as
612
+ `scripts/e2e/envoy-session-delivery.sh` runs it. Like the other two, it is never a CI step:
613
+
614
+ ```bash
615
+ bash packages/claude-envoy/scripts/smoke-session-title.sh
616
+ ```
617
+
618
+ ## Local checks
619
+
620
+ ```bash
621
+ bun run --cwd packages/claude-envoy lint
622
+ bun run --cwd packages/claude-envoy typecheck
623
+ bun run --cwd packages/claude-envoy test
624
+ bun packages/claude-envoy/scripts/build.ts --out "$(mktemp -d)"
625
+ ```
626
+
627
+ `bun run --cwd packages/claude-envoy verify-build "$(mktemp -d)/built.tar"` builds the plugin
628
+ twice and compares the two builds, which is what the release's build job runs; nothing in a
629
+ checkout holds a built copy to compare against.
630
+ `tests/answers-claude.test.ts` runs the real Claude Code, the release `.claude-code-pin` at the
631
+ repository root pins, against a stand-in Messages endpoint and Dispatch: the list reaches the model
632
+ at startup, on `--resume`, after `/compact` and in a subagent. It skips without `LEGION_TEST_CLAUDE`
633
+ naming the binary, except on GitHub Actions, where `.github/actions/install-claude-code` installs
634
+ the pin. Locally:
635
+
636
+ ```bash
637
+ mkdir -p "$TMPDIR/claude-code" && bun add --cwd "$TMPDIR/claude-code" --exact --no-save "@anthropic-ai/claude-code@$(<.claude-code-pin)"
638
+ cd packages/claude-envoy && LEGION_TEST_CLAUDE="$TMPDIR/claude-code/node_modules/.bin/claude" bun test tests/answers-claude.test.ts
639
+ ```