@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.omp-plugin/plugin.json +1 -1
- package/AGENTS.md +42 -9
- package/README.md +24 -8
- package/dist/dispatch.js +38 -8
- package/dist/envoy-channel.js +137 -46
- package/dist/session-hook.js +21 -6
- package/legion/agents/design-outsider.md +44 -0
- package/legion/skills/legion-architect/SKILL.md +2 -1
- package/legion/skills/legion-architect/references/wake-routing.md +1 -1
- package/legion/skills/legion-worker/references/findings.md +3 -3
- package/legion/skills/legion-worker/references/merge-gate.md +1 -1
- package/package.json +1 -1
- package/scripts/build.ts +4 -1
- package/scripts/smoke-clear-rebind.sh +9 -4
- package/skills/envoy/SKILL.md +4 -4
- package/skills/envoy/references/deliveries.md +3 -3
- package/src/envoy-channel-server.ts +151 -49
- package/src/legion-claim.ts +103 -4
- package/src/session-identity.ts +32 -12
package/.omp-plugin/plugin.json
CHANGED
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
|
|
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)
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
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.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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 {
|