@sjawhar/claude-envoy 1.14.1 → 1.16.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.
@@ -2,5 +2,5 @@
2
2
  "name": "claude-envoy",
3
3
  "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
4
4
  "mcpServers": "./.mcp.json",
5
- "version": "1.14.1"
5
+ "version": "1.16.0"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-envoy",
3
- "version": "1.14.1",
3
+ "version": "1.16.0",
4
4
  "description": "Deliver Envoy events as Claude Code channel notifications and expose Envoy MCP tools.",
5
5
  "mcpServers": {}
6
6
  }
package/AGENTS.md CHANGED
@@ -119,7 +119,7 @@ Dispatch asks stay on Dispatch.
119
119
  | Real Claude Code test | `tests/answers-claude.test.ts`, `/.claude-code-pin`, `/.github/actions/install-claude-code` | The `answers` hook on the real binary: Claude Code runs with this package as `--plugin-dir`, an isolated HOME and `CLAUDE_CONFIG_DIR`, and one stand-in as its Messages endpoint (`ANTHROPIC_BASE_URL`, server-sent events as the API streams them) and Dispatch (`DISPATCH_URL`); each case reads the list off the Messages request after the moment: startup (`claude -p --session-id`), `--resume <id>`, `/compact` sent in stream-json as `legion claude-bridge` sends turns, and a subagent (the stand-in answers with an `Agent` tool call, the API name of Claude Code's Task tool). `LEGION_TEST_CLAUDE` names the binary; the test skips without it, except on GitHub Actions, where the claude-envoy job installs the release `.claude-code-pin` pins (`bun add` into the runner's temp directory). A pin bump that changes a request shape is fixed in that file. |
120
120
  | Inbound channel server | `src/envoy-channel-server.ts`, `src/envoy-off.ts` | MCP capability, notification queue, NATS registration, session-id handoff, role persistence, rejected-frame replies, and the Envoy tools, on the envoy.json Claude Code's rule picks (`@legion/envoy-client/session-settings`); why a section is off, and that inbound messages are off, goes to stderr once as `envoy-channel: <notice>`. `runEnvoyChannelServer` chooses between that server and the two reduced modes of `src/envoy-off.ts`. With Envoy off it runs `serveEnvoyOff` instead: an MCP server that lists no tool, refuses every call with `Envoy is off for this session: <why>` (the section's notice or error), opens no NATS connection and calls no listener, and exits 0 when stdin ends or on SIGTERM, SIGINT or SIGHUP. With Envoy on and inbound messages off (`inbound.off`: no NATS server, from `envoy.natsUrls` or `ENVOY_NATS_URL`, or its seed refused), it serves the tools on `inboundOffChannelSession`: no NATS connection, nothing registered with the listener, an empty inbox, `envoy_subscribe` refused naming why, and a role `envoy_role_set` claims still persisted. |
121
121
  | Channel gate | `src/claude-channel.ts` | Whether Claude Code loaded this server as a channel, which it never tells the server: the command line of the nearest ancestor whose program is `claude` (`claudeCommandLine`; Claude Code may run the server through a shell prefix or a `bun` shim), read from `/proc` (exact, with its stdout's terminal and its directory) or `ps` (split on whitespace), against Claude Code's own gate as of 2.1.301: a `plugin:claude-envoy@…` entry when `CLAUDE_PLUGIN_ROOT` says it runs as a plugin, any `server:` entry from a bare config; `--channels` entries before development ones; `--dangerously-load-development-channels` only in an interactive session (`asksForPrintMode`, the arguments skipped as Claude Code's scanner skips them, and a terminal on stdout where `/proc` shows it); the marketplace it is loaded from (`loadedMarketplace`: `inline` for `--plugin-dir`, else the cache's, compared after Claude Code's cache-name sanitizing); for `--channels`, `allowedChannelPlugins` (object or `plugin@marketplace` entries, each invalid one dropped alone) from the managed source that owns it (`remote-settings.json`, else `managed-settings.json` with its non-dot drop-ins; under `managedSourcesBehavior: "merge"` the highest that sets it). Not loaded, the session registers `capabilities: []`. `ENVOY_CHANNEL_LOADED=1`/`0` overrides it. A command line it cannot read, or no `claude` ancestor, counts as loaded, and so does one Node overwrote with `process.title = "claude"` (on Linux, the title alone followed by NUL padding, whatever the executable's name; elsewhere one word from a program named `node…`). |
122
- | Session identity | `src/session-identity.ts`, `src/session-state.ts`, `src/claude-session.ts` | `SessionIdentity` is the one mutable id holder; handoff file `sessions/<claude-pid>/session-id`; role file `roles/<url-encoded session id>.json` (`readPersistedRole`, `writePersistedRole`; the suffix is what keeps a session id of `.` or `..` inside `roles/`, since `encodeURIComponent` leaves a dot alone). `CLAUDE_CODE_SESSION_ID`, or explicit `ENVOY_SESSION_ID` for QA. `src/session-state.ts` holds what the state files share and nothing that imports zod: `writeAtomicStateFile`, and `pruneStateDirectory(directory, isStale)`, the one ENOENT-tolerant sweep behind all three of them — handoffs by liveness (a `sessions/<pid>` whose Claude process is gone, anything else there left alone), titles and roles by age (`writtenLongAgo`, `STATE_FILE_MAX_AGE_MS`, thirty days). The role sweep exists because exactly one path removes a role file by hand — a soft `setRole` the listener refuses with a holder that is **not** the predecessor the claim named, which says that predecessor no longer holds the role. A refusal naming the predecessor itself keeps the file, since that record belongs to the live holder, which may be another process sharing this state directory. The handoff sweep cannot reach either, keyed as it is by session id with no process left to ask about. All three run at startup in `startChannelSession`, each under its own `.catch` that logs and swallows: the heartbeat timer is not armed yet, so housekeeping must never cost a session its channel. |
122
+ | Session identity | `src/session-identity.ts`, `src/session-state.ts`, `src/claude-session.ts` | `SessionIdentity` is the one mutable id holder; handoff file `sessions/<claude-pid>/session-id`; role file `roles/<url-encoded session id>.json` (`readPersistedRole`, `writePersistedRole`; the suffix is what keeps a session id of `.` or `..` inside `roles/`, since `encodeURIComponent` leaves a dot alone), whose record carries the writing process's `client_instance_id` beside the session id and the role, optional so a record written before the field loads unchanged. `CLAUDE_CODE_SESSION_ID`, or explicit `ENVOY_SESSION_ID` for QA. `src/session-state.ts` holds what the state files share and nothing that imports zod: `writeAtomicStateFile`, and `pruneStateDirectory(directory, isStale)`, the one ENOENT-tolerant sweep behind all three of them — handoffs by liveness (a `sessions/<pid>` whose Claude process is gone, anything else there left alone), titles and roles by age (`writtenLongAgo`, `STATE_FILE_MAX_AGE_MS`, thirty days). The role sweep exists because exactly one path removes a role file by hand — a soft `setRole` the listener refuses naming a session that is **not** the one the record is for, which says that session no longer holds the role. A refusal naming the record's own session keeps the file: the role is then held under that id by another process sharing this state directory, and the refused process goes on pressing its claim, refreshing the record's mtime on each heartbeat as a confirmed holder does, so the age sweep never reaches a record a process is still claiming for. The handoff sweep cannot reach either, keyed as it is by session id with no process left to ask about. All three run at startup in `startChannelSession`, each under its own `.catch` that logs and swallows: the heartbeat timer is not armed yet, so housekeeping must never cost a session its channel. |
123
123
  | Session title | `src/session-title.ts`, `src/session-state.ts` | The title every registration sends, which Dispatch's Agents page shows in place of the id: `ENVOY_SESSION_TITLE` on one line when it holds a character that shows; else the name the person running the session gave it (`claude -n`, `--name`, `/rename`), alone, with no short id after it, since a name a person chose needs nothing to tell it apart; else a name and the current id's first eight characters (a function of the id, so it follows a `/clear` handoff, and keeps two sessions in one tmux window apart). The chosen name is read, through `readChosenTitleSync`, from the `titles/<url-encoded session id>.txt` file the hooks write under `CLAUDE_PLUGIN_DATA` (keyed by id, like the role file, so `claude --resume <id>` finds it and a `/clear`'s new id starts without one; the suffix keeps a session id of `.` or `..` inside `titles/`, since `encodeURIComponent` leaves a dot alone), and every writer and reader goes through `sessionTitleFile` so the file the hook writes is the file the server reads; that read is synchronous and swallows every error, because a cosmetic title must leave the session on its fallback rather than fail the registration carrying it, and the name goes through the same `oneLine`/`visible` pair the other sources do, so a blank or invisible one falls through to the fallback while a multi-line one is put on one line and kept. The fallback's name is the pane's tmux name (`TMUX` and `TMUX_PANE`, one bounded `tmux -u -S <socket> display-message` at startup, used only when the answering server's pid is the one in `TMUX` and the pane's process is one of this process's ancestors (`isAncestor`); `session:window` when the window's `automatic-rename` is off, else the session, never a window index; for grouped sessions the group's name), else the project directory's name (outside tmux, a refused or missing answer, or a session named only by digits in a window nobody named). That name is worked out once as the server starts; the chosen name is asked for on every call instead, so a rename reaches the page on the session's next prompt plus a heartbeat, with no watcher anywhere. `pruneStaleSessionTitles` drops title files untouched for thirty days at startup, through the sweep the handoff and role prunes share and under the same best-effort guard, since nothing else deletes a file keyed by a session id. The listener replaces the stored title on every registration, and `register` also stamps it (`stampTitle`: `writeSessionTitle` in the session's Dispatch state directory) once per id, where the `dispatch` command reads it for the `session_title` on its writes; with inbound messages or Envoy off, nothing registers, so `runEnvoyChannelServer` stamps it once for the starting id (a `/clear` there leaves the new id unstamped); nothing is stamped when the session starts with Dispatch off or under `ENVOY_SESSION_ID`. |
124
124
  | NATS topic consumer | `src/channel-forwarder.ts` | Dedupe through the shared `createDeliveryDedupe`: a re-send by its `dedupe_key` (never by `event_id` alone, which the listener mints fresh for every send, so a Dispatch Retry would pass as new), and a second copy of one publish arriving on overlapping subscriptions by its `event_id`; surface the envelope's topic, and drain safely at shutdown. The promise the dedupe keeps is stated on `DELIVERY_DUPLICATE_WINDOW_MS` in `packages/contracts`. |
125
125
  | Consumer delivery | `src/envoy-channel-server.ts` (`openRelay`, `deliverRelayed`, `renewRelayEpoch`, `relaysCoreTopic`), `src/replay-state.ts` (the `consumer` part of `replay-<session id>.json`), `../envoy-client/src/consumer-delivery.ts`, `../../scripts/e2e/envoy-session-delivery.sh` | The listener's relay of the session's durable consumer ("Overview"): the relay subscription and epoch, each frame's `received`, `done` and `nak`, and the persisted resume point. The repository's `scripts/e2e/envoy-session-delivery.sh` is the live proof under `legion claude-bridge` (`scripts/e2e/README.md`). |
@@ -285,17 +285,50 @@ above says how to run it).
285
285
  the last signal Claude Code gives there, and the bridge's inbox answer in a Legion pane, which
286
286
  comes once Claude Code re-emits the delivery (`--replay-user-messages`).
287
287
  - Preserve the held role at `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json` (keyed by session id so
288
- `claude --resume <id>` finds it); soft-reclaim it with its `previous_session_id` after
289
- registration, then reassert it on each registration heartbeat. The listener grants such a claim
290
- only when that predecessor has no live registry entry, when its live entry carries this
288
+ `claude --resume <id>` finds it), with this process's client instance id in the record beside
289
+ the session id and the role, since two `claude` processes of one user read one state directory
290
+ and a record keyed by session id alone names no writer; soft-reclaim it with its
291
+ `previous_session_id` after registration, then reassert it on each registration heartbeat. A
292
+ record another process wrote is pressed only while this process holds no role, and a record
293
+ from a build before the writer was named is pressed as it always was. The listener grants such
294
+ a claim only when that predecessor has no live registry entry, when its live entry carries this
291
295
  process's own client instance id, or when either side carries no instance id. A predecessor
292
296
  still live under another client instance keeps the role, and the claim is refused naming it.
293
297
  That refusal keeps the predecessor's role file, because the record belongs to the live holder.
294
- A refusal naming any other session removes the file. The handoff directory
295
- `sessions/<claude-pid>/` is pruned at startup when its pid is gone — never deleted on shutdown,
296
- because Claude Code restarts the server inside the same `claude` process — and the two
297
- id-keyed directories, `roles/` and `titles/`, are pruned there too at thirty days, since a
298
- session that ends while it still holds its role deletes neither file itself.
298
+ A claim of a role already held under this session's own id is decided on the standing claim:
299
+ granted to the process that claim names, granted once nobody renewed it inside the listener's
300
+ live window, and otherwise refused naming this very session. The re-assertion confirms the
301
+ role only when the lookup names this session *and* this process and reports no hand-back,
302
+ since a claim another process made under this id is not this one's to renew and a claim the
303
+ listener marked handed back is nobody's to renew; a refusal naming this session keeps the role,
304
+ refreshes the record's mtime and asks again on the next heartbeat, and only a refusal naming a
305
+ different session ends the role and removes the file. The restore keeps asking on the same
306
+ rule: a refusal naming this session's own id leaves the restore pending on `roleTransferFrom`,
307
+ which every heartbeat presses again until the claim is granted, since one claim at boot and no
308
+ more would cost a `--resume` or a channel-server restart inside the live window its role for
309
+ the life of the process. The Legion pane's claim waits the same refusal out rather than
310
+ stopping the pane, asking every `ROLE_CLAIM_RETRY_MS` (10 s) for the live window and one
311
+ attempt more, so the grant lands inside the daemon's registration deadline for the pane's
312
+ `claims/ready` — `worker_boot_timeout_seconds` × `worker_boot_registration_deadline_intervals`
313
+ from the registration — and it throws only on a refusal naming a different session. The
314
+ lookup's rule and the record's are the client contract's own,
315
+ `roleHeldBy` and `mayActOnRoleRecord` in `@legion/envoy-client/transport`, which
316
+ `packages/pi-envoy` calls too and which read an empty instance id as none, the lookup leaving
317
+ `client_instance_id` out where the claim names no process. Every claim and every topic
318
+ hand-back states this process's instance too, so a release frees only the role claims this
319
+ process holds, and a release the holder's own renewal beat under each of the route's three
320
+ conditional deletes fails rather than reporting one it did not make. The registration that
321
+ renews those claims renews every one of them whatever topics it carries, and renews no claim
322
+ the listener has marked handed back: what says a role was given up is the mark a hand-back
323
+ leaves on a claim it could not free. This process writes no such mark — it gives a role up by
324
+ deregistering the session, not by releasing the lane — but a client built before a release
325
+ stated its process can leave one on this session's claim, and the re-assertion above claims
326
+ the role back on the next heartbeat, its grant a fresh record with no mark.
327
+ The handoff directory `sessions/<claude-pid>/` is pruned at startup when its pid is gone —
328
+ never deleted on shutdown, because Claude Code restarts the server inside the same `claude`
329
+ process — and the two id-keyed directories, `roles/` and `titles/`, are pruned there too at
330
+ thirty days, since a session that ends while it still holds its role deletes neither file
331
+ itself.
299
332
  - Shared logic remains shared: use `sessionDispatchSettings`, `formatOpenAsksSummary`,
300
333
  `postDeliveryReply`, `mergeInterestSources`, and `sendConfirmationText`. No Dispatch call goes
301
334
  through the MCP server; the `dispatch` command runs the shared executor, and a `dispatch_*` tool
package/README.md CHANGED
@@ -186,19 +186,35 @@ events and sends them to the current Claude Code session as supported
186
186
  - A resumed server rebuilds the interests its session id already registered. `envoy_list` reports
187
187
  the union of live NATS subscriptions and registry interests with each topic's `source` (`live`,
188
188
  `registry`, `both`).
189
- - `envoy_role_set` persists the held role in `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json`.
189
+ - `envoy_role_set` persists the held role in `${CLAUDE_PLUGIN_DATA}/roles/<session-id>.json`,
190
+ which names the writing process's client instance beside the session id and the role.
190
191
  `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
+ `previous_session_id`; a plain relaunch is a new session and inherits nothing. A record another
193
+ process wrote is pressed only while this process holds no role, since two `claude` processes of
194
+ one user share the state directory and a record is keyed by session id alone. The listener
192
195
  moves the role off that predecessor only when the predecessor has no live registration, when
193
196
  its live registration carries this process's own client instance id, or when either side
194
197
  carries no instance id. A predecessor another process still runs keeps the role, and the claim
195
198
  is refused naming it: this session goes on running and registered without the role, claims it
196
199
  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.
200
+ belongs to the live holder. A claim of a role already held under this session's own id is
201
+ decided on the standing claim: the listener grants it to the process that claim names, grants
202
+ it once nobody renewed that claim inside its live window, and otherwise refuses it naming this
203
+ very session, which keeps the role and the record here and asks again on the next heartbeat.
204
+ Only a refusal naming a different session ends the role and removes the record. Every healthy
205
+ registration heartbeat checks and reasserts the role if the listener lost it, and it confirms
206
+ the role only when the lookup names this session *and* this process and reports no hand-back
207
+ (`handed_back`); each heartbeat also renews the claims this process holds, whatever topics
208
+ that registration carries, and renews no claim the listener has marked handed back. A client
209
+ built before a release stated its process can hand this session's role lane back without
210
+ freeing the claim, which the listener marks rather than frees, so the mark is read as a role
211
+ this process does not hold and the next heartbeat claims it back, its grant a fresh record
212
+ with no mark. This process hands no lane back itself. Shutdown (stdin end, SIGTERM, SIGINT, or the SIGHUP a
213
+ closing pane sends the whole process group) drains NATS and deregisters the session, naming
214
+ this process's client instance id, so the listener removes the registry entry only while it
215
+ still carries that id or none, and every topic hand-back names it too, so a release frees only
216
+ the role claims this process holds; a release the holder's own renewal beat under each of the
217
+ route's three conditional deletes fails rather than reporting one it did not make.
202
218
  - `/clear` mints a new Claude session id, but a stdio MCP server keeps the `CLAUDE_CODE_SESSION_ID`
203
219
  it was spawned with. The SessionStart hook writes the current id to
204
220
  `${CLAUDE_PLUGIN_DATA}/sessions/<claude-pid>/session-id` (keyed by the `claude` process both the
@@ -234,7 +250,7 @@ Claude Code drops invalid meta keys, so the server filters them before writing t
234
250
  | `dedupe_key` | Identity a re-send shares with the first delivery; the channel uses it to drop the re-send. |
235
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. |
236
252
  | `from_session` | Optional source session identifier. |
237
- | `expects_reply` | Optional Envoy reply expectation. |
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`. |
238
254
  | `in_reply_to` | Optional correlated inbound event id. |
239
255
 
240
256
  ## Envoy tools
package/dist/dispatch.js CHANGED
@@ -15439,7 +15439,7 @@ var dispatchToolSpecs = [
15439
15439
  {
15440
15440
  name: "dispatch_message",
15441
15441
  example: { issue: "DSP-1", body: "Implementation started." },
15442
- description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) or document feedback (dispatch comment). " + "To answer a human's direct message to this session - one sent from the Agents page, which names no issue - pass that message's bare id as " + "in_reply_to and no issue; the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A BTW, Aside or Steer " + "frame carries a reply_with command: answer with it, on that issue. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
15442
+ description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) or document feedback (dispatch comment). " + "To answer a human's direct message to this session - one sent from the Agents page, which names no issue - pass that message's bare id as " + "in_reply_to and no issue; the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A frame reading expects_reply: optional or " + "required carries a reply_with command: answer with it, on that issue. One reading false carries none and asks for no answer; where " + "you answer one anyway, its card names what to answer. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
15443
15443
  arguments: (z) => ({
15444
15444
  issue: z.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
15445
15445
  body: z.string().describe(`Update text. ${BODY_BLOCK_RULE}`),
@@ -16766,6 +16766,7 @@ var DISPATCH_TEXT_REFERENCES = [
16766
16766
  ];
16767
16767
  // ../contracts/src/envelope.ts
16768
16768
  var isSubject = (value) => typeof value === "string" && value.length > 0;
16769
+ var EXPECTS_REPLY_VALUES = [false, "optional", "required"];
16769
16770
  var EnvelopeSchema = object({
16770
16771
  event_id: string2().min(1),
16771
16772
  source: _enum2([
@@ -16799,7 +16800,7 @@ var EnvelopeSchema = object({
16799
16800
  in_reply_to: string2().min(1).optional(),
16800
16801
  supersedes: string2().min(1).optional(),
16801
16802
  urgency: _enum2(["low", "med", "high", "blocking"]).optional(),
16802
- expects_reply: _enum2(["none", "optional", "required"]).optional()
16803
+ expects_reply: literal(EXPECTS_REPLY_VALUES).optional()
16803
16804
  });
16804
16805
  var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
16805
16806
  var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
@@ -16820,6 +16821,7 @@ function dedupeKeyNamesItsEvent(envelope) {
16820
16821
  }
16821
16822
  // ../contracts/src/envoy-session.ts
16822
16823
  var SESSION_HEARTBEAT_MS = 90000;
16824
+ var SESSION_LIVE_WINDOW_MS = 300000;
16823
16825
  var LISTENER_CALL_TIMEOUT_MS = 5000;
16824
16826
  var LISTENER_CALL_RETRY_DELAY_MS = 250;
16825
16827
  var LISTENER_CALL_ATTEMPTS = 2;
@@ -16937,6 +16939,12 @@ var SLACK_POST_TEXT_MAX = 4000;
16937
16939
  function overCapMessage(length, max) {
16938
16940
  return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
16939
16941
  }
16942
+ function literalOfSet(api, values) {
16943
+ const node = api.literal?.(values);
16944
+ if (node === undefined || typeof node.safeParse !== "function")
16945
+ return;
16946
+ return values.every((value) => node.safeParse(value).success) ? node : undefined;
16947
+ }
16940
16948
  function zodSchemaApi(zod) {
16941
16949
  const api = zod;
16942
16950
  return {
@@ -16965,6 +16973,7 @@ function zodSchemaApi(zod) {
16965
16973
  },
16966
16974
  boolean: () => api.boolean(),
16967
16975
  enum: (values) => api.enum(values),
16976
+ literal: (values) => literalOfSet(api, values) ?? api.unknown(),
16968
16977
  unknown: () => api.unknown(),
16969
16978
  array: (item, opts = {}) => {
16970
16979
  let schema = api.array(item);
@@ -19329,6 +19338,7 @@ var InboundSenderSchema = object({
19329
19338
  title: string2().optional(),
19330
19339
  roles: array(string2()).optional()
19331
19340
  });
19341
+ var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
19332
19342
  var InboundEnvelopeSchema = object({
19333
19343
  event_id: string2().optional(),
19334
19344
  source: string2(),
@@ -19345,7 +19355,7 @@ var InboundEnvelopeSchema = object({
19345
19355
  in_reply_to: string2().optional(),
19346
19356
  supersedes: string2().optional(),
19347
19357
  urgency: string2().optional(),
19348
- expects_reply: string2().optional()
19358
+ expects_reply: InboundExpectsReplySchema.optional()
19349
19359
  }).passthrough();
19350
19360
  function tolerantShape(shape) {
19351
19361
  return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
@@ -20052,7 +20062,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
20052
20062
  break;
20053
20063
  }
20054
20064
  const role = envelope.sender?.roles?.[0];
20055
- const reply = dispatchReply ?? replyWith(envelope);
20065
+ const wantsNoReply = envelope.expects_reply === false;
20066
+ const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
20056
20067
  const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
20057
20068
  const unrecognised = [
20058
20069
  ...parseIssues,
@@ -20071,7 +20082,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
20071
20082
  ...inReplyTo === undefined ? {} : { re: inReplyTo },
20072
20083
  ...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
20073
20084
  ...reply === undefined ? {} : { reply_with: reply },
20074
- ...role === undefined ? {} : {
20085
+ ...role === undefined || wantsNoReply ? {} : {
20075
20086
  reply_role: {
20076
20087
  tool: "envoy_publish",
20077
20088
  args: { topic: `notifications.role.${role}`, message: "..." }
@@ -27826,7 +27837,9 @@ var RoleWireSchema = object({
27826
27837
  host: string2().optional(),
27827
27838
  last_seen: number2().int(),
27828
27839
  state: WireStateSchema,
27829
- holder_state: SessionStateSchema.optional()
27840
+ holder_state: SessionStateSchema.optional(),
27841
+ client_instance_id: string2().optional(),
27842
+ handed_back: boolean2().optional()
27830
27843
  });
27831
27844
  var RoleHeldWireSchema = object({
27832
27845
  error: string2(),
@@ -27905,6 +27918,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
27905
27918
  function holderStateOf(role) {
27906
27919
  return role.holder_state ?? role.state;
27907
27920
  }
27921
+ function namedProcess(id) {
27922
+ return id === "" ? undefined : id;
27923
+ }
27924
+ function roleHeldBy(info, sessionID, clientInstanceID) {
27925
+ if (info === undefined || info.holder !== sessionID || info.handed_back === true)
27926
+ return false;
27927
+ const holding = namedProcess(info.client_instance_id);
27928
+ return holding === undefined || holding === namedProcess(clientInstanceID);
27929
+ }
27930
+ function mayActOnRoleRecord(writer, clientInstanceID, holdsRole) {
27931
+ const wrote = namedProcess(writer);
27932
+ if (wrote === undefined || wrote === namedProcess(clientInstanceID))
27933
+ return true;
27934
+ return !holdsRole;
27935
+ }
27908
27936
  function newClientInstanceID() {
27909
27937
  return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
27910
27938
  }
@@ -28100,7 +28128,8 @@ function createEnvoyClient(config) {
28100
28128
  unsubscribe: async (input) => {
28101
28129
  await post("/v1/interests/unsubscribe", {
28102
28130
  session_id: input.sessionID,
28103
- topics: expandSubscriptionTopics(input.topics)
28131
+ topics: expandSubscriptionTopics(input.topics),
28132
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
28104
28133
  }, { actingSession: input.sessionID });
28105
28134
  },
28106
28135
  getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
@@ -28170,7 +28199,8 @@ function createEnvoyClient(config) {
28170
28199
  session_id: input.sessionID,
28171
28200
  role: input.role,
28172
28201
  ...soft ? { soft: true } : {},
28173
- ...previous === "" ? {} : { previous_session_id: previous }
28202
+ ...previous === "" ? {} : { previous_session_id: previous },
28203
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
28174
28204
  };
28175
28205
  let raw;
28176
28206
  try {