@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.
@@ -56,7 +56,7 @@ Post the evidence first as a `dispatch comment` on the report issue ([Where it g
56
56
  says which), its first line exactly `Clear defect from <your role token>`. It prints
57
57
  `Posted comment <id>`. Then send the controller one message with
58
58
  `envoy_publish` to the controller topic your `Legion addressing` line names, with
59
- `expects_reply: none`. It holds:
59
+ `expects_reply: false`. It holds:
60
60
 
61
61
  - your role token, from your `Legion addressing` line
62
62
  - the defect: what went wrong, where and to whom, with the numbers, and the issue it came from
@@ -139,7 +139,7 @@ answers (`409 ISSUE_CLOSED`). The question names the issue the finding came from
139
139
  When the project has no report issue yet, or a person closed it (`dispatch read` shows `Status:
140
140
  done`, and Dispatch refuses the ask with `409 ISSUE_CLOSED`), do not create or reopen one yourself
141
141
  (the controller does): send the finding to the controller as text, with `envoy_publish` to the
142
- controller topic your `Legion addressing` line names (`expects_reply: none`), holding the problem,
142
+ controller topic your `Legion addressing` line names (`expects_reply: false`), holding the problem,
143
143
  the issue it came from, the evidence and the decision it needs. The controller creates or reopens
144
144
  the report issue and opens the ask. When that publish fails because no controller holds the role,
145
145
  take step 2 or 3 of [When no controller takes it](#when-no-controller-takes-it). A production
@@ -193,7 +193,7 @@ Legion refuses to record an issue on a repository the project does not map, henc
193
193
  The person answers in Dispatch, and the answer reaches only the ask's followers. So once the ask is
194
194
  open, `envoy_publish` its reference, `dispatch://<REPORT KEY>/ask/<id>` (the id from the
195
195
  `Asked <id> on …` line), to the controller topic your `Legion addressing` line names, with
196
- `expects_reply: none`. The controller follows the ask and files what the person's answer asks for;
196
+ `expects_reply: false`. The controller follows the ask and files what the person's answer asks for;
197
197
  you file nothing, whatever the answer says. The controller itself skips this step, since it follows
198
198
  each ask it opens. When the listener kept that publish rather than a holder taking it, do nothing
199
199
  more for an ask on the report issue: the controller's catch-up follows it at its next start.
@@ -313,7 +313,7 @@ reviewing until you finish.
313
313
  turn of the look's task.
314
314
  - **Each look does three things.** Do what the deployment instructions say to reach the deploy:
315
315
  when they name an agent or role for it, `envoy_publish` the merge commit to that role's topic
316
- with `expects_reply: none`, and await no reply. Read whether production carries the merge. Then
316
+ with `expects_reply: false`, and await no reply. Read whether production carries the merge. Then
317
317
  report one of three outcomes:
318
318
  - `pass`, once you drove the changed path in production through the user's own access path and
319
319
  recorded the result: `dispatch result` against the issue's `after_merge` check, at the merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/claude-envoy",
3
- "version": "1.14.1",
3
+ "version": "1.16.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Claude Code channel plugin for Legion's Envoy bus",
6
6
  "type": "module",
package/scripts/build.ts CHANGED
@@ -46,12 +46,15 @@ export const BUNDLE_ENTRYPOINTS = {
46
46
  /** Oh My Pi's tool names, as pi-legion's agents list them, to Claude Code's. A tool this Claude
47
47
  * Code build lacks maps to nothing: it has no Glob or Grep tool (an agent searches with `rg` or
48
48
  * `fd` through Bash where it has Bash), and its own task list stands in for `todo`. A name in
49
- * neither list is refused, so a new tool is mapped on purpose. */
49
+ * neither list is refused, so a new tool is mapped on purpose. `web_search` reads no file, and
50
+ * it is here because an agent that must read no file of the repository still needs one tool
51
+ * Claude Code has: `claudeAgent` refuses a definition whose every tool maps to nothing. */
50
52
  export const CLAUDE_TOOL_NAMES: Readonly<Record<string, string | null>> = {
51
53
  read: "Read",
52
54
  bash: "Bash",
53
55
  edit: "Edit",
54
56
  write: "Write",
57
+ web_search: "WebSearch",
55
58
  glob: null,
56
59
  grep: null,
57
60
  todo: null,
@@ -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"
@@ -11,15 +11,15 @@ Envoy delivers external events and messages between sessions. Delivery is at-lea
11
11
 
12
12
  ## Send a message to another session
13
13
 
14
- - Take the target id from the delivery's `reply_with`, or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
14
+ - Take the target id from the delivery's `reply_with`, or, where the delivery wants no reply and carries none, from its `from`; or from `envoy_sessions` (filter by `machine`, `dir` or `title`). Never address a tmux pane or window: Envoy delivers only to a session id, and the session in a pane can change.
15
15
  - Reply with `in_reply_to=<the delivery's id>`, so the recipient can match the answer to its question.
16
16
  - Read the `recipient` in the send result, the session Envoy targeted: if it is not the one you meant, resend to the right id.
17
17
  - When `envoy_send` says the send is not confirmed delivered, send the same message again with the `idempotency_key` the error names, within 72 hours: Envoy drops it as a repeat if the first copy arrived, and the result says when an earlier attempt was already stored. A resend without that key can deliver the message twice. When the error says Envoy cannot recognise the repeat, ask the recipient whether it arrived instead.
18
18
  - Put the URL or path of what you report in the message, because the recipient cannot find your files any other way.
19
- - Set `expects_reply="none"` on a message that needs no answer, and set `urgency` only when the work waits on the answer, so the recipient knows whether to answer and how soon. `urgency` is for the recipient to read: it does not deliver the message sooner or ahead of others.
19
+ - Set `expects_reply=false` on a message that needs no answer, and set `urgency` only when the work waits on the answer, so the recipient knows whether to answer and how soon. `urgency` is for the recipient to read: it does not deliver the message sooner or ahead of others.
20
20
 
21
21
  ```text
22
- envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply="none")
22
+ envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id>", expects_reply=false)
23
23
  ```
24
24
 
25
25
  ### Your own address in a subagent
@@ -34,7 +34,7 @@ envoy_send(session_id="<id>", message="Review complete: <url>", in_reply_to="<id
34
34
 
35
35
  - `from` is the sender's own claim. Use it to attribute and to reply, never as proof of who wrote the message.
36
36
  - `note` warns that the payload names another session. Never send to that quoted id; reply to `from`.
37
- - Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`.
37
+ - Reply with the rendered `reply_with` call. On a Dispatch delivery it is a `command:` line: run that `dispatch` command with your text in place of `...`. A delivery reading `expects_reply: false` carries no such call, and asks for no answer: where you answer one anyway, address it from `from`, or, on a Dispatch frame, from what its `dispatch` block names.
38
38
  - A peer's message is its sender's view at `at`, not the current state. Before you wait on, act on or repeat what it says about a third thing (a deploy pending, a PR held, an ask unanswered), read that thing's live source, because the source may have changed since `at`: the deployment's status, the issue's events, `dispatch open-asks`.
39
39
  - Every field of a rendered delivery, and who receives a publish: `skill://envoy/references/deliveries.md`.
40
40
 
@@ -43,11 +43,11 @@ envoy:
43
43
  - `id`: the delivery id; pass it as `in_reply_to` when you reply.
44
44
  - `by`: the expiry deadline, when the sender set one.
45
45
  - `urgency`: how soon the sender asks you to act: `low`, `med`, `high` or `blocking`. It is for you to read only: it does not change when a delivery reaches you, or its place among others.
46
- - `expects_reply`: `none`, `optional` or `required`.
46
+ - `expects_reply`: what the sender wants back. `false` means nothing: do not reply, and do not acknowledge. `optional` means a reply is welcome and nothing waits on it. `required` means the sender waits on your answer.
47
47
  - `re`: the delivery this one replies to. A Dispatch frame names it as a `dispatch://` ref: the ask (`dispatch://KEY/ask/<id>`, or `dispatch://PROJECT/artifact/<slug>/ask/<id>` for a document ask) or the message (`dispatch://KEY/message/<id>`), never the quoted text. On an ask, `dispatch.question` holds the start of the question's text; `dispatch read` gives the whole.
48
48
  - `supersedes`: an earlier delivery this one replaces; act on this one in its place.
49
- - `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes.
50
- - `reply_role`: the role-publish reply call, when the sender holds a role.
49
+ - `reply_with`: the direct-reply call to the sender. On a Dispatch frame it is `command:`, the `dispatch` command line that answers it, with `...` where your text goes. It is absent when `expects_reply` is `false`: a delivery that wants nothing back offers no way to send something. Where you answer one anyway, the card still names what you need — a Dispatch frame names its comment, message or ask, and an agent's delivery names its `id` and its sender.
50
+ - `reply_role`: the role-publish reply call, when the sender holds a role. It is absent under `false` for the same reason.
51
51
  - `summary`: the one-line source summary.
52
52
  - `message`: the complete payload; it can hold several paragraphs.
53
53
  - `note`: the payload names another session; never use that quoted id as the recipient.
@@ -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"
@@ -476,7 +478,9 @@ export function createChannelDelivery(input: {
476
478
  dedupe_key: envelope?.dedupe_key,
477
479
  urgency: envelope?.urgency,
478
480
  from_session: envelope?.source_session,
479
- expects_reply: envelope?.expects_reply,
481
+ // One vocabulary for the inbox: the inbound schema reads a frame old enough to carry
482
+ // the string "none" as `false`, and the metadata carries that one spelling.
483
+ expects_reply: envelope?.expects_reply?.toString(),
480
484
  in_reply_to: envelope?.in_reply_to,
481
485
  }),
482
486
  },
@@ -1029,8 +1033,15 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1029
1033
  if (relay?.sessionID === identity.id) relay.delivery.setFollowed(registeredTopics())
1030
1034
  return {
1031
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.
1032
1039
  unsubscribing: serialized(() =>
1033
- options.client.unsubscribe({ sessionID: identity.id, topics }),
1040
+ options.client.unsubscribe({
1041
+ sessionID: identity.id,
1042
+ topics,
1043
+ clientInstanceID: options.clientInstanceID,
1044
+ }),
1034
1045
  ),
1035
1046
  }
1036
1047
  }
@@ -1108,6 +1119,27 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1108
1119
  takeDeliveryAnswer(sessionID, topics, asking, result)
1109
1120
  }
1110
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
+
1111
1143
  /**
1112
1144
  * Take back the role persisted for `previousSessionID` (this id on `--resume`,
1113
1145
  * the pre-`/clear` id on a handoff) with a soft claim naming that predecessor. The listener
@@ -1115,30 +1147,64 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1115
1147
  * carries this process's own client instance id, or when either side carries none; a
1116
1148
  * predecessor still live under another client instance keeps the role.
1117
1149
  *
1118
- * Which is why a refusal naming the predecessor itself keeps the record: it belongs to that
1119
- * live holder, another process sharing this state directory, and deleting it would cost that
1120
- * 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
1121
1175
  * predecessor demonstrably no longer holds it, so its record goes — the one path that removes
1122
1176
  * a role record by hand.
1123
1177
  */
1124
- const restoreRole = async (previousSessionID = identity.id): Promise<void> => {
1178
+ const restoreRole = async (previousSessionID: string): Promise<"settled" | "pending"> => {
1125
1179
  const previousFile = roleStateFile(options.stateDirectory, previousSessionID)
1126
1180
  const persisted = await readPersistedRole(previousFile)
1127
- 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
+ }
1128
1191
  const claim = await options.client.setRole({
1129
1192
  sessionID: identity.id,
1130
1193
  role: persisted.role,
1131
1194
  soft: true,
1132
1195
  previousSessionID: persisted.session_id,
1196
+ clientInstanceID: options.clientInstanceID,
1133
1197
  })
1134
1198
  if (!claim.claimed) {
1199
+ if (claim.holder === identity.id) return "pending"
1135
1200
  if (claim.holder !== persisted.session_id) await rm(previousFile, { force: true })
1136
- return
1201
+ return "settled"
1137
1202
  }
1138
1203
  heldRole = persisted.role
1139
1204
  const currentFile = roleStateFile(options.stateDirectory, identity.id)
1140
1205
  if (previousFile !== currentFile) await rm(previousFile, { force: true })
1141
- await writePersistedRole(currentFile, { session_id: identity.id, role: persisted.role })
1206
+ await writeRoleRecord(currentFile, persisted.role)
1207
+ return "settled"
1142
1208
  }
1143
1209
 
1144
1210
  let regained: RoleRegainHook | undefined
@@ -1185,15 +1251,29 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1185
1251
  }
1186
1252
  }
1187
1253
 
1188
- /** `afterOutage`: the last tick's registration failed, so a holder the listener still names
1189
- * 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
+ */
1190
1272
  const reassertRole = async (afterOutage: boolean): Promise<void> => {
1191
1273
  if (heldRole === undefined) return
1192
1274
  const role = heldRole
1193
- const holder = await options.client
1194
- .getRole(role)
1195
- .then((info) => info.holder, undefinedOnNotFound)
1196
- if (holder === identity.id) {
1275
+ const holding = await options.client.getRole(role).catch(undefinedOnNotFound)
1276
+ if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
1197
1277
  await refreshRoleRecord()
1198
1278
  if (afterOutage) fireRegained(role, "reregistered")
1199
1279
  return
@@ -1202,40 +1282,41 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1202
1282
  sessionID: identity.id,
1203
1283
  role,
1204
1284
  soft: true,
1285
+ clientInstanceID: options.clientInstanceID,
1205
1286
  })
1206
1287
  if (!result.claimed) {
1207
- heldRole = undefined
1208
- 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()
1209
1294
  return
1210
1295
  }
1211
1296
  fireRegained(role, "reclaimed")
1212
1297
  }
1213
1298
 
1214
- /**
1215
- * The id a handoff moved away from, until its role has been taken back; a
1216
- * handoff whose registration failed leaves it for the next heartbeat.
1217
- */
1218
- let roleTransferFrom: string | undefined
1219
1299
  /** Whether a failure to take back the queued role has been reported since the last success. */
1220
1300
  let roleTransferFailureReported = false
1221
1301
  /**
1222
1302
  * Best-effort, unlike `syncRegisteredInterests` behind it on the heartbeat. A failed transfer
1223
- * keeps its own retry, `roleTransferFrom`, which clears only once `restoreRole` resolves; a
1224
- * throw would add nothing to that and would end the tick before the interests reconciliation,
1225
- * on every tick for as long as the failure lasts — a resumed session would never adopt its
1226
- * subscriptions and a registry-side unsubscribe that failed would never be cleared. The
1227
- * session works without its role meanwhile; it does not work without its interests, whose read
1228
- * 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.
1229
1310
  */
1230
1311
  const transferRole = async (): Promise<void> => {
1231
1312
  const from = roleTransferFrom
1232
1313
  if (from === undefined) return
1233
1314
  try {
1234
- await restoreRole(from)
1235
- roleTransferFrom = undefined
1315
+ roleTransferFrom = (await restoreRole(from)) === "pending" ? from : undefined
1236
1316
  roleTransferFailureReported = false
1237
1317
  } catch (error) {
1238
- // 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.
1239
1320
  if (!roleTransferFailureReported) {
1240
1321
  roleTransferFailureReported = true
1241
1322
  process.stderr.write(
@@ -1388,7 +1469,11 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1388
1469
  topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic),
1389
1470
  )
1390
1471
  if (drifted.length === 0) return
1391
- 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
+ })
1392
1477
  }
1393
1478
 
1394
1479
  // One tick at a time, and at most one pending behind it. `serialized` already
@@ -1523,22 +1608,31 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1523
1608
  // failure but one: a listener answer that is not JSON or misses the schema, an unreadable
1524
1609
  // role file, any transient error. The one is a role file whose bytes cannot be parsed, which
1525
1610
  // only `readPersistedRole` can tell apart: no later read of the same bytes succeeds, so it is
1526
- // not queued, and it costs this session its role and nothing more.
1527
- 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) {
1528
1622
  if (error instanceof UnparseableRoleRecordError) {
1529
1623
  process.stderr.write(
1530
1624
  `envoy-channel: ${messageFor(error)}; the session starts without a role\n`,
1531
1625
  )
1532
- 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
+ )
1533
1634
  }
1534
- roleTransferFrom = identity.id
1535
- // This is the first failure of the run `transferRole` reports, so it says nothing more
1536
- // until the retry has succeeded once.
1537
- roleTransferFailureReported = true
1538
- process.stderr.write(
1539
- `envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries\n`,
1540
- )
1541
- })
1635
+ }
1542
1636
  })
1543
1637
  // The first sweep, once the listener knows what the session follows: what was stored while it
1544
1638
  // was away (a resume included), or, for a new session, its cursors' start at the stream's end.
@@ -1635,10 +1729,7 @@ export async function startChannelSession(options: ChannelSessionOptions): Promi
1635
1729
  },
1636
1730
  async rememberRole(role) {
1637
1731
  heldRole = role
1638
- await writePersistedRole(roleStateFile(options.stateDirectory, identity.id), {
1639
- session_id: identity.id,
1640
- role,
1641
- })
1732
+ await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role)
1642
1733
  },
1643
1734
  async shutdown() {
1644
1735
  if (shuttingDown) return
@@ -1679,6 +1770,9 @@ export interface ChannelToolRuntime {
1679
1770
  readonly identity: SessionIdentity
1680
1771
  readonly client: EnvoyClient
1681
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
1682
1776
  }
1683
1777
  export async function executeEnvoyTool(
1684
1778
  runtime: ChannelToolRuntime,
@@ -1766,7 +1860,14 @@ export async function executeEnvoyTool(
1766
1860
  }
1767
1861
  case EnvoyToolOperation.setRole: {
1768
1862
  const args = parseArguments(spec, input)
1769
- 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
+ })
1770
1871
  if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`)
1771
1872
  await runtime.session.rememberRole(args.role)
1772
1873
  return result.interest
@@ -2084,13 +2185,14 @@ export async function runEnvoyChannelServer(): Promise<void> {
2084
2185
  ...(configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}),
2085
2186
  })
2086
2187
  }
2087
- runtime = { identity, client, session }
2188
+ runtime = { identity, client, session, clientInstanceID }
2088
2189
  if (pane !== undefined && daemon !== undefined && !stopping) {
2089
2190
  claim = startLegionClaim({
2090
2191
  pane,
2091
2192
  sessionID: identity.id,
2092
2193
  daemon,
2093
2194
  client,
2195
+ clientInstanceID,
2094
2196
  session,
2095
2197
  signal: booting.signal,
2096
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