@sjawhar/claude-envoy 1.15.0 → 1.17.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.15.0"
5
+ "version": "1.17.0"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-envoy",
3
- "version": "1.15.0",
3
+ "version": "1.17.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
package/dist/dispatch.js CHANGED
@@ -16821,6 +16821,7 @@ function dedupeKeyNamesItsEvent(envelope) {
16821
16821
  }
16822
16822
  // ../contracts/src/envoy-session.ts
16823
16823
  var SESSION_HEARTBEAT_MS = 90000;
16824
+ var SESSION_LIVE_WINDOW_MS = 300000;
16824
16825
  var LISTENER_CALL_TIMEOUT_MS = 5000;
16825
16826
  var LISTENER_CALL_RETRY_DELAY_MS = 250;
16826
16827
  var LISTENER_CALL_ATTEMPTS = 2;
@@ -27836,7 +27837,9 @@ var RoleWireSchema = object({
27836
27837
  host: string2().optional(),
27837
27838
  last_seen: number2().int(),
27838
27839
  state: WireStateSchema,
27839
- holder_state: SessionStateSchema.optional()
27840
+ holder_state: SessionStateSchema.optional(),
27841
+ client_instance_id: string2().optional(),
27842
+ handed_back: boolean2().optional()
27840
27843
  });
27841
27844
  var RoleHeldWireSchema = object({
27842
27845
  error: string2(),
@@ -27915,6 +27918,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
27915
27918
  function holderStateOf(role) {
27916
27919
  return role.holder_state ?? role.state;
27917
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
+ }
27918
27936
  function newClientInstanceID() {
27919
27937
  return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
27920
27938
  }
@@ -28110,7 +28128,8 @@ function createEnvoyClient(config) {
28110
28128
  unsubscribe: async (input) => {
28111
28129
  await post("/v1/interests/unsubscribe", {
28112
28130
  session_id: input.sessionID,
28113
- topics: expandSubscriptionTopics(input.topics)
28131
+ topics: expandSubscriptionTopics(input.topics),
28132
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
28114
28133
  }, { actingSession: input.sessionID });
28115
28134
  },
28116
28135
  getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
@@ -28180,7 +28199,8 @@ function createEnvoyClient(config) {
28180
28199
  session_id: input.sessionID,
28181
28200
  role: input.role,
28182
28201
  ...soft ? { soft: true } : {},
28183
- ...previous === "" ? {} : { previous_session_id: previous }
28202
+ ...previous === "" ? {} : { previous_session_id: previous },
28203
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
28184
28204
  };
28185
28205
  let raw;
28186
28206
  try {
@@ -38470,6 +38470,7 @@ function dedupeKeyNamesItsEvent(envelope) {
38470
38470
  }
38471
38471
  // ../contracts/src/envoy-session.ts
38472
38472
  var SESSION_HEARTBEAT_MS = 90000;
38473
+ var SESSION_LIVE_WINDOW_MS = 300000;
38473
38474
  var LISTENER_CALL_TIMEOUT_MS = 5000;
38474
38475
  var LISTENER_CALL_RETRY_DELAY_MS = 250;
38475
38476
  var LISTENER_CALL_ATTEMPTS = 2;
@@ -44814,7 +44815,9 @@ var RoleWireSchema = object({
44814
44815
  host: string2().optional(),
44815
44816
  last_seen: number2().int(),
44816
44817
  state: WireStateSchema,
44817
- holder_state: SessionStateSchema.optional()
44818
+ holder_state: SessionStateSchema.optional(),
44819
+ client_instance_id: string2().optional(),
44820
+ handed_back: boolean2().optional()
44818
44821
  });
44819
44822
  var RoleHeldWireSchema = object({
44820
44823
  error: string2(),
@@ -44893,6 +44896,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
44893
44896
  function holderStateOf(role) {
44894
44897
  return role.holder_state ?? role.state;
44895
44898
  }
44899
+ function namedProcess(id) {
44900
+ return id === "" ? undefined : id;
44901
+ }
44902
+ function roleHeldBy(info, sessionID, clientInstanceID) {
44903
+ if (info === undefined || info.holder !== sessionID || info.handed_back === true)
44904
+ return false;
44905
+ const holding = namedProcess(info.client_instance_id);
44906
+ return holding === undefined || holding === namedProcess(clientInstanceID);
44907
+ }
44908
+ function mayActOnRoleRecord(writer, clientInstanceID, holdsRole) {
44909
+ const wrote = namedProcess(writer);
44910
+ if (wrote === undefined || wrote === namedProcess(clientInstanceID))
44911
+ return true;
44912
+ return !holdsRole;
44913
+ }
44896
44914
  function newClientInstanceID() {
44897
44915
  return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
44898
44916
  }
@@ -45088,7 +45106,8 @@ function createEnvoyClient(config) {
45088
45106
  unsubscribe: async (input) => {
45089
45107
  await post("/v1/interests/unsubscribe", {
45090
45108
  session_id: input.sessionID,
45091
- topics: expandSubscriptionTopics(input.topics)
45109
+ topics: expandSubscriptionTopics(input.topics),
45110
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
45092
45111
  }, { actingSession: input.sessionID });
45093
45112
  },
45094
45113
  getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
@@ -45158,7 +45177,8 @@ function createEnvoyClient(config) {
45158
45177
  session_id: input.sessionID,
45159
45178
  role: input.role,
45160
45179
  ...soft ? { soft: true } : {},
45161
- ...previous === "" ? {} : { previous_session_id: previous }
45180
+ ...previous === "" ? {} : { previous_session_id: previous },
45181
+ ...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
45162
45182
  };
45163
45183
  let raw;
45164
45184
  try {
@@ -55191,7 +55211,7 @@ var import_nats2 = __toESM(require_mod4(), 1);
55191
55211
 
55192
55212
  // package.json
55193
55213
  var name = "@sjawhar/claude-envoy";
55194
- var version2 = "1.15.0";
55214
+ var version2 = "1.17.0";
55195
55215
  var legion = {
55196
55216
  daemonApiVersion: 56
55197
55217
  };
@@ -55880,7 +55900,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
55880
55900
  }
55881
55901
 
55882
55902
  // src/session-identity.ts
55883
- var PersistedRole = object({ session_id: string2().min(1), role: string2().min(1) });
55903
+ var PersistedRole = object({
55904
+ session_id: string2().min(1),
55905
+ role: string2().min(1),
55906
+ client_instance_id: string2().min(1).optional()
55907
+ });
55884
55908
 
55885
55909
  class SessionIdentity {
55886
55910
  directory;
@@ -56123,9 +56147,13 @@ async function waitForSavedTranscript(transcript, signal, pollMs = 100) {
56123
56147
  function isFile(file) {
56124
56148
  return stat2(file).then((info) => info.isFile(), () => false);
56125
56149
  }
56150
+ var ROLE_CLAIM_RETRY_MS = 1e4;
56151
+ var ROLE_CLAIM_WAIT_MS = SESSION_LIVE_WINDOW_MS + ROLE_CLAIM_RETRY_MS;
56126
56152
  function startLegionClaim(deps) {
56127
56153
  let capability;
56128
56154
  const { sessionID } = deps;
56155
+ const roleWaitMs = deps.roleWaitMs ?? ROLE_CLAIM_WAIT_MS;
56156
+ const roleRetryMs = deps.roleRetryMs ?? ROLE_CLAIM_RETRY_MS;
56129
56157
  const boot = async () => {
56130
56158
  const transcript = await waitForTranscript(deps.pane.configDir, sessionID, deps.signal, deps.pollMs);
56131
56159
  if (deps.pane.launcherSaves) {
@@ -56144,16 +56172,39 @@ function startLegionClaim(deps) {
56144
56172
  capability = registered;
56145
56173
  },
56146
56174
  claimRole: async (role) => {
56147
- const claimed = await deps.client.setRole({ sessionID, role, soft: true });
56148
- if (!claimed.claimed) {
56149
- throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`);
56175
+ const deadline = Date.now() + roleWaitMs;
56176
+ let waited = false;
56177
+ for (;; ) {
56178
+ const claimed = await deps.client.setRole({
56179
+ sessionID,
56180
+ role,
56181
+ soft: true,
56182
+ clientInstanceID: deps.clientInstanceID
56183
+ });
56184
+ if (claimed.claimed)
56185
+ break;
56186
+ if (claimed.holder !== sessionID) {
56187
+ throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`);
56188
+ }
56189
+ const left = deadline - Date.now();
56190
+ if (left <= 0) {
56191
+ throw new Error(`Envoy role ${role} is still claimed under this session by another process after ${roleWaitMs} ms of asking`);
56192
+ }
56193
+ if (!waited) {
56194
+ waited = true;
56195
+ deps.log(`Envoy role ${role} is claimed under this session by the process this pane replaced; asking again every ${roleRetryMs} ms for up to ${roleWaitMs} ms, until nobody has answered for that claim inside the listener's live window`);
56196
+ }
56197
+ await delay(Math.min(roleRetryMs, left), undefined, { signal: deps.signal });
56150
56198
  }
56151
56199
  await deps.session.rememberRole(role);
56152
56200
  },
56153
56201
  onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
56154
56202
  log: deps.log,
56155
56203
  stopping: "stopping the pane so the daemon launches it again",
56156
- stop: deps.stop
56204
+ stop: () => {
56205
+ if (!deps.signal.aborted)
56206
+ deps.stop();
56207
+ }
56157
56208
  });
56158
56209
  };
56159
56210
  const booted = boot().catch((error) => {
@@ -57861,7 +57912,11 @@ async function startChannelSession(options) {
57861
57912
  relay.delivery.setFollowed(registeredTopics());
57862
57913
  return {
57863
57914
  dropping,
57864
- unsubscribing: serialized(() => options.client.unsubscribe({ sessionID: identity.id, topics }))
57915
+ unsubscribing: serialized(() => options.client.unsubscribe({
57916
+ sessionID: identity.id,
57917
+ topics,
57918
+ clientInstanceID: options.clientInstanceID
57919
+ }))
57865
57920
  };
57866
57921
  }
57867
57922
  function registeredTopics() {
@@ -57914,27 +57969,40 @@ async function startChannelSession(options) {
57914
57969
  const result = await registering;
57915
57970
  takeDeliveryAnswer(sessionID, topics, asking, result);
57916
57971
  };
57917
- const restoreRole = async (previousSessionID = identity.id) => {
57972
+ const writeRoleRecord = (file, role) => writePersistedRole(file, {
57973
+ session_id: identity.id,
57974
+ role,
57975
+ client_instance_id: options.clientInstanceID
57976
+ });
57977
+ let roleTransferFrom;
57978
+ const restoreRole = async (previousSessionID) => {
57918
57979
  const previousFile = roleStateFile(options.stateDirectory, previousSessionID);
57919
57980
  const persisted = await readPersistedRole(previousFile);
57920
57981
  if (persisted === undefined)
57921
- return;
57982
+ return "settled";
57983
+ if (!mayActOnRoleRecord(persisted.client_instance_id, options.clientInstanceID, heldRole !== undefined)) {
57984
+ return "settled";
57985
+ }
57922
57986
  const claim = await options.client.setRole({
57923
57987
  sessionID: identity.id,
57924
57988
  role: persisted.role,
57925
57989
  soft: true,
57926
- previousSessionID: persisted.session_id
57990
+ previousSessionID: persisted.session_id,
57991
+ clientInstanceID: options.clientInstanceID
57927
57992
  });
57928
57993
  if (!claim.claimed) {
57994
+ if (claim.holder === identity.id)
57995
+ return "pending";
57929
57996
  if (claim.holder !== persisted.session_id)
57930
57997
  await rm2(previousFile, { force: true });
57931
- return;
57998
+ return "settled";
57932
57999
  }
57933
58000
  heldRole = persisted.role;
57934
58001
  const currentFile = roleStateFile(options.stateDirectory, identity.id);
57935
58002
  if (previousFile !== currentFile)
57936
58003
  await rm2(previousFile, { force: true });
57937
- await writePersistedRole(currentFile, { session_id: identity.id, role: persisted.role });
58004
+ await writeRoleRecord(currentFile, persisted.role);
58005
+ return "settled";
57938
58006
  };
57939
58007
  let regained;
57940
58008
  const fireRegained = (role, reason) => {
@@ -57964,8 +58032,8 @@ async function startChannelSession(options) {
57964
58032
  if (heldRole === undefined)
57965
58033
  return;
57966
58034
  const role = heldRole;
57967
- const holder = await options.client.getRole(role).then((info) => info.holder, undefinedOnNotFound);
57968
- if (holder === identity.id) {
58035
+ const holding = await options.client.getRole(role).catch(undefinedOnNotFound);
58036
+ if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
57969
58037
  await refreshRoleRecord();
57970
58038
  if (afterOutage)
57971
58039
  fireRegained(role, "reregistered");
@@ -57974,24 +58042,27 @@ async function startChannelSession(options) {
57974
58042
  const result = await options.client.setRole({
57975
58043
  sessionID: identity.id,
57976
58044
  role,
57977
- soft: true
58045
+ soft: true,
58046
+ clientInstanceID: options.clientInstanceID
57978
58047
  });
57979
58048
  if (!result.claimed) {
57980
- heldRole = undefined;
57981
- await rm2(roleStateFile(options.stateDirectory, identity.id), { force: true });
58049
+ if (result.holder !== identity.id) {
58050
+ heldRole = undefined;
58051
+ await rm2(roleStateFile(options.stateDirectory, identity.id), { force: true });
58052
+ return;
58053
+ }
58054
+ await refreshRoleRecord();
57982
58055
  return;
57983
58056
  }
57984
58057
  fireRegained(role, "reclaimed");
57985
58058
  };
57986
- let roleTransferFrom;
57987
58059
  let roleTransferFailureReported = false;
57988
58060
  const transferRole = async () => {
57989
58061
  const from = roleTransferFrom;
57990
58062
  if (from === undefined)
57991
58063
  return;
57992
58064
  try {
57993
- await restoreRole(from);
57994
- roleTransferFrom = undefined;
58065
+ roleTransferFrom = await restoreRole(from) === "pending" ? from : undefined;
57995
58066
  roleTransferFailureReported = false;
57996
58067
  } catch (error) {
57997
58068
  if (!roleTransferFailureReported) {
@@ -58056,7 +58127,11 @@ async function startChannelSession(options) {
58056
58127
  const drifted = registry.topics.filter((topic) => topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic));
58057
58128
  if (drifted.length === 0)
58058
58129
  return;
58059
- await options.client.unsubscribe({ sessionID: identity.id, topics: drifted });
58130
+ await options.client.unsubscribe({
58131
+ sessionID: identity.id,
58132
+ topics: drifted,
58133
+ clientInstanceID: options.clientInstanceID
58134
+ });
58060
58135
  };
58061
58136
  let heartbeatInFlight = false;
58062
58137
  let heartbeatRequested = false;
@@ -58145,17 +58220,19 @@ async function startChannelSession(options) {
58145
58220
  await options.connection.flush();
58146
58221
  await serialized(async () => {
58147
58222
  await register();
58148
- await restoreRole().catch((error) => {
58223
+ try {
58224
+ roleTransferFrom = await restoreRole(identity.id) === "pending" ? identity.id : undefined;
58225
+ } catch (error) {
58149
58226
  if (error instanceof UnparseableRoleRecordError) {
58150
58227
  process.stderr.write(`envoy-channel: ${messageFor(error)}; the session starts without a role
58151
58228
  `);
58152
- return;
58153
- }
58154
- roleTransferFrom = identity.id;
58155
- roleTransferFailureReported = true;
58156
- process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
58229
+ } else {
58230
+ roleTransferFrom = identity.id;
58231
+ roleTransferFailureReported = true;
58232
+ process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
58157
58233
  `);
58158
- });
58234
+ }
58235
+ }
58159
58236
  });
58160
58237
  triggerReplay();
58161
58238
  for (const prune of [
@@ -58230,10 +58307,7 @@ async function startChannelSession(options) {
58230
58307
  },
58231
58308
  async rememberRole(role) {
58232
58309
  heldRole = role;
58233
- await writePersistedRole(roleStateFile(options.stateDirectory, identity.id), {
58234
- session_id: identity.id,
58235
- role
58236
- });
58310
+ await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role);
58237
58311
  },
58238
58312
  async shutdown() {
58239
58313
  if (shuttingDown)
@@ -58332,7 +58406,11 @@ async function executeEnvoyTool(runtime, name, input) {
58332
58406
  }
58333
58407
  case EnvoyToolOperation.setRole: {
58334
58408
  const args = parseArguments(spec, input);
58335
- const result = await runtime.client.setRole({ sessionID: identity.id, role: args.role });
58409
+ const result = await runtime.client.setRole({
58410
+ sessionID: identity.id,
58411
+ role: args.role,
58412
+ clientInstanceID: runtime.clientInstanceID
58413
+ });
58336
58414
  if (!result.claimed)
58337
58415
  throw new Error(`role ${args.role} is held by ${result.holder}`);
58338
58416
  await runtime.session.rememberRole(args.role);
@@ -58546,13 +58624,14 @@ async function runEnvoyChannelServer() {
58546
58624
  ...configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}
58547
58625
  });
58548
58626
  }
58549
- runtime = { identity, client, session };
58627
+ runtime = { identity, client, session, clientInstanceID };
58550
58628
  if (pane !== undefined && daemon !== undefined && !stopping) {
58551
58629
  claim = startLegionClaim({
58552
58630
  pane,
58553
58631
  sessionID: identity.id,
58554
58632
  daemon,
58555
58633
  client,
58634
+ clientInstanceID,
58556
58635
  session,
58557
58636
  signal: booting.signal,
58558
58637
  log: logLegion,
@@ -15820,6 +15820,7 @@ function dedupeKeyNamesItsEvent(envelope) {
15820
15820
  }
15821
15821
  // ../contracts/src/envoy-session.ts
15822
15822
  var SESSION_HEARTBEAT_MS = 90000;
15823
+ var SESSION_LIVE_WINDOW_MS = 300000;
15823
15824
  var LISTENER_CALL_TIMEOUT_MS = 5000;
15824
15825
  var LISTENER_CALL_RETRY_DELAY_MS = 250;
15825
15826
  var LISTENER_CALL_ATTEMPTS = 2;
@@ -26872,7 +26873,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
26872
26873
  }
26873
26874
 
26874
26875
  // src/session-identity.ts
26875
- var PersistedRole = object({ session_id: string2().min(1), role: string2().min(1) });
26876
+ var PersistedRole = object({
26877
+ session_id: string2().min(1),
26878
+ role: string2().min(1),
26879
+ client_instance_id: string2().min(1).optional()
26880
+ });
26876
26881
 
26877
26882
  class SessionIdentity {
26878
26883
  directory;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/claude-envoy",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Claude Code channel plugin for Legion's Envoy bus",
6
6
  "type": "module",
@@ -49,11 +49,16 @@ cleanup() {
49
49
  sleep 0.2
50
50
  done
51
51
  done
52
- # A deregistered session keeps its interest entry until the listener reaps it; an
53
- # unsubscribe with no topics removes the entry now.
52
+ # A deregistered session keeps its interest entry until the listener reaps it; the
53
+ # administrator's route removes the entry now. The empty-topics unsubscribe no longer serves a
54
+ # cleanup that names no process: it frees the role claims of the client instance its body
55
+ # states, and this script can state none — each channel server mints its own, no listener route
56
+ # reports it, and both processes have exited by the time this runs. DELETE
57
+ # /v1/interests/<session_id> removes a session's interests whoever holds them, which is what a
58
+ # cleanup wants.
54
59
  for id in "${session_ids[@]}"; do
55
- curl --connect-timeout 2 --max-time 5 -fsS -o /dev/null -X POST -H 'Content-Type: application/json' \
56
- "${envoy_url}/v1/interests/unsubscribe" --data "{\"session_id\":\"${id}\",\"topics\":[]}" || true
60
+ curl --connect-timeout 2 --max-time 5 -fsS -o /dev/null -X DELETE \
61
+ "${envoy_url}/v1/interests/${id}" || true
57
62
  done
58
63
  # Claude Code keeps a transcript directory per project path, named after it.
59
64
  rm -rf "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/${workdir//[^A-Za-z0-9]/-}" "$workdir"
@@ -71,8 +71,10 @@ import {
71
71
  type EnvoyClient,
72
72
  expandSubscriptionTopics,
73
73
  holderStateOf,
74
+ mayActOnRoleRecord,
74
75
  mergeInterestSources,
75
76
  newClientInstanceID,
77
+ roleHeldBy,
76
78
  type SubscribeResult,
77
79
  } from "@legion/envoy-client/transport"
78
80
  import { createLegionDaemonClient } from "@legion/pi-shared/daemon-client"
@@ -1031,8 +1033,15 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1031
1033
  if (relay?.sessionID === identity.id) relay.delivery.setFollowed(registeredTopics())
1032
1034
  return {
1033
1035
  dropping,
1036
+ // The release names this process, so the listener frees the role claim of this process
1037
+ // alone where one of these topics is a role's: a second process that resumed this session
1038
+ // id keeps the role it holds.
1034
1039
  unsubscribing: serialized(() =>
1035
- options.client.unsubscribe({ sessionID: identity.id, topics }),
1040
+ options.client.unsubscribe({
1041
+ sessionID: identity.id,
1042
+ topics,
1043
+ clientInstanceID: options.clientInstanceID,
1044
+ }),
1036
1045
  ),
1037
1046
  }
1038
1047
  }
@@ -1110,6 +1119,27 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1110
1119
  takeDeliveryAnswer(sessionID, topics, asking, result)
1111
1120
  }
1112
1121
 
1122
+ /**
1123
+ * Writes `file` as this session's role record, naming this process as its writer, so a second
1124
+ * `claude` reading the same state directory — one that resumed this session id — tells its own
1125
+ * record from this one's. The destination is the caller's, because `restoreRole` resolves it
1126
+ * before it removes the predecessor's file while the record's `session_id` is read after.
1127
+ */
1128
+ const writeRoleRecord = (file: string, role: string): Promise<void> =>
1129
+ writePersistedRole(file, {
1130
+ session_id: identity.id,
1131
+ role,
1132
+ client_instance_id: options.clientInstanceID,
1133
+ })
1134
+
1135
+ /**
1136
+ * The id whose role record is still to be taken back, read by `transferRole` on every
1137
+ * heartbeat: a handoff has moved away from it, a restore of it failed, or a restore of it was
1138
+ * refused by a standing claim under this session's own id. Written by each caller of
1139
+ * `restoreRole` from what that call answered, and by `adoptHandoff` for the id it left.
1140
+ */
1141
+ let roleTransferFrom: string | undefined
1142
+
1113
1143
  /**
1114
1144
  * Take back the role persisted for `previousSessionID` (this id on `--resume`,
1115
1145
  * the pre-`/clear` id on a handoff) with a soft claim naming that predecessor. The listener
@@ -1117,30 +1147,64 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1117
1147
  * carries this process's own client instance id, or when either side carries none; a
1118
1148
  * predecessor still live under another client instance keeps the role.
1119
1149
  *
1120
- * Which is why a refusal naming the predecessor itself keeps the record: it belongs to that
1121
- * live holder, another process sharing this state directory, and deleting it would cost that
1122
- * process its only way back to the role. A refusal naming any other session says the
1150
+ * Answers `"pending"` when the role is still to be taken back from that id and only time
1151
+ * decides it, `"settled"` when nothing more is to be asked of that record. The caller keeps
1152
+ * the retry: a restore writes none of its own.
1153
+ *
1154
+ * A record another process wrote is pressed only while this process holds no role
1155
+ * (`mayActOnRoleRecord`, the one rule both clients read). One state directory is read by two
1156
+ * processes whenever a second `claude` resumes a live session's id, and a record is keyed by
1157
+ * session id alone, so the record found here can be the other process's claim rather than a
1158
+ * predecessor of this one's (dispatch://INFRA-783). With no role of its own this process has
1159
+ * nothing to lose by asking, and the listener decides between the two; holding one, it leaves
1160
+ * that record where its writer can still use it. A record written before the writer was named
1161
+ * is nobody's in particular and is pressed as it always was.
1162
+ *
1163
+ * A refusal naming this session's own id is the standing claim of the process this one
1164
+ * replaced — the `claude` that `--resume` continues, or the channel server this one restarted
1165
+ * — which renewed that claim at its last heartbeat and so still stands inside the listener's
1166
+ * live window. The listener grants it the moment nobody has answered for it inside that
1167
+ * window, so that refusal is the `"pending"` answer and every heartbeat asks again until it
1168
+ * is granted: one claim at boot and no more would cost this process its role for the life of
1169
+ * the process, on every `--resume` and every restart inside the window. The record stays
1170
+ * where it is, because the pending claim is read back out of it.
1171
+ *
1172
+ * Which is why a refusal naming the predecessor itself keeps the record too: it belongs to
1173
+ * that live holder, another process sharing this state directory, and deleting it would cost
1174
+ * that process its only way back to the role. A refusal naming any other session says the
1123
1175
  * predecessor demonstrably no longer holds it, so its record goes — the one path that removes
1124
1176
  * a role record by hand.
1125
1177
  */
1126
- const restoreRole = async (previousSessionID = identity.id): Promise<void> => {
1178
+ const restoreRole = async (previousSessionID: string): Promise<"settled" | "pending"> => {
1127
1179
  const previousFile = roleStateFile(options.stateDirectory, previousSessionID)
1128
1180
  const persisted = await readPersistedRole(previousFile)
1129
- if (persisted === undefined) return
1181
+ if (persisted === undefined) return "settled"
1182
+ if (
1183
+ !mayActOnRoleRecord(
1184
+ persisted.client_instance_id,
1185
+ options.clientInstanceID,
1186
+ heldRole !== undefined,
1187
+ )
1188
+ ) {
1189
+ return "settled"
1190
+ }
1130
1191
  const claim = await options.client.setRole({
1131
1192
  sessionID: identity.id,
1132
1193
  role: persisted.role,
1133
1194
  soft: true,
1134
1195
  previousSessionID: persisted.session_id,
1196
+ clientInstanceID: options.clientInstanceID,
1135
1197
  })
1136
1198
  if (!claim.claimed) {
1199
+ if (claim.holder === identity.id) return "pending"
1137
1200
  if (claim.holder !== persisted.session_id) await rm(previousFile, { force: true })
1138
- return
1201
+ return "settled"
1139
1202
  }
1140
1203
  heldRole = persisted.role
1141
1204
  const currentFile = roleStateFile(options.stateDirectory, identity.id)
1142
1205
  if (previousFile !== currentFile) await rm(previousFile, { force: true })
1143
- await writePersistedRole(currentFile, { session_id: identity.id, role: persisted.role })
1206
+ await writeRoleRecord(currentFile, persisted.role)
1207
+ return "settled"
1144
1208
  }
1145
1209
 
1146
1210
  let regained: RoleRegainHook | undefined
@@ -1187,15 +1251,29 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1187
1251
  }
1188
1252
  }
1189
1253
 
1190
- /** `afterOutage`: the last tick's registration failed, so a holder the listener still names
1191
- * may have been answered "no holder" meanwhile. */
1254
+ /**
1255
+ * Re-establishes this session as the live holder of the role it holds, on every heartbeat.
1256
+ * `afterOutage`: the last tick's registration failed, so a holder the listener still names may
1257
+ * have been answered "no holder" meanwhile.
1258
+ *
1259
+ * The lookup confirms the role only when it names this session *and* this process
1260
+ * (`roleHeldBy`, the one rule both clients read): a claim standing under this very session id
1261
+ * that another process made is not this one's to renew, and reading it as confirmation is how
1262
+ * a lane kept heartbeating while a continuation's claim held the role and then lapsed
1263
+ * (dispatch://INFRA-783). Anything else is claimed again, soft.
1264
+ *
1265
+ * A refusal naming a different session ends the role here: that session holds it, and this
1266
+ * one's record is the only thing that would claim otherwise, so it goes. A refusal naming this
1267
+ * session is the standing claim of another process under this id, which the listener grants
1268
+ * the moment that process stops renewing it; the role and its record stay, and the next
1269
+ * heartbeat asks again. The record's mtime is refreshed as it is on a confirmed holder, so the
1270
+ * age sweep never expires a record this process is still pressing a claim for.
1271
+ */
1192
1272
  const reassertRole = async (afterOutage: boolean): Promise<void> => {
1193
1273
  if (heldRole === undefined) return
1194
1274
  const role = heldRole
1195
- const holder = await options.client
1196
- .getRole(role)
1197
- .then((info) => info.holder, undefinedOnNotFound)
1198
- if (holder === identity.id) {
1275
+ const holding = await options.client.getRole(role).catch(undefinedOnNotFound)
1276
+ if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
1199
1277
  await refreshRoleRecord()
1200
1278
  if (afterOutage) fireRegained(role, "reregistered")
1201
1279
  return
@@ -1204,40 +1282,41 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1204
1282
  sessionID: identity.id,
1205
1283
  role,
1206
1284
  soft: true,
1285
+ clientInstanceID: options.clientInstanceID,
1207
1286
  })
1208
1287
  if (!result.claimed) {
1209
- heldRole = undefined
1210
- await rm(roleStateFile(options.stateDirectory, identity.id), { force: true })
1288
+ if (result.holder !== identity.id) {
1289
+ heldRole = undefined
1290
+ await rm(roleStateFile(options.stateDirectory, identity.id), { force: true })
1291
+ return
1292
+ }
1293
+ await refreshRoleRecord()
1211
1294
  return
1212
1295
  }
1213
1296
  fireRegained(role, "reclaimed")
1214
1297
  }
1215
1298
 
1216
- /**
1217
- * The id a handoff moved away from, until its role has been taken back; a
1218
- * handoff whose registration failed leaves it for the next heartbeat.
1219
- */
1220
- let roleTransferFrom: string | undefined
1221
1299
  /** Whether a failure to take back the queued role has been reported since the last success. */
1222
1300
  let roleTransferFailureReported = false
1223
1301
  /**
1224
1302
  * Best-effort, unlike `syncRegisteredInterests` behind it on the heartbeat. A failed transfer
1225
- * keeps its own retry, `roleTransferFrom`, which clears only once `restoreRole` resolves; a
1226
- * throw would add nothing to that and would end the tick before the interests reconciliation,
1227
- * on every tick for as long as the failure lasts — a resumed session would never adopt its
1228
- * subscriptions and a registry-side unsubscribe that failed would never be cleared. The
1229
- * session works without its role meanwhile; it does not work without its interests, whose read
1230
- * has no queue of its own and needs the failed tick, its report and its retry.
1303
+ * keeps its own retry, `roleTransferFrom`, which this clears only once `restoreRole` answers
1304
+ * that the record is settled; a throw would add nothing to that and would end the tick
1305
+ * before the interests reconciliation, on every tick for as long as the failure lasts — a
1306
+ * resumed session would never adopt its subscriptions and a registry-side unsubscribe that
1307
+ * failed would never be cleared. The session works without its role meanwhile; it does not
1308
+ * work without its interests, whose read has no queue of its own and needs the failed tick,
1309
+ * its report and its retry.
1231
1310
  */
1232
1311
  const transferRole = async (): Promise<void> => {
1233
1312
  const from = roleTransferFrom
1234
1313
  if (from === undefined) return
1235
1314
  try {
1236
- await restoreRole(from)
1237
- roleTransferFrom = undefined
1315
+ roleTransferFrom = (await restoreRole(from)) === "pending" ? from : undefined
1238
1316
  roleTransferFailureReported = false
1239
1317
  } catch (error) {
1240
- // Said once per failure run: every heartbeat retries it.
1318
+ // `roleTransferFrom` is untouched, so the next heartbeat asks again.
1319
+ // Said once per failure run.
1241
1320
  if (!roleTransferFailureReported) {
1242
1321
  roleTransferFailureReported = true
1243
1322
  process.stderr.write(
@@ -1390,7 +1469,11 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1390
1469
  topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic),
1391
1470
  )
1392
1471
  if (drifted.length === 0) return
1393
- await options.client.unsubscribe({ sessionID: identity.id, topics: drifted })
1472
+ await options.client.unsubscribe({
1473
+ sessionID: identity.id,
1474
+ topics: drifted,
1475
+ clientInstanceID: options.clientInstanceID,
1476
+ })
1394
1477
  }
1395
1478
 
1396
1479
  // One tick at a time, and at most one pending behind it. `serialized` already
@@ -1525,22 +1608,31 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1525
1608
  // failure but one: a listener answer that is not JSON or misses the schema, an unreadable
1526
1609
  // role file, any transient error. The one is a role file whose bytes cannot be parsed, which
1527
1610
  // only `readPersistedRole` can tell apart: no later read of the same bytes succeeds, so it is
1528
- // not queued, and it costs this session its role and nothing more.
1529
- await restoreRole().catch((error: unknown) => {
1611
+ // not queued, and it costs this session its role and nothing more. A claim the listener
1612
+ // refused naming this very session is queued the same way, on the `"pending"` the restore
1613
+ // answers: the process this one resumed renewed that claim at its last heartbeat, and the
1614
+ // role comes back from the standing claim itself rather than from the reaper, which never
1615
+ // frees it while this process keeps that session alive. The listener grants it to a process
1616
+ // that keeps asking once that renewal is older than the live window
1617
+ // (`roleOwnSessionVerdict`), so the role is back one live window after that renewal, plus
1618
+ // up to the one heartbeat tick it takes this process to ask again.
1619
+ try {
1620
+ roleTransferFrom = (await restoreRole(identity.id)) === "pending" ? identity.id : undefined
1621
+ } catch (error) {
1530
1622
  if (error instanceof UnparseableRoleRecordError) {
1531
1623
  process.stderr.write(
1532
1624
  `envoy-channel: ${messageFor(error)}; the session starts without a role\n`,
1533
1625
  )
1534
- return
1626
+ } else {
1627
+ roleTransferFrom = identity.id
1628
+ // This is the first failure of the run `transferRole` reports, so it says nothing more
1629
+ // until the retry has succeeded once.
1630
+ roleTransferFailureReported = true
1631
+ process.stderr.write(
1632
+ `envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries\n`,
1633
+ )
1535
1634
  }
1536
- roleTransferFrom = identity.id
1537
- // This is the first failure of the run `transferRole` reports, so it says nothing more
1538
- // until the retry has succeeded once.
1539
- roleTransferFailureReported = true
1540
- process.stderr.write(
1541
- `envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries\n`,
1542
- )
1543
- })
1635
+ }
1544
1636
  })
1545
1637
  // The first sweep, once the listener knows what the session follows: what was stored while it
1546
1638
  // was away (a resume included), or, for a new session, its cursors' start at the stream's end.
@@ -1637,10 +1729,7 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1637
1729
  },
1638
1730
  async rememberRole(role) {
1639
1731
  heldRole = role
1640
- await writePersistedRole(roleStateFile(options.stateDirectory, identity.id), {
1641
- session_id: identity.id,
1642
- role,
1643
- })
1732
+ await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role)
1644
1733
  },
1645
1734
  async shutdown() {
1646
1735
  if (shuttingDown) return
@@ -1681,6 +1770,9 @@ export interface ChannelToolRuntime {
1681
1770
  readonly identity: SessionIdentity
1682
1771
  readonly client: EnvoyClient
1683
1772
  readonly session: ChannelSession
1773
+ /** The process the tools act as: the id every registration and every claim of this channel
1774
+ * server carries (`ChannelSessionOptions.clientInstanceID`). */
1775
+ readonly clientInstanceID: string
1684
1776
  }
1685
1777
  export async function executeEnvoyTool(
1686
1778
  runtime: ChannelToolRuntime,
@@ -1768,7 +1860,14 @@ export async function executeEnvoyTool(
1768
1860
  }
1769
1861
  case EnvoyToolOperation.setRole: {
1770
1862
  const args = parseArguments(spec, input)
1771
- const result = await runtime.client.setRole({ sessionID: identity.id, role: args.role })
1863
+ // The claim names this process, as every claim this server makes does: the listener
1864
+ // records it as the role's holder, so a second process on this session id cannot be
1865
+ // mistaken for the one holding the role.
1866
+ const result = await runtime.client.setRole({
1867
+ sessionID: identity.id,
1868
+ role: args.role,
1869
+ clientInstanceID: runtime.clientInstanceID,
1870
+ })
1772
1871
  if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`)
1773
1872
  await runtime.session.rememberRole(args.role)
1774
1873
  return result.interest
@@ -2086,13 +2185,14 @@ export async function runEnvoyChannelServer(): Promise<void> {
2086
2185
  ...(configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}),
2087
2186
  })
2088
2187
  }
2089
- runtime = { identity, client, session }
2188
+ runtime = { identity, client, session, clientInstanceID }
2090
2189
  if (pane !== undefined && daemon !== undefined && !stopping) {
2091
2190
  claim = startLegionClaim({
2092
2191
  pane,
2093
2192
  sessionID: identity.id,
2094
2193
  daemon,
2095
2194
  client,
2195
+ clientInstanceID,
2096
2196
  session,
2097
2197
  signal: booting.signal,
2098
2198
  log: logLegion,
@@ -1,6 +1,7 @@
1
1
  import { readdir, stat } from "node:fs/promises"
2
2
  import { join } from "node:path"
3
3
  import { setTimeout as delay } from "node:timers/promises"
4
+ import { SESSION_LIVE_WINDOW_MS } from "@legion/contracts"
4
5
  import { messageFor } from "@legion/envoy-client/errors"
5
6
  import { readSecretFile } from "@legion/envoy-client/secret-file"
6
7
  import type { EnvoyClient } from "@legion/envoy-client/transport"
@@ -132,6 +133,42 @@ function isFile(file: string): Promise<boolean> {
132
133
  )
133
134
  }
134
135
 
136
+ /**
137
+ * How long apart the attempts of {@link startLegionClaim}'s wait on a claim the listener refused
138
+ * naming this very session are.
139
+ *
140
+ * The wait runs between `claims/register` and `claims/ready`, and the daemon retires a pane that
141
+ * has not said it is ready `worker_boot_timeout_seconds` ×
142
+ * `worker_boot_registration_deadline_intervals` after its registration armed that deadline — 120
143
+ * s × 3 = 360 s with the shipped defaults (`packages/daemon/internal/config/config.go`, armed by
144
+ * `register` through `armRegistration` in `packages/daemon/internal/supervise`). So the interval
145
+ * is what stands between the grant and that deadline: the listener grants the standing claim on
146
+ * the first attempt that falls after the live window its last renewal bought it, which is at
147
+ * most `SESSION_LIVE_WINDOW_MS` after this pane registered, and every part of an interval past
148
+ * that point is dead waiting. At a heartbeat's cadence the attempt at 270 s is still refused
149
+ * whenever that renewal fell inside the 30 s before the registration, and the next one is at 360
150
+ * s — the deadline itself. At 10 s the grant lands inside 310 s of the registration, 50 s of
151
+ * room for `claims/ready` and its own retries.
152
+ *
153
+ * What the shorter interval costs: one `POST /v1/roles/set` per interval, a conditional write the
154
+ * listener refuses without touching the claim, at most about 30 of them, and only in a pane the
155
+ * daemon relaunched into its predecessor's standing claim — once per such relaunch, for at most
156
+ * one live window.
157
+ */
158
+ export const ROLE_CLAIM_RETRY_MS = 10_000
159
+
160
+ /**
161
+ * How long that same wait keeps asking before it gives up and the pane is stopped: the live
162
+ * window and one attempt more, so the last attempt falls after the window the process this pane
163
+ * replaced could still have been answering for its claim in, even when it answered for it at the
164
+ * very moment this pane registered.
165
+ *
166
+ * Inside the daemon's registration deadline on purpose: a pane that cannot have its role writes
167
+ * why to its log and is launched again, rather than being retired by a deadline that would
168
+ * otherwise fire first and say nothing.
169
+ */
170
+ export const ROLE_CLAIM_WAIT_MS = SESSION_LIVE_WINDOW_MS + ROLE_CLAIM_RETRY_MS
171
+
135
172
  export interface LegionClaim {
136
173
  /** The registered claim, or undefined until `claims/register` has answered. */
137
174
  readonly capability: () => ClaimCapability | undefined
@@ -148,22 +185,44 @@ export interface LegionClaim {
148
185
  * `claims/ready` with no capability report, again each time the heartbeat regains the role. A
149
186
  * failure the core stops on ends the pane through `stop`; a failure before a registration is
150
187
  * logged and leaves the pane to the daemon's registration deadline.
188
+ *
189
+ * A claim the listener refuses naming this very session is waited out rather than stopped on:
190
+ * the pane the daemon relaunched with `--resume` meets the standing claim of the process it
191
+ * replaced, and that claim is granted the moment nobody has answered for it inside the
192
+ * listener's live window (`roleWaitMs`). Stopping instead would hand the daemon a relaunch that
193
+ * meets the same claim, for a whole window of relaunches.
194
+ *
195
+ * The claim is asked again every {@link ROLE_CLAIM_RETRY_MS} for up to
196
+ * {@link ROLE_CLAIM_WAIT_MS}, both sized so the grant, and the give-up behind it, fall inside the
197
+ * daemon's registration deadline for this pane's `claims/ready`.
151
198
  */
152
199
  export function startLegionClaim(deps: {
153
200
  readonly pane: LegionPane
154
201
  readonly sessionID: string
155
202
  readonly daemon: LegionDaemonClient
156
203
  readonly client: Pick<EnvoyClient, "setRole">
204
+ /** The channel server's own client instance: the id its registrations carry, which this
205
+ * claim names as the process holding the role. */
206
+ readonly clientInstanceID: string
157
207
  readonly session: Pick<ChannelSession, "rememberRole" | "onRoleRegained">
158
208
  readonly signal: AbortSignal
159
209
  /** Writes one line to the pane's log, the server's stderr. */
160
210
  readonly log: (line: string) => void
161
211
  /** Ends the pane's Claude Code, this server's parent, so the daemon launches the claim again. */
162
212
  readonly stop: () => void
213
+ /**
214
+ * How long a claim refused by a standing claim under this very session id keeps asking before
215
+ * it gives up and the pane is stopped: {@link ROLE_CLAIM_WAIT_MS}.
216
+ */
217
+ readonly roleWaitMs?: number
218
+ /** How long between those attempts: {@link ROLE_CLAIM_RETRY_MS}. */
219
+ readonly roleRetryMs?: number
163
220
  readonly pollMs?: number
164
221
  }): LegionClaim {
165
222
  let capability: ClaimCapability | undefined
166
223
  const { sessionID } = deps
224
+ const roleWaitMs = deps.roleWaitMs ?? ROLE_CLAIM_WAIT_MS
225
+ const roleRetryMs = deps.roleRetryMs ?? ROLE_CLAIM_RETRY_MS
167
226
 
168
227
  const boot = async (): Promise<void> => {
169
228
  const transcript = await waitForTranscript(
@@ -188,16 +247,56 @@ export function startLegionClaim(deps: {
188
247
  capability = registered
189
248
  },
190
249
  claimRole: async (role) => {
191
- const claimed = await deps.client.setRole({ sessionID, role, soft: true })
192
- if (!claimed.claimed) {
193
- throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`)
250
+ // Soft, so a role another live session holds is left with it, and naming this process,
251
+ // so the listener records the claim as this process's: a second `claude` that resumed
252
+ // this session id can then neither be taken for the holder nor release what it holds.
253
+ //
254
+ // A refusal naming this very session is that same claim seen from the other side: the
255
+ // process this pane was relaunched from renewed it at its last heartbeat, so it stands
256
+ // inside the listener's live window and the listener grants it to nobody else until
257
+ // that window passes with no answer for it. So the claim is asked again on the
258
+ // channel server's own heartbeat cadence until it is granted. Only a refusal naming a
259
+ // different session throws, which stops the pane: that role is another session's, and
260
+ // no amount of asking changes it.
261
+ const deadline = Date.now() + roleWaitMs
262
+ let waited = false
263
+ for (;;) {
264
+ const claimed = await deps.client.setRole({
265
+ sessionID,
266
+ role,
267
+ soft: true,
268
+ clientInstanceID: deps.clientInstanceID,
269
+ })
270
+ if (claimed.claimed) break
271
+ if (claimed.holder !== sessionID) {
272
+ throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`)
273
+ }
274
+ const left = deadline - Date.now()
275
+ if (left <= 0) {
276
+ throw new Error(
277
+ `Envoy role ${role} is still claimed under this session by another process after ${roleWaitMs} ms of asking`,
278
+ )
279
+ }
280
+ if (!waited) {
281
+ waited = true
282
+ // Said once: the state is normal and self-resolving, and a line per attempt would
283
+ // fill the pane's log with the same sentence for a whole live window.
284
+ deps.log(
285
+ `Envoy role ${role} is claimed under this session by the process this pane replaced; asking again every ${roleRetryMs} ms for up to ${roleWaitMs} ms, until nobody has answered for that claim inside the listener's live window`,
286
+ )
287
+ }
288
+ await delay(Math.min(roleRetryMs, left), undefined, { signal: deps.signal })
194
289
  }
195
290
  await deps.session.rememberRole(role)
196
291
  },
197
292
  onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
198
293
  log: deps.log,
199
294
  stopping: "stopping the pane so the daemon launches it again",
200
- stop: deps.stop,
295
+ // A boot the shutdown aborted stops nothing: this server is already going, and `stop`
296
+ // would SIGTERM the `claude` it runs under on its way out.
297
+ stop: () => {
298
+ if (!deps.signal.aborted) deps.stop()
299
+ },
201
300
  })
202
301
  }
203
302
 
@@ -4,7 +4,23 @@ import { hasErrnoCode, messageFor } from "@legion/envoy-client/errors"
4
4
  import { z } from "zod"
5
5
  import { pruneStateDirectory, writeAtomicStateFile, writtenLongAgo } from "./session-state"
6
6
 
7
- const PersistedRole = z.object({ session_id: z.string().min(1), role: z.string().min(1) })
7
+ /**
8
+ * The role record on disk: the session the role was claimed for, the role, and the process that
9
+ * wrote the record.
10
+ *
11
+ * `client_instance_id` is the writer's client instance — one Claude Code channel-server process,
12
+ * the same id its registrations and its claims carry. One state directory is read by two
13
+ * processes whenever a second `claude` resumes a live session's id, and the record is keyed by
14
+ * session id alone, so without the writer a process cannot tell its own record from the other's.
15
+ * It is optional because a record written before the field existed carries none: such a record
16
+ * loads and is read as written by no known process (`restoreRole` in the channel server presses
17
+ * it as it always did).
18
+ */
19
+ const PersistedRole = z.object({
20
+ session_id: z.string().min(1),
21
+ role: z.string().min(1),
22
+ client_instance_id: z.string().min(1).optional(),
23
+ })
8
24
 
9
25
  /**
10
26
  * The session id the channel server acts as, held once and passed by reference.
@@ -72,9 +88,10 @@ export class UnparseableRoleRecordError extends Error {
72
88
  }
73
89
 
74
90
  /**
75
- * The role a session held, from its role file; undefined when it has none. Bytes that do not
76
- * parse throw `UnparseableRoleRecordError`; every other failure to read them (`EACCES`, `EISDIR`,
77
- * any I/O error) propagates unchanged.
91
+ * The role a session held, from its role file; undefined when it has none. A record written
92
+ * before the writer was named carries no `client_instance_id` and loads unchanged. Bytes that do
93
+ * not parse throw `UnparseableRoleRecordError`; every other failure to read them (`EACCES`,
94
+ * `EISDIR`, any I/O error) propagates unchanged.
78
95
  */
79
96
  export async function readPersistedRole(
80
97
  roleFile: string,
@@ -137,14 +154,17 @@ export async function pruneStaleSessionHandoffs(stateDirectory: string): Promise
137
154
  * title records follow, except the record of `currentSessionId` — the session running the sweep.
138
155
  *
139
156
  * A record's mtime is when its session was last seen holding the role: every heartbeat on which
140
- * the listener still names the session as holder touches the record (`refreshRoleRecord` in the
141
- * channel server), so this age reaches only a record whose session has not confirmed its role for
142
- * that long — in practice one that stopped running. Only one path removes a role file by hand —
143
- * a soft `setRole` another live session holds off (`claimed: false`, the listener's 409), never
144
- * any answer of the publish route — so a session that holds its role to the end leaves its file
145
- * behind, and the handoff sweep cannot reach it: that one walks `sessions/<pid>/` and asks
146
- * the operating system about a pid, while a role file is keyed by session id and has no process
147
- * left to ask about.
157
+ * the listener still names the session as holder touches the record, and so does every heartbeat
158
+ * a refused process spends pressing its claim again (`refreshRoleRecord` in the channel server),
159
+ * so this age reaches only a record whose session has not confirmed its role for that long — in
160
+ * practice one that stopped running. Only one path removes a role file by hand — a soft
161
+ * `setRole` refused (`claimed: false`, the listener's 409) naming a session other than the one
162
+ * the record is for, which says that session demonstrably no longer holds the role — never any
163
+ * answer of the publish route, and never a refusal naming the record's own session, which is the
164
+ * role held under that id by another process. So a session that holds its role to the end leaves
165
+ * its file behind, and the handoff sweep cannot reach it: that one walks `sessions/<pid>/` and
166
+ * asks the operating system about a pid, while a role file is keyed by session id and has no
167
+ * process left to ask about.
148
168
  *
149
169
  * The sweep runs at startup, before any heartbeat has touched anything, and a session whose claim
150
170
  * the listener refused there has not touched its own record either: age alone would expire the