@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.
- package/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/plugin.json +6 -0
- package/AGENTS.md +343 -0
- package/README.md +639 -0
- package/bin/dispatch +6 -0
- package/bin/envoy-channel.ts +9 -0
- package/bin/envoy-send.ts +33 -0
- package/dist/THIRD_PARTY_NOTICES +816 -0
- package/dist/dispatch.js +27707 -0
- package/dist/envoy-channel.js +57695 -0
- package/dist/session-hook.js +26328 -0
- package/dist/session-title-hook.js +116 -0
- package/hooks/hooks.json +60 -0
- package/hooks/session-hook.ts +512 -0
- package/hooks/session-title-hook.ts +46 -0
- package/legion/.claude-plugin/plugin.json +4 -0
- package/legion/agents/deep-worker.md +68 -0
- package/legion/agents/oracle.md +35 -0
- package/legion/agents/plan-gap-analyst.md +65 -0
- package/legion/agents/plan-reviewer.md +104 -0
- package/legion/agents/thermonuclear-code-quality.md +30 -0
- package/legion/agents/thermonuclear-deep-review.md +30 -0
- package/legion/hooks/hooks.json +89 -0
- package/legion/skills/ce-simplify-code/LICENSE +21 -0
- package/legion/skills/ce-simplify-code/SKILL.md +66 -0
- package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
- package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
- package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
- package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
- package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
- package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
- package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
- package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
- package/legion/skills/legion-architect/SKILL.md +86 -0
- package/legion/skills/legion-architect/references/acceptance.md +208 -0
- package/legion/skills/legion-architect/references/decomposition.md +129 -0
- package/legion/skills/legion-architect/references/design-gate.md +206 -0
- package/legion/skills/legion-architect/references/merge-notices.md +91 -0
- package/legion/skills/legion-architect/references/wake-routing.md +41 -0
- package/legion/skills/legion-controller/SKILL.md +151 -0
- package/legion/skills/legion-controller/references/admission.md +22 -0
- package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
- package/legion/skills/legion-controller/references/daily-report.md +75 -0
- package/legion/skills/legion-controller/references/escalation.md +82 -0
- package/legion/skills/legion-controller/references/finding-asks.md +181 -0
- package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
- package/legion/skills/legion-controller/references/session.md +100 -0
- package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
- package/legion/skills/legion-controller/references/triage.md +96 -0
- package/legion/skills/legion-controller/references/wakes.md +132 -0
- package/legion/skills/legion-oracle/SKILL.md +48 -0
- package/legion/skills/legion-retro/SKILL.md +112 -0
- package/legion/skills/legion-retro/references/learnings.md +60 -0
- package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
- package/legion/skills/legion-worker/SKILL.md +69 -0
- package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
- package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
- package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
- package/legion/skills/legion-worker/references/findings.md +199 -0
- package/legion/skills/legion-worker/references/merge-gate.md +353 -0
- package/legion/skills/legion-worker/references/pr-body.md +218 -0
- package/legion/skills/legion-worker/references/review-threads.md +77 -0
- package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
- package/legion/skills/legion-worker/references/tools.md +708 -0
- package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
- package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
- package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
- package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
- package/package.json +49 -1
- package/scripts/build-inputs.txt +84 -0
- package/scripts/build.ts +415 -0
- package/scripts/check-packed-skills.sh +50 -0
- package/scripts/pack-skills.sh +146 -0
- package/scripts/smoke-channel.sh +178 -0
- package/scripts/smoke-clear-rebind.sh +164 -0
- package/scripts/smoke-session-title.sh +245 -0
- package/scripts/verify-build.sh +88 -0
- package/skills/agent-secrets/SKILL.md +180 -0
- package/skills/asd-ste100/LICENSE +21 -0
- package/skills/asd-ste100/SKILL.md +140 -0
- package/skills/asd-ste100/examples/before-after.md +92 -0
- package/skills/asd-ste100/references/writing-rules.md +59 -0
- package/skills/dispatch/SKILL.md +89 -0
- package/skills/dispatch-first/SKILL.md +46 -0
- package/skills/dispatch-writing-specs/SKILL.md +80 -0
- package/skills/envoy/SKILL.md +82 -0
- package/skills/envoy/references/check-settlement.md +62 -0
- package/skills/envoy/references/deliveries.md +67 -0
- package/skills/envoy/references/legion.md +14 -0
- package/skills/envoy/references/roles.md +12 -0
- package/skills/envoy/references/sources.md +41 -0
- package/skills/outside-message/SKILL.md +111 -0
- package/skills/plain-words/SKILL.md +88 -0
- package/src/channel-forwarder.ts +262 -0
- package/src/claude-channel.ts +705 -0
- package/src/claude-session.ts +38 -0
- package/src/envoy-channel-server.ts +2115 -0
- package/src/envoy-client.ts +26 -0
- package/src/envoy-off.ts +62 -0
- package/src/legion-claim.ts +211 -0
- package/src/legion-inbox.ts +110 -0
- package/src/legion-phase-hook.ts +554 -0
- package/src/legion-tool.ts +69 -0
- package/src/replay-state.ts +97 -0
- package/src/send-arguments.ts +13 -0
- package/src/session-identity.ts +165 -0
- package/src/session-state.ts +142 -0
- package/src/session-title.ts +251 -0
- 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
|
+
```
|