@sjawhar/claude-envoy 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # claude-envoy
2
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.
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 declares the experimental `claude/channel`
11
- capability, so Claude Code accepts its event notifications; it also exposes the Envoy messaging
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 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
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 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.
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
- ## Channel metadata
186
+ ## What a delivered line carries
241
187
 
242
- Each notification has rendered Envoy content and only identifier-safe, string-valued metadata.
243
- Claude Code drops invalid meta keys, so the server filters them before writing to stdio.
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
- | Key | Meaning |
196
+ Three envelope fields the server acts on and shows nowhere:
197
+
198
+ | Field | What the server does with it |
246
199
  | --- | --- |
247
- | `producer` | Envoy producer, or `unknown` for a malformed envelope (`source` is Claude Code's own attribute, naming the channel). |
248
- | `topic` | NATS subject that delivered the event. |
249
- | `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. |
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. Channel notifications are one-way ingress, not a remote
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 as a
320
- user turn, since print mode delivers no channel notification (`src/legion-inbox.ts`): the bridge
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
- ## Enable the channel
431
-
432
- Channels are gated by Claude Code's managed settings: whoever administers the machine writes
433
- `/etc/claude-code/managed-settings.json` (on macOS,
434
- `/Library/Application Support/ClaudeCode/managed-settings.json`), which turns channels on and
435
- allowlists the plugin, and the user opts the plugin into each session with `--channels`. An
436
- account that receives server-managed settings (cached as `remote-settings.json` in the Claude Code
437
- config directory) takes these keys from them instead, and under the default
438
- `managedSourcesBehavior` the local file then changes nothing:
439
-
440
- ```json
441
- {
442
- "channelsEnabled": true,
443
- "allowedChannelPlugins": [
444
- { "marketplace": "legion-plugins", "plugin": "claude-envoy" }
445
- ]
446
- }
447
- ```
448
-
449
- ```bash
450
- claude --channels plugin:claude-envoy@legion-plugins
451
- ```
452
-
453
- Restart Claude Code once after its first launch on a new profile (a fresh `~/.claude` or a new
454
- `CLAUDE_CONFIG_DIR`), with the same flag, and read the banner it shows at startup.
455
- "Channels (experimental) messages from plugin:claude-envoy@legion-plugins inject directly in this
456
- session" means the channel is loaded. `--channels ignored` and "Channels are not currently
457
- available" mean it is not: the session drops every channel message while it still advertises
458
- Aside, so restart again. A first launch that says `--channels waiting for your organization's
459
- policy` keeps saying so after the policy has loaded, and its messages arrive (the channel check's
460
- limits, above).
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 channel session, but it disables features that need terminal input, including
548
- multiple-choice questions and plan approval. Production Legion workers must have MCP and channel
549
- consent preconfigured and must not depend on local dialogs.
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
- - **Targeted BTW:** no session advertises `btw`. A channel notification has no model or
554
- Claude Code acknowledgement, so advertising BTW would promise an automatic correlated Dispatch
555
- reply that does not exist. Add it only with a server-owned pending-delivery state and explicit
556
- reply tool design.
557
- - **Steer outside a Legion pane:** Claude Code queues channel notifications for the model's next
558
- turn, so a steer cannot be honoured any differently from an aside; a session whose deliveries
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-channel.sh` is a real manual smoke, never a CI step. It starts an isolated, interactive
567
- Claude Code session, answers its startup dialogs unattended (folder trust, the development-channel
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. Like `smoke-channel.sh`, it runs its session on its own tmux server, on a
608
- socket in its temp directory, and prints the `tmux -S <socket> attach -t <session>` line that
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 `![${pictureCaption(name)}](${address})`;
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(PICTURE_SYNTAX)].map((match) => match[1]);
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
- ...result
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";