@sjawhar/claude-envoy 1.18.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/.omp-plugin/plugin.json +2 -2
- package/AGENTS.md +167 -42
- package/README.md +95 -177
- package/dist/dispatch.js +54 -6
- package/dist/envoy-channel.js +2413 -875
- package/dist/session-hook.js +54 -6
- package/hooks/claude-mod.d.ts +500 -0
- package/hooks/hooks.json +2 -1
- package/hooks/mod-protocol.ts +728 -0
- package/hooks/mod-writer.ts +506 -0
- package/hooks/mod.ts +716 -0
- package/package.json +2 -2
- package/scripts/smoke-clear-rebind.sh +1 -1
- package/scripts/smoke-session-title.sh +1 -1
- package/skills/agent-secrets/SKILL.md +55 -4
- package/src/agent-stream-claude.ts +558 -0
- package/src/agent-stream-runner.ts +340 -0
- package/src/bounded-set.ts +54 -0
- package/src/channel-forwarder.ts +1 -1
- package/src/claude-rows.ts +195 -0
- package/src/envoy-channel-server.ts +394 -136
- package/src/envoy-off.ts +6 -1
- package/src/installed-build.ts +26 -0
- package/src/legion-inbox.ts +22 -11
- package/src/mod-link.ts +562 -0
- package/src/mod-session.ts +499 -0
- package/src/replay-state.ts +2 -18
- package/src/state-file.ts +39 -0
- package/src/user-turn-router.ts +496 -0
- package/src/user-turn-state.ts +134 -0
- package/scripts/smoke-channel.sh +0 -178
- package/src/claude-channel.ts +0 -705
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# claude-envoy
|
|
2
2
|
|
|
3
|
-
Claude Code
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Claude Code plugin for Legion's Envoy subsystem. One MCP stdio server consumes Envoy NATS events
|
|
4
|
+
and puts them into the current Claude Code session's own conversation, and takes a person's
|
|
5
|
+
message from Dispatch's Agents page as that person's own turn.
|
|
6
6
|
|
|
7
7
|
## Architecture
|
|
8
8
|
|
|
9
9
|
- `.mcp.json` launches the built bundle `dist/envoy-channel.js` (built from
|
|
10
|
-
`bin/envoy-channel.ts`; see "The bundle" below). It
|
|
11
|
-
|
|
10
|
+
`bin/envoy-channel.ts`; see "The bundle" below). It reaches the model through this plugin's
|
|
11
|
+
Claude Code mod (`hooks/mod.ts`); it also exposes the Envoy messaging
|
|
12
12
|
tools through that same MCP server.
|
|
13
13
|
- `bin/dispatch` is the `dispatch` command, which Claude Code puts on the Bash tool's `PATH` while
|
|
14
14
|
the plugin is enabled; it runs the built bundle `dist/dispatch.js`. Agents reach Dispatch
|
|
@@ -76,12 +76,16 @@ events and sends them to the current Claude Code session as supported
|
|
|
76
76
|
Envoy's notification stream and relays on `notifications.envoy.deliver.<session_id>`, which
|
|
77
77
|
the server subscribes before it registers: the session's own subject, the stored copies of role
|
|
78
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
|
|
80
|
-
Legion pane once the bridge reports that Claude Code took the delivery, and in
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
server answers each frame `received` as it decodes and `done` once the session has it: in a
|
|
80
|
+
Legion pane once the bridge reports that Claude Code took the delivery, and in a terminal
|
|
81
|
+
session once the mod reports the write: a row stored in the turn running
|
|
82
|
+
(`$.session.append`), or, while the session is idle, a prompt the engine took
|
|
83
|
+
(`$.prompt.submit`). Envoy sends a frame the mod reported no write for again, and the mod
|
|
84
|
+
writes each Envoy event once, by its event id (`writeOnce` in `hooks/mod-writer.ts`), so the
|
|
85
|
+
repeat is answered from the first write's record.
|
|
86
|
+
A frame not taken yet when Claude Code stops, or published while the session was not running
|
|
87
|
+
or disconnected, comes again and is delivered once, for up to 72 hours.
|
|
88
|
+
Team and other ad-hoc topics, which the
|
|
85
89
|
stream does not store, stay live only. The resume point is kept in `replay-<session id>.json`
|
|
86
90
|
with the replay state below. `packages/envoy/AGENTS.md`, "Session consumers", states the rule.
|
|
87
91
|
- The core path is the rollout fallback dispatch://INFRA-771 removes (a listener that answers
|
|
@@ -106,80 +110,22 @@ events and sends them to the current Claude Code session as supported
|
|
|
106
110
|
messages are not recovered, and live delivery goes on as before.
|
|
107
111
|
- A forwarded role-lane envelope (one that still names its `notifications.role.<role>` topic while
|
|
108
112
|
arriving on the direct subject) receives its empty receipt immediately after the server accepts
|
|
109
|
-
the event into its ordered
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
113
|
+
the event into its ordered notification queue.
|
|
114
|
+
That confirms the server took the event, not that the model read it: nothing in the mod
|
|
115
|
+
protocol says the model read a row.
|
|
116
|
+
A plain direct send (`/v1/messages/send`) is a JetStream publish and gets no receipt: its reply
|
|
117
|
+
subject is the publisher's acknowledgement inbox, and an empty receipt there fails the publish.
|
|
118
|
+
- The server registers the session as self-subscribed with what it can take now
|
|
119
|
+
(`sessionCapabilities` in `src/envoy-channel-server.ts`), read again at every registration. A
|
|
120
|
+
terminal session reaches the model only through its mod (`hooks/mod.ts`, `src/mod-session.ts`),
|
|
121
|
+
so it advertises nothing until the mod connects and `aside`, `btw` and `steer` once it has; a
|
|
122
|
+
Legion pane, whose deliveries go to the bridge's inbox (below), advertises `aside` and `steer`
|
|
123
|
+
from the start and gains `btw` with the mod. It registers again as soon as the link hears from
|
|
124
|
+
the mod - its hello, or its first poll, since a mod says hello once per Claude Code process -
|
|
125
|
+
so Dispatch learns what the session takes without waiting for a heartbeat. With no capability
|
|
126
|
+
the listener refuses every targeted delivery (`session … does not advertise aside`) and
|
|
127
|
+
Dispatch shows the session as taking no messages, which is the right answer for a session
|
|
128
|
+
nothing can reach.
|
|
183
129
|
- A targeted Dispatch delivery the shared renderer rejects is never shown to the model: when it
|
|
184
130
|
names a message, the server posts `Invalid Dispatch targeted delivery frame` to
|
|
185
131
|
`POST /api/v1/messages/{id}/reply` so the attempt fails visibly; otherwise it is logged and dropped.
|
|
@@ -237,21 +183,23 @@ events and sends them to the current Claude Code session as supported
|
|
|
237
183
|
session's Bash first), with `ENVOY_URL` and `ENVOY_TOKEN_FILE` or `ENVOY_TOKEN` over its `envoy`
|
|
238
184
|
section. With Envoy off it sends nothing and exits 1, saying why.
|
|
239
185
|
|
|
240
|
-
##
|
|
186
|
+
## What a delivered line carries
|
|
241
187
|
|
|
242
|
-
|
|
243
|
-
|
|
188
|
+
Every line the server writes into the session is one Envoy envelope as the shared
|
|
189
|
+
`@legion/envoy-client/delivery` renderer renders it; raw envelope bytes are never exposed.
|
|
190
|
+
The rendering names the sender (`from`), the time (`at`) and the event's id (`id`), and, where the
|
|
191
|
+
envelope carries them, `urgency`, `expects_reply` — what the sender wants back, as a string:
|
|
192
|
+
`false` (nothing, so do not reply and do not acknowledge), `optional` or `required`, the old `none`
|
|
193
|
+
reaching the reader as `false` — `re` for a correlated inbound event, and
|
|
194
|
+
`reply_with`, the command that answers it.
|
|
244
195
|
|
|
245
|
-
|
|
196
|
+
Three envelope fields the server acts on and shows nowhere:
|
|
197
|
+
|
|
198
|
+
| Field | What the server does with it |
|
|
246
199
|
| --- | --- |
|
|
247
|
-
| `
|
|
248
|
-
| `
|
|
249
|
-
| `
|
|
250
|
-
| `dedupe_key` | Identity a re-send shares with the first delivery; the channel uses it to drop the re-send. |
|
|
251
|
-
| `urgency` | Optional: how soon the sender asks the reader to act. Informational only: it does not change when or in what order deliveries arrive. |
|
|
252
|
-
| `from_session` | Optional source session identifier. |
|
|
253
|
-
| `expects_reply` | Optional: what the sender wants back, as a string: `false` (nothing, so do not reply and do not acknowledge), `optional` or `required`. The old `none` reaches the reader as `false`. |
|
|
254
|
-
| `in_reply_to` | Optional correlated inbound event id. |
|
|
200
|
+
| `event_id` | Envoy event identity of this publish; the listener mints a new one for every send. The server writes one copy of a publish that arrives on two overlapping subscriptions. |
|
|
201
|
+
| `dedupe_key` | Identity a re-send shares with the first delivery; the server drops the re-send. |
|
|
202
|
+
| `topic` | The NATS subject the event arrived on: the session's own subject, a followed topic, or a role lane. |
|
|
255
203
|
|
|
256
204
|
## Envoy tools
|
|
257
205
|
|
|
@@ -272,7 +220,8 @@ follow an ask prints `Following ask <id> on <issue>: …` once per ask, after it
|
|
|
272
220
|
longer than 25,000 characters is written to a file the output names. It reads the envoy.json
|
|
273
221
|
below on every call.
|
|
274
222
|
|
|
275
|
-
Dispatch asks remain on native Dispatch.
|
|
223
|
+
Dispatch asks remain on native Dispatch.
|
|
224
|
+
An Envoy delivery is one-way ingress, not a remote
|
|
276
225
|
human approval surface.
|
|
277
226
|
|
|
278
227
|
## Skills
|
|
@@ -316,8 +265,8 @@ Claude Code when `legion.yaml` sets `worker_backends: {<role>: claude}` (tmux ru
|
|
|
316
265
|
- serves the `legion` tool: `read_record`, `handoff_complete` and `request_backward_move`, the
|
|
317
266
|
phase ops `@sjawhar/pi-legion`'s tool serves, on the same schema and executor
|
|
318
267
|
(`src/legion-tool.ts`);
|
|
319
|
-
- hands each Envoy delivery to the bridge's socket (`LEGION_CLAUDE_INBOX`), which sends it
|
|
320
|
-
|
|
268
|
+
- hands each Envoy delivery to the bridge's socket (`LEGION_CLAUDE_INBOX`), which sends it to
|
|
269
|
+
Claude Code as a user turn (`src/legion-inbox.ts`): the bridge
|
|
321
270
|
starts a turn with it when Claude Code is idle, and sends it as the next user message of the
|
|
322
271
|
running turn otherwise. So the session advertises `steer` (Dispatch's Send) beside `aside`, and
|
|
323
272
|
the bridge handles both alike. The bridge runs Claude Code with `--replay-user-messages` and
|
|
@@ -427,45 +376,38 @@ Each release publishes its own changelog entries into its GitHub release body, r
|
|
|
427
376
|
fragments its own commits added, and each fragment stays under `.changes/unreleased/`.
|
|
428
377
|
`CHANGELOG.md` is the history before that and stops where the fragments begin.
|
|
429
378
|
|
|
430
|
-
##
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
`
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
session
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
For a plugin outside the allowlist (a development marketplace, a bare `server:` entry) use
|
|
463
|
-
`--dangerously-load-development-channels <entry>`; it bypasses the allowlist only, so
|
|
464
|
-
`channelsEnabled` still applies. Do not combine it with any `--channels` entry that names the
|
|
465
|
-
same plugin: Claude Code matches the `--channels` entry first, so that entry then needs the
|
|
466
|
-
allowlist and the installed plugin's marketplace, and otherwise Claude Code skips the channel
|
|
467
|
-
(`Channel notifications skipped: you asked for plugin:…@legion-plugins but the installed
|
|
468
|
-
claude-envoy plugin is from …`).
|
|
379
|
+
## How a message reaches the model
|
|
380
|
+
|
|
381
|
+
Nothing has to be enabled on the machine: the plugin carries a Claude Code mod (`hooks/mod.ts`,
|
|
382
|
+
loaded by the `modules` key of `hooks/hooks.json`), and the mod and this server meet on a Unix
|
|
383
|
+
socket of the session's own (`hooks/mod-protocol.ts`, `src/mod-link.ts`). Claude Code 2.1.310 or
|
|
384
|
+
newer runs the mod; below that it never connects, and a terminal session advertises no delivery
|
|
385
|
+
mode, so Dispatch refuses a message to it rather than reporting it delivered.
|
|
386
|
+
|
|
387
|
+
That socket is `claude-envoy/ce-<hash of the session id>.sock` under `XDG_RUNTIME_DIR`, and
|
|
388
|
+
under Claude Code's own configuration directory — `CLAUDE_CONFIG_DIR`, else `$HOME/.claude` —
|
|
389
|
+
where that variable is unset. Both are directories only their own user can create anything in,
|
|
390
|
+
which is where ssh's `ControlPath` and gpg-agent put theirs; `TMPDIR` and `/tmp` are no bases,
|
|
391
|
+
since whoever else can write a directory can bind the path first and then read the conversation
|
|
392
|
+
and submit prompts as the person. The server also refuses a directory that is anyone else's or
|
|
393
|
+
open to anyone else, rather than binding in it, and a session whose environment names no base of
|
|
394
|
+
its own gets no link at all, with the reason on stderr. The name is the session id's hash
|
|
395
|
+
because the whole path has 100 bytes to fit in (Claude Code's bound on `HttpInit.socketPath`):
|
|
396
|
+
a macOS `/Users/<name>/.claude` comes to 62 bytes and a Legion pane's `CLAUDE_CONFIG_DIR` to 78,
|
|
397
|
+
where the id spelled out passed 100 on a macOS temp directory. A resolved path past that bound
|
|
398
|
+
is refused with the reason too, never moved to a base other users share. One link serves one
|
|
399
|
+
mod: the first mod instance to name itself holds it, and a second Claude Code process booted on
|
|
400
|
+
the same session id is refused and stops, so one conversation is in the live view and a person's
|
|
401
|
+
Send has one place to go.
|
|
402
|
+
|
|
403
|
+
A person's own message from Dispatch's Agents page becomes the session's own user turn
|
|
404
|
+
(`src/user-turn-router.ts`): the frame is only a candidate, and what makes a turn is Dispatch
|
|
405
|
+
recording that this session accepted that attempt, whose stored body is what is injected. During
|
|
406
|
+
a turn it is a row of the conversation the model reads at the turn's next request; while the
|
|
407
|
+
session is idle it is the prompt of a new turn, in the person's own words. A `/` message runs as
|
|
408
|
+
the command it is and its output is the reply on Dispatch; `/model` and `/effort` set what they
|
|
409
|
+
name; a BTW is answered off to the side and adds nothing to the conversation. Every other frame
|
|
410
|
+
arrives as the rendered card that names its sender.
|
|
469
411
|
|
|
470
412
|
The plugin reads one envoy.json: `$CLAUDE_CONFIG_DIR/envoy.json` when `CLAUDE_CONFIG_DIR` is set,
|
|
471
413
|
else the file `ENVOY_CONFIG_FILE` names, else `${XDG_CONFIG_HOME:-~/.config}/legion/envoy.json`,
|
|
@@ -544,48 +486,24 @@ comment or message from the session carries the same name. With inbound messages
|
|
|
544
486
|
writes it once, for the id the session starts with. It writes nothing when the session starts with
|
|
545
487
|
Dispatch off, or under the QA override `ENVOY_SESSION_ID`.
|
|
546
488
|
|
|
547
|
-
`claude -p` can run a
|
|
548
|
-
multiple-choice questions and plan approval.
|
|
549
|
-
|
|
489
|
+
`claude -p` can run a session with this plugin, but it disables features that need terminal
|
|
490
|
+
input, including multiple-choice questions and plan approval.
|
|
491
|
+
Production Legion workers must have
|
|
492
|
+
MCP consent preconfigured and must not depend on local dialogs.
|
|
550
493
|
|
|
551
494
|
## Intentionally omitted
|
|
552
495
|
|
|
553
|
-
- **
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
-
|
|
558
|
-
|
|
559
|
-
are channel notifications does not advertise it.
|
|
560
|
-
- **Permission relay:** the server does not declare `claude/channel/permission`. It has no
|
|
561
|
-
sender-authenticated reply path or reply-ack design for remote permission decisions; declaring
|
|
562
|
-
one would let unauthenticated Envoy payloads approve tool use. Dispatch asks stay on Dispatch.
|
|
496
|
+
- **A delivery to a session with no mod:** a terminal session whose mod has not connected
|
|
497
|
+
advertises no delivery mode at all, rather than taking a message nothing can show. Claude Code
|
|
498
|
+
below 2.1.310 never runs the mod, so such a session takes nothing.
|
|
499
|
+
- **Permission relay:** remote permission decisions have no sender-authenticated reply path or
|
|
500
|
+
reply-ack design here; carrying them would let unauthenticated Envoy payloads approve tool use.
|
|
501
|
+
Dispatch asks stay on Dispatch.
|
|
563
502
|
|
|
564
503
|
## Manual smoke
|
|
565
504
|
|
|
566
|
-
`smoke-
|
|
567
|
-
|
|
568
|
-
warning, first-use MCP consent — in whatever order they appear), waits for the Envoy registration,
|
|
569
|
-
sends an actual direct Envoy event, and asserts the model writes the unique payload before checking
|
|
570
|
-
teardown. It requires a live Envoy listener, NATS, Claude authentication, a configured model,
|
|
571
|
-
managed settings that enable channels and allowlist the plugin (see "Enable the channel"), and an
|
|
572
|
-
installed plugin entry:
|
|
573
|
-
|
|
574
|
-
```bash
|
|
575
|
-
ENVOY_NATS_URL=nats://envoy-nats:4222 \
|
|
576
|
-
CLAUDE_CHANNEL_ENTRY=plugin:claude-envoy@legion-plugins \
|
|
577
|
-
bash -c 'cd packages/claude-envoy/scripts && ./smoke-channel.sh'
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
`CLAUDE_CHANNEL_FLAG` defaults to `--channels` when the entry ends in `@legion-plugins` (the
|
|
581
|
-
allowlisted marketplace) and to `--dangerously-load-development-channels` for every other entry;
|
|
582
|
-
set it explicitly to override. The `PASS` line prints the registered session row (id, dir,
|
|
583
|
-
capabilities); a failure dumps the pane and the full `tmux pipe-pane` capture of everything Claude
|
|
584
|
-
printed. The session runs on the smoke's own tmux server, on a socket in its temp directory, so its
|
|
585
|
-
teardown can end only that session; the script prints the `tmux -S <socket> attach -t <session>` line
|
|
586
|
-
that watches it. Use `CLAUDE_BIN` or `ENVOY_URL` to select a different Claude binary or listener. For
|
|
587
|
-
source-tree diagnostics, pass a temporary bare-server config through `CLAUDE_MCP_CONFIG` and use
|
|
588
|
-
`CLAUDE_CHANNEL_ENTRY=server:envoy`; the marketplace command above is the plugin packaging check.
|
|
505
|
+
`smoke-clear-rebind.sh` and `smoke-session-title.sh` are real manual smokes, never CI steps. Each
|
|
506
|
+
needs a live Envoy listener, NATS, Claude authentication and a configured model.
|
|
589
507
|
|
|
590
508
|
To exercise an unmerged checkout the way the marketplace would install it, register a development
|
|
591
509
|
marketplace with a `command` source in copy mode whose command builds a copy of the plugin with
|
|
@@ -604,8 +522,8 @@ heartbeats later. It also fails when the entry moves later than the handoff poll
|
|
|
604
522
|
`ENVOY_HEARTBEAT_MS=60000` that checks the poll rather than a heartbeat that happened to fall soon
|
|
605
523
|
after `/clear`. It needs `tmux`, `curl`, `jq`, `bun` (for the default build), an authenticated
|
|
606
524
|
`claude`, and the listener and NATS; it sends no prompt to the model and removes both ids' registry
|
|
607
|
-
entries when it exits.
|
|
608
|
-
|
|
525
|
+
entries when it exits. It runs its session on its own tmux server, on a socket in its temp
|
|
526
|
+
directory, and prints the `tmux -S <socket> attach -t <session>` line that
|
|
609
527
|
watches it. Pointed at an older build (an installed plugin cache directory), it reproduces the
|
|
610
528
|
stale subject that build leaves behind:
|
|
611
529
|
|
package/dist/dispatch.js
CHANGED
|
@@ -14923,6 +14923,16 @@ function actorLabel(actor, titles) {
|
|
|
14923
14923
|
return `${actorName(actor, titles)}${actorAttribution(actor)}`;
|
|
14924
14924
|
}
|
|
14925
14925
|
// ../contracts/src/agent-stream.ts
|
|
14926
|
+
var AGENT_STREAM_SUBJECT_PREFIX = "agentstream.";
|
|
14927
|
+
var AGENT_STREAM_PROTOCOL = 1;
|
|
14928
|
+
function agentStreamFramesSubject(sessionID) {
|
|
14929
|
+
return `${AGENT_STREAM_SUBJECT_PREFIX}${sessionID}.frames`;
|
|
14930
|
+
}
|
|
14931
|
+
function agentStreamControlSubject(sessionID) {
|
|
14932
|
+
return `${AGENT_STREAM_SUBJECT_PREFIX}${sessionID}.control`;
|
|
14933
|
+
}
|
|
14934
|
+
var AGENT_STREAM_WATCH_TTL_MS = 30000;
|
|
14935
|
+
var AGENT_STREAM_WATCH_INTERVAL_MS = 1e4;
|
|
14926
14936
|
var AGENT_STREAM_LIMITS = {
|
|
14927
14937
|
partChars: 16000,
|
|
14928
14938
|
toolChars: 8000,
|
|
@@ -14934,6 +14944,16 @@ var AGENT_STREAM_LIMITS = {
|
|
|
14934
14944
|
commandNameChars: 100,
|
|
14935
14945
|
commandDescriptionChars: 200
|
|
14936
14946
|
};
|
|
14947
|
+
var AGENT_STREAM_TRUNCATION_SUFFIX = `
|
|
14948
|
+
\u2026 truncated`;
|
|
14949
|
+
function isTypableCommandName(name) {
|
|
14950
|
+
return /^[^\s\p{Cc}]+$/u.test(name);
|
|
14951
|
+
}
|
|
14952
|
+
function capAgentStreamText(text, limit) {
|
|
14953
|
+
if (text.length <= limit)
|
|
14954
|
+
return text;
|
|
14955
|
+
return text.slice(0, limit) + AGENT_STREAM_TRUNCATION_SUFFIX;
|
|
14956
|
+
}
|
|
14937
14957
|
// ../contracts/src/claim-holds.ts
|
|
14938
14958
|
var HOLDING_OF = {
|
|
14939
14959
|
gone: "lapsed",
|
|
@@ -15008,6 +15028,24 @@ function isIdPrefix(value) {
|
|
|
15008
15028
|
function pictureCaption(name) {
|
|
15009
15029
|
return name.replace(/[\\[\]`]/g, "\\$&").replace(/\s+/g, " ");
|
|
15010
15030
|
}
|
|
15031
|
+
var PICTURE_CAPTION = String.raw`(?:\\.|[^\\\]\n]){0,512}`;
|
|
15032
|
+
var PICTURE_ADDRESS = String.raw`dispatch://[^\s()]{1,2048}@v[1-9][0-9]{0,18}`;
|
|
15033
|
+
var PICTURE_TITLE = String.raw`(?:\s{1,4}"(?:\\.|[^\\"]){0,256}")?`;
|
|
15034
|
+
var PICTURE_SYNTAX = String.raw`!\[${PICTURE_CAPTION}\]\((${PICTURE_ADDRESS})${PICTURE_TITLE}\)`;
|
|
15035
|
+
function pictureSyntax() {
|
|
15036
|
+
return new RegExp(PICTURE_SYNTAX, "g");
|
|
15037
|
+
}
|
|
15038
|
+
function blockPlaceholder(type) {
|
|
15039
|
+
return `[${type}]`;
|
|
15040
|
+
}
|
|
15041
|
+
var PICTURE_PLACEHOLDER = blockPlaceholder("image");
|
|
15042
|
+
function trimmedLines(text) {
|
|
15043
|
+
return text.split(`
|
|
15044
|
+
`).map((line) => line.trim()).filter((line) => line !== "");
|
|
15045
|
+
}
|
|
15046
|
+
function comparableLines(text) {
|
|
15047
|
+
return trimmedLines(text.replace(pictureSyntax(), ""));
|
|
15048
|
+
}
|
|
15011
15049
|
var PICTURE_TYPES = {
|
|
15012
15050
|
"image/png": true,
|
|
15013
15051
|
"image/jpeg": true,
|
|
@@ -18214,9 +18252,9 @@ class DispatchClient {
|
|
|
18214
18252
|
async messageReply(id, input, options = {}) {
|
|
18215
18253
|
return this.#json("POST", ["api", "v1", "messages", id, "reply"], input, options.followUp === true ? { follow_up: "true" } : undefined);
|
|
18216
18254
|
}
|
|
18217
|
-
async deliveryReply(resource, id, input) {
|
|
18255
|
+
async deliveryReply(resource, id, input, options = {}) {
|
|
18218
18256
|
const collection = resource === "message" ? "messages" : "comments";
|
|
18219
|
-
await this.#json("POST", ["api", "v1", collection, id, "reply"], input);
|
|
18257
|
+
await this.#json("POST", ["api", "v1", collection, id, "reply"], input, options.followUp === true ? { follow_up: "true" } : undefined);
|
|
18220
18258
|
}
|
|
18221
18259
|
async getMessage(issue, id) {
|
|
18222
18260
|
return this.#json("GET", [
|
|
@@ -19262,6 +19300,9 @@ function toolImage(bytes) {
|
|
|
19262
19300
|
return;
|
|
19263
19301
|
return { data: Buffer.from(bytes).toString("base64"), mimeType };
|
|
19264
19302
|
}
|
|
19303
|
+
function imageBlocks(images) {
|
|
19304
|
+
return images.map(({ data, mimeType }) => ({ type: "image", data, mimeType }));
|
|
19305
|
+
}
|
|
19265
19306
|
function pictureLine(name, address) {
|
|
19266
19307
|
return ``;
|
|
19267
19308
|
}
|
|
@@ -19275,9 +19316,8 @@ function withPictureLines(text, lines) {
|
|
|
19275
19316
|
${lines.join(`
|
|
19276
19317
|
`)}`;
|
|
19277
19318
|
}
|
|
19278
|
-
var PICTURE_SYNTAX = /!\[(?:\\.|[^\\\]])*\]\((dispatch:\/\/[^\s()]+@v[1-9][0-9]*)(?:\s+"(?:\\.|[^\\"])*")?\)/g;
|
|
19279
19319
|
function pictureAddresses(text) {
|
|
19280
|
-
return [...text.matchAll(
|
|
19320
|
+
return [...text.matchAll(pictureSyntax())].map((match) => match[1]);
|
|
19281
19321
|
}
|
|
19282
19322
|
function picturesNewestFirst(texts) {
|
|
19283
19323
|
const ordered = texts.map((item, index) => ({ item, index, at: Date.parse(item.at) })).sort((left, right) => (Number.isNaN(right.at) ? 0 : right.at) - (Number.isNaN(left.at) ? 0 : left.at) || left.index - right.index);
|
|
@@ -19370,13 +19410,14 @@ function dispatchReplyPath(resource, id) {
|
|
|
19370
19410
|
return `/api/v1/${collection}/${encodeURIComponent(id)}/reply`;
|
|
19371
19411
|
}
|
|
19372
19412
|
async function postDeliveryReply(config, sessionId, delivery, result) {
|
|
19413
|
+
const { followUp, ...reply } = result;
|
|
19373
19414
|
const client = new DispatchClient(config.url, config.credential);
|
|
19374
19415
|
await client.deliveryReply(delivery.resource, delivery.id, {
|
|
19375
19416
|
actor: { kind: "session", id: sessionId },
|
|
19376
19417
|
attempt: delivery.attempt,
|
|
19377
19418
|
...delivery.replyFields,
|
|
19378
|
-
...
|
|
19379
|
-
});
|
|
19419
|
+
...reply
|
|
19420
|
+
}, followUp === undefined ? {} : { followUp });
|
|
19380
19421
|
}
|
|
19381
19422
|
function expectsLaneReceipt(frame) {
|
|
19382
19423
|
return frame.reply !== undefined && frame.reply !== "" && frame.subject === frame.directSubject && frame.envelopeTopic !== undefined && frame.envelopeTopic !== frame.directSubject;
|
|
@@ -27565,6 +27606,13 @@ function dispatchTarget(env, config) {
|
|
|
27565
27606
|
const url = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
27566
27607
|
return { enabled: true, url, source };
|
|
27567
27608
|
}
|
|
27609
|
+
function activeDispatchSettings(resolution) {
|
|
27610
|
+
if (resolution.enabled)
|
|
27611
|
+
return resolution;
|
|
27612
|
+
if (resolution.error !== null)
|
|
27613
|
+
throw new Error(`dispatch config: ${resolution.error}`);
|
|
27614
|
+
return null;
|
|
27615
|
+
}
|
|
27568
27616
|
|
|
27569
27617
|
// ../envoy-client/src/envoy-settings.ts
|
|
27570
27618
|
var DEFAULT_ENVOY_URL = "http://127.0.0.1:9020";
|