@sjawhar/claude-envoy 0.0.0 → 1.1.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.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/plugin.json +6 -0
  4. package/AGENTS.md +343 -0
  5. package/README.md +639 -0
  6. package/bin/dispatch +6 -0
  7. package/bin/envoy-channel.ts +9 -0
  8. package/bin/envoy-send.ts +33 -0
  9. package/dist/THIRD_PARTY_NOTICES +816 -0
  10. package/dist/dispatch.js +27707 -0
  11. package/dist/envoy-channel.js +57695 -0
  12. package/dist/session-hook.js +26328 -0
  13. package/dist/session-title-hook.js +116 -0
  14. package/hooks/hooks.json +60 -0
  15. package/hooks/session-hook.ts +512 -0
  16. package/hooks/session-title-hook.ts +46 -0
  17. package/legion/.claude-plugin/plugin.json +4 -0
  18. package/legion/agents/deep-worker.md +68 -0
  19. package/legion/agents/oracle.md +35 -0
  20. package/legion/agents/plan-gap-analyst.md +65 -0
  21. package/legion/agents/plan-reviewer.md +104 -0
  22. package/legion/agents/thermonuclear-code-quality.md +30 -0
  23. package/legion/agents/thermonuclear-deep-review.md +30 -0
  24. package/legion/hooks/hooks.json +89 -0
  25. package/legion/skills/ce-simplify-code/LICENSE +21 -0
  26. package/legion/skills/ce-simplify-code/SKILL.md +66 -0
  27. package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  28. package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  29. package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  30. package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
  31. package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
  32. package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
  33. package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
  34. package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
  35. package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
  36. package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
  37. package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
  38. package/legion/skills/legion-architect/SKILL.md +86 -0
  39. package/legion/skills/legion-architect/references/acceptance.md +208 -0
  40. package/legion/skills/legion-architect/references/decomposition.md +129 -0
  41. package/legion/skills/legion-architect/references/design-gate.md +206 -0
  42. package/legion/skills/legion-architect/references/merge-notices.md +91 -0
  43. package/legion/skills/legion-architect/references/wake-routing.md +41 -0
  44. package/legion/skills/legion-controller/SKILL.md +151 -0
  45. package/legion/skills/legion-controller/references/admission.md +22 -0
  46. package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
  47. package/legion/skills/legion-controller/references/daily-report.md +75 -0
  48. package/legion/skills/legion-controller/references/escalation.md +82 -0
  49. package/legion/skills/legion-controller/references/finding-asks.md +181 -0
  50. package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
  51. package/legion/skills/legion-controller/references/session.md +100 -0
  52. package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
  53. package/legion/skills/legion-controller/references/triage.md +96 -0
  54. package/legion/skills/legion-controller/references/wakes.md +132 -0
  55. package/legion/skills/legion-oracle/SKILL.md +48 -0
  56. package/legion/skills/legion-retro/SKILL.md +112 -0
  57. package/legion/skills/legion-retro/references/learnings.md +60 -0
  58. package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
  59. package/legion/skills/legion-worker/SKILL.md +69 -0
  60. package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
  61. package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
  62. package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
  63. package/legion/skills/legion-worker/references/findings.md +199 -0
  64. package/legion/skills/legion-worker/references/merge-gate.md +353 -0
  65. package/legion/skills/legion-worker/references/pr-body.md +218 -0
  66. package/legion/skills/legion-worker/references/review-threads.md +77 -0
  67. package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
  68. package/legion/skills/legion-worker/references/tools.md +708 -0
  69. package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
  70. package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
  71. package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
  72. package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
  73. package/package.json +49 -1
  74. package/scripts/build-inputs.txt +84 -0
  75. package/scripts/build.ts +415 -0
  76. package/scripts/check-packed-skills.sh +50 -0
  77. package/scripts/pack-skills.sh +146 -0
  78. package/scripts/smoke-channel.sh +178 -0
  79. package/scripts/smoke-clear-rebind.sh +164 -0
  80. package/scripts/smoke-session-title.sh +245 -0
  81. package/scripts/verify-build.sh +88 -0
  82. package/skills/agent-secrets/SKILL.md +180 -0
  83. package/skills/asd-ste100/LICENSE +21 -0
  84. package/skills/asd-ste100/SKILL.md +140 -0
  85. package/skills/asd-ste100/examples/before-after.md +92 -0
  86. package/skills/asd-ste100/references/writing-rules.md +59 -0
  87. package/skills/dispatch/SKILL.md +89 -0
  88. package/skills/dispatch-first/SKILL.md +46 -0
  89. package/skills/dispatch-writing-specs/SKILL.md +80 -0
  90. package/skills/envoy/SKILL.md +82 -0
  91. package/skills/envoy/references/check-settlement.md +62 -0
  92. package/skills/envoy/references/deliveries.md +67 -0
  93. package/skills/envoy/references/legion.md +14 -0
  94. package/skills/envoy/references/roles.md +12 -0
  95. package/skills/envoy/references/sources.md +41 -0
  96. package/skills/outside-message/SKILL.md +111 -0
  97. package/skills/plain-words/SKILL.md +88 -0
  98. package/src/channel-forwarder.ts +262 -0
  99. package/src/claude-channel.ts +705 -0
  100. package/src/claude-session.ts +38 -0
  101. package/src/envoy-channel-server.ts +2115 -0
  102. package/src/envoy-client.ts +26 -0
  103. package/src/envoy-off.ts +62 -0
  104. package/src/legion-claim.ts +211 -0
  105. package/src/legion-inbox.ts +110 -0
  106. package/src/legion-phase-hook.ts +554 -0
  107. package/src/legion-tool.ts +69 -0
  108. package/src/replay-state.ts +97 -0
  109. package/src/send-arguments.ts +13 -0
  110. package/src/session-identity.ts +165 -0
  111. package/src/session-state.ts +142 -0
  112. package/src/session-title.ts +251 -0
  113. package/src/subscription-topics.ts +16 -0
@@ -0,0 +1,2115 @@
1
+ import { type Stats, unwatchFile, watchFile } from "node:fs"
2
+ import { rm } from "node:fs/promises"
3
+ import {
4
+ agentSubject,
5
+ type DeliveryCapability,
6
+ ROLE_TOPIC_PREFIX,
7
+ zodSchemaApi,
8
+ } from "@legion/contracts"
9
+ import { forgetBrokerSession } from "@legion/envoy-client/broker-credential"
10
+ import {
11
+ type ConsumerDelivery,
12
+ type ConsumerDeliverySnapshot,
13
+ type ConsumerFrame,
14
+ createConsumerDelivery,
15
+ } from "@legion/envoy-client/consumer-delivery"
16
+ import {
17
+ createDeliveryDedupe,
18
+ type DispatchDelivery,
19
+ expectsLaneReceipt,
20
+ inboundTimestamp,
21
+ postDeliveryReply,
22
+ type RenderInboundResult,
23
+ renderInbound,
24
+ } from "@legion/envoy-client/delivery"
25
+ import { sessionDirectory as dispatchSessionDirectory } from "@legion/envoy-client/dispatch-session-directory"
26
+ import { writeSessionTitle } from "@legion/envoy-client/dispatch-session-state"
27
+ import { subscriptionRemovedTopics } from "@legion/envoy-client/dispatch-subscribe"
28
+ import {
29
+ heartbeatRetryDelayMs,
30
+ REGISTRATION_SETTLE_MS,
31
+ SHUTDOWN_SETTLE_MS,
32
+ } from "@legion/envoy-client/envoy-settings"
33
+ import { messageFor } from "@legion/envoy-client/errors"
34
+ import { machineID } from "@legion/envoy-client/machine"
35
+ import { natsConnectionOptions } from "@legion/envoy-client/nats-connection"
36
+ import {
37
+ closeReplayedRemoval,
38
+ createReplayEngine,
39
+ envoyNoticeText,
40
+ failureNoticeFields,
41
+ type ReplayDeliveryOutcome,
42
+ type ReplayFrame,
43
+ type ReplayNotice,
44
+ reachesPrivateStreamSubject,
45
+ rememberedTopics,
46
+ replayedText,
47
+ replayFollowedSubjects,
48
+ replaySuppressed,
49
+ routeReplayNotice,
50
+ } from "@legion/envoy-client/replay"
51
+ import {
52
+ type SessionConfigFile,
53
+ sessionConfigFile,
54
+ sessionDispatchSettings,
55
+ sessionSettings,
56
+ } from "@legion/envoy-client/session-settings"
57
+ import { postSlackReply } from "@legion/envoy-client/slack-post"
58
+ import {
59
+ EnvoyToolOperation,
60
+ envoyToolSpecs,
61
+ publishConfirmationText,
62
+ sendConfirmationText,
63
+ sessionsOnMachine,
64
+ type ToolArgumentsByOperation,
65
+ type ToolSpec,
66
+ toMessageMetadata,
67
+ } from "@legion/envoy-client/tool-contract"
68
+ import {
69
+ createEnvoyClient,
70
+ EnvoyApiError,
71
+ type EnvoyClient,
72
+ expandSubscriptionTopics,
73
+ holderStateOf,
74
+ mergeInterestSources,
75
+ newClientInstanceID,
76
+ type SubscribeResult,
77
+ } from "@legion/envoy-client/transport"
78
+ import { createLegionDaemonClient } from "@legion/pi-shared/daemon-client"
79
+ import { findPushedBranchHead, paneHandoffReport } from "@legion/pi-shared/handoff-record"
80
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js"
81
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
82
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
83
+ import { connect } from "nats"
84
+ import { z } from "zod"
85
+ import { name as packageName, version as packageVersion } from "../package.json" with {
86
+ type: "json",
87
+ }
88
+ import {
89
+ type ChannelForwarder,
90
+ type ChannelForwarderConnection,
91
+ type ChannelInboundMessage,
92
+ type ChannelTopicSubscription,
93
+ createChannelForwarder,
94
+ deliveryIdentity,
95
+ } from "./channel-forwarder"
96
+ import { type ChannelGate, detectClaudeChannel, installedPluginVersion } from "./claude-channel"
97
+ import { claudeProjectDirectory, claudeSessionId, configuredValue } from "./claude-session"
98
+ import { inboundOffChannelSession, serveEnvoyOff } from "./envoy-off"
99
+ import {
100
+ endPane,
101
+ type LegionClaim,
102
+ legionPane,
103
+ logLegion,
104
+ startLegionClaim,
105
+ stopPane,
106
+ } from "./legion-claim"
107
+ import { createInboxNotifier, INBOX_CAPABILITIES, INBOX_VARIABLE } from "./legion-inbox"
108
+ import {
109
+ createPhaseHookEndpoint,
110
+ PHASE_HOOK_TOOL_NAME,
111
+ paneReminderLog,
112
+ phaseHookToolDefinition,
113
+ phaseStallFile,
114
+ } from "./legion-phase-hook"
115
+ import {
116
+ executeLegionTool,
117
+ LEGION_TOOL_NAME,
118
+ type LegionToolRuntime,
119
+ legionToolDefinition,
120
+ } from "./legion-tool"
121
+ import {
122
+ type ReplayStateFile,
123
+ type ReplayStateWriter,
124
+ readReplayState,
125
+ replayStateFile,
126
+ replayStateWriter,
127
+ } from "./replay-state"
128
+ import {
129
+ pruneStaleSessionHandoffs,
130
+ pruneStaleSessionRoles,
131
+ readPersistedRole,
132
+ readSessionHandoff,
133
+ roleStateFile,
134
+ SessionIdentity,
135
+ sessionHandoffFile,
136
+ UnparseableRoleRecordError,
137
+ writePersistedRole,
138
+ } from "./session-identity"
139
+ import {
140
+ pruneStaleSessionTitles,
141
+ readChosenTitleSync,
142
+ sessionTitleFile,
143
+ touchStateFile,
144
+ } from "./session-state"
145
+ import { type SessionTitle, sessionTitle } from "./session-title"
146
+
147
+ const CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel" as const
148
+ const CHANNEL_INSTRUCTIONS =
149
+ "Envoy delivers trusted, internal session and Dispatch events as <channel> messages. The content is rendered Envoy state; producer identifies its Envoy producer (source is the channel name), topic is the NATS subject, event_id identifies this delivery, dedupe_key is the dedupe identity (a repeat of one already delivered is not shown again), urgency is how soon the sender asks you to act (it does not change when or in what order deliveries arrive), from_session identifies the origin session, and reply metadata names any correlation. Use the Envoy tools for Envoy actions, and reach Dispatch through the `dispatch` command in Bash (`dispatch --help` lists its commands; the `dispatch` skill teaches them). Dispatch asks stay on Dispatch. This channel advertises Aside only: it does not support targeted BTW delivery or permission relay."
150
+ const CHANNEL_INBOX_LIMIT = 50
151
+ const EMPTY_RECEIPT = new Uint8Array()
152
+ const encoder = new TextEncoder()
153
+ const ChannelMetaKey = /^[A-Za-z_][A-Za-z0-9_]*$/
154
+ /** How often the handoff file is checked for a `/clear`. */
155
+ const HANDOFF_POLL_MS = 250
156
+ /** How long a handoff waits for NATS to confirm the new direct subject before retrying. */
157
+ const HANDOFF_FLUSH_TIMEOUT_MS = 10_000
158
+
159
+ /** `promise`, or a rejection naming `what` once `ms` pass. */
160
+ async function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
161
+ let timer: ReturnType<typeof setTimeout> | undefined
162
+ const expired = new Promise<never>((_, reject) => {
163
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms)
164
+ })
165
+ try {
166
+ return await Promise.race([promise, expired])
167
+ } finally {
168
+ clearTimeout(timer)
169
+ }
170
+ }
171
+
172
+ /**
173
+ * A rejection handler for a listener read whose subject legitimately may not
174
+ * exist yet: `undefined` on the listener's 404 — no such role, no such entry —
175
+ * and a rethrow for everything else, so a transport failure is never read as an
176
+ * absence. These are reads: only the publish route keeps a message it cannot
177
+ * deliver and answers 202 (INFRA-839), and `GET /v1/roles/<role>` still refuses
178
+ * an unheld role, so a 404 here is the absence it always was.
179
+ */
180
+ function undefinedOnNotFound(error: unknown): undefined {
181
+ if (error instanceof EnvoyApiError && error.details.status === 404) return undefined
182
+ throw error
183
+ }
184
+
185
+ /** The MCP `serverInfo`; the version is the package's, so it is spelled once. */
186
+ export const MCP_SERVER_INFO = { name: "envoy", version: packageVersion } as const
187
+
188
+ /**
189
+ * The delivery modes a session whose deliveries are channel notifications advertises: Claude Code
190
+ * queues a notification for the model's next turn, which is an aside and cannot be a steer.
191
+ */
192
+ export const CHANNEL_CAPABILITIES = ["aside"] as const satisfies readonly DeliveryCapability[]
193
+
194
+ export interface ChannelNotification {
195
+ readonly method: typeof CHANNEL_NOTIFICATION_METHOD
196
+ readonly params: {
197
+ readonly content: string
198
+ readonly meta: Readonly<Record<string, string>>
199
+ }
200
+ }
201
+
202
+ export interface ChannelNotifier {
203
+ notification(notification: ChannelNotification): Promise<void>
204
+ }
205
+
206
+ /** Why the heartbeat regained this session's role (`ChannelSession.onRoleRegained`). */
207
+ export type RoleRegainReason = "reclaimed" | "reregistered"
208
+ export type RoleRegainHook = (role: string, reason: RoleRegainReason) => Promise<void>
209
+
210
+ export interface ChannelInboxEntry {
211
+ readonly event_id: string
212
+ readonly at: string
213
+ readonly from: string
214
+ readonly summary: string
215
+ }
216
+
217
+ export interface ChannelDelivery {
218
+ /**
219
+ * Queue one rendered envelope onto the notifier, behind every frame queued before it. Resolves
220
+ * whether it went to Claude Code, once the notifier resolved its write: `false` for a frame
221
+ * skipped, dropped, suppressed, or answered on Dispatch with an error instead (once that reply
222
+ * is posted). `late` is a relay frame delivered before or stored before the current epoch
223
+ * (`ConsumerFrame.late`): a late BTW is suppressed, as a replayed one is.
224
+ */
225
+ enqueue(input: {
226
+ readonly subject: string
227
+ readonly raw: string
228
+ readonly late?: boolean
229
+ }): Promise<boolean>
230
+ /** Latest inbound metadata, most recent first; no envelope body is retained. */
231
+ inbox(): readonly ChannelInboxEntry[]
232
+ }
233
+
234
+ /**
235
+ * The channel delivery of a running session, which also shows what replay recovers
236
+ * (`@legion/envoy-client/replay`), on the same notifier as a live frame: the channel notification,
237
+ * or the Legion bridge's inbox.
238
+ */
239
+ export interface ChannelDeliveryPath extends ChannelDelivery {
240
+ /**
241
+ * Shows a stored frame the session missed, rendered as a live one is, with when it was
242
+ * published, and runs nothing: a replay answers nothing on Dispatch, so a targeted delivery the
243
+ * renderer refuses gets no late reply. `suppressed` for a frame the renderer does not show; a
244
+ * rejection is a failure.
245
+ */
246
+ replay(frame: ReplayFrame): Promise<ReplayDeliveryOutcome>
247
+ /** Shows a notice of Envoy's own; a rejection says it was not shown. */
248
+ notice(content: string): Promise<void>
249
+ }
250
+
251
+ export interface ChannelSession {
252
+ readonly delivery: ChannelDelivery
253
+ /** Every NATS subject this process is consuming right now, direct route included. */
254
+ topics(): readonly string[]
255
+ /** Follow Envoy topics and refresh the registry interest; returns the topics that were new. */
256
+ follow(topics: readonly string[]): Promise<readonly string[]>
257
+ /** Stop following user-selected topics while retaining the direct session route. */
258
+ unfollow(topics: readonly string[]): Promise<readonly string[]>
259
+ /** Persist the role held by an explicit envoy_role_set call, or by the Legion claim. */
260
+ rememberRole(role: string): Promise<void>
261
+ /**
262
+ * Sets the one hook the heartbeat runs after it re-establishes this session as its held role's
263
+ * live holder: `"reclaimed"` when the listener had stopped naming this session and a soft claim
264
+ * took the role back, `"reregistered"` on the first healthy tick after a failed registration,
265
+ * when the listener may have answered "no holder" meanwhile. The Legion claim sends its daemon
266
+ * ready again from it. The hook runs detached from the heartbeat; a rejection is logged.
267
+ */
268
+ onRoleRegained(hook: RoleRegainHook): void
269
+ /** Drain NATS, deregister this listener route, and release timer resources. */
270
+ shutdown(): Promise<void>
271
+ }
272
+
273
+ export interface ChannelSessionOptions {
274
+ readonly identity: SessionIdentity
275
+ readonly connection: ChannelForwarderConnection
276
+ readonly notifier: ChannelNotifier
277
+ /**
278
+ * The delivery modes the session advertises to the listener, which Dispatch checks every
279
+ * targeted delivery against: the ones `notifier` honours (`CHANNEL_CAPABILITIES`, or
280
+ * `INBOX_CAPABILITIES` for the Legion bridge's inbox).
281
+ */
282
+ readonly capabilities: readonly DeliveryCapability[]
283
+ readonly client: Pick<
284
+ EnvoyClient,
285
+ | "subscribe"
286
+ | "unsubscribe"
287
+ | "unregisterSession"
288
+ | "setRole"
289
+ | "getRole"
290
+ | "getInterest"
291
+ | "replay"
292
+ >
293
+ readonly heartbeatMs: number
294
+ /**
295
+ * The session's envoy.json (`sessionConfigFile`): a rejected Dispatch frame is answered with the
296
+ * Dispatch URL and token read from it at that moment.
297
+ */
298
+ readonly configFile: SessionConfigFile
299
+ /** How long a handoff waits for NATS to confirm the new direct subject before it retries. */
300
+ readonly handoffFlushTimeoutMs?: number
301
+ /** How long a deregistration waits for a registration already on the wire (default
302
+ * `REGISTRATION_SETTLE_MS`), past which the DELETE goes out anyway. The shutdown path spends
303
+ * at most `SHUTDOWN_SETTLE_MS` of it, over both of its waits. */
304
+ readonly registrationSettleMs?: number
305
+ /**
306
+ * Schedules the one extra registration a failed heartbeat makes, at the delay it is handed
307
+ * (the shared draw, `heartbeatRetryDelayMs`), and answers how to drop it; `setTimeout` on this
308
+ * process's clock unless a test passes its own, so a test reads the delay the session drew and
309
+ * runs the call without waiting it out.
310
+ */
311
+ readonly scheduleRetry?: (run: () => void, delayMs: number) => () => void
312
+ /**
313
+ * `${CLAUDE_PLUGIN_DATA}`: role state per session id, the per-process handoff files, and the
314
+ * replay state per session id.
315
+ */
316
+ readonly stateDirectory: string
317
+ /**
318
+ * The channel-server process this session runs in, as a random 128-bit hex id minted once per
319
+ * process (`runEnvoyChannelServer`). Every registration and every deregistration carries it,
320
+ * so the listener can tell this process's registry entry from one another process made under
321
+ * the same session id: a soft role claim against a live predecessor is granted only to the
322
+ * instance that registered it, and a deregistration removes an entry only while it is this
323
+ * instance's.
324
+ */
325
+ readonly clientInstanceID: string
326
+ /**
327
+ * The `claude` process this server belongs to. When set, the server polls that
328
+ * process's handoff file and rebinds to the id it names; unset under the QA override.
329
+ */
330
+ readonly handoffPid?: number
331
+ /**
332
+ * The title each registration sends for the current id (`sessionTitle`); omitted, the title is
333
+ * empty and Dispatch names the session by its id.
334
+ */
335
+ readonly title?: SessionTitle
336
+ /**
337
+ * Writes the title where the `dispatch` command reads it to stamp the session's Dispatch writes
338
+ * (`writeSessionTitle` into the session's Dispatch state directory), as pi-envoy does for an Oh
339
+ * My Pi session; omitted, nothing is written.
340
+ */
341
+ readonly stampTitle?: (sessionId: string, title: string) => void
342
+ /**
343
+ * The build every registration names (`installedPluginVersion` of the bundle Claude Code runs,
344
+ * or `source` for a bundle outside its plugin cache).
345
+ */
346
+ readonly pluginVersion: string
347
+ }
348
+
349
+ // The shared tool contract builds schemas from each host's Zod API. Claude's
350
+ // server emits JSON Schema with its own Zod but keeps validation canonical. The
351
+ // specs are frozen module constants, so each schema is built once per spec.
352
+ const argumentSchemas = new WeakMap<ToolSpec, z.ZodObject<z.ZodRawShape>>()
353
+ function argumentsSchema(spec: ToolSpec): z.ZodObject<z.ZodRawShape> {
354
+ let schema = argumentSchemas.get(spec)
355
+ if (schema === undefined) {
356
+ schema = z.object(spec.arguments(zodSchemaApi(z)) as z.ZodRawShape)
357
+ argumentSchemas.set(spec, schema)
358
+ }
359
+ return schema
360
+ }
361
+
362
+ function parseArguments<Operation extends EnvoyToolOperation>(
363
+ spec: ToolSpec & { readonly operation: Operation },
364
+ input: unknown,
365
+ ): ToolArgumentsByOperation[Operation] {
366
+ return argumentsSchema(spec).parse(input) as ToolArgumentsByOperation[Operation]
367
+ }
368
+
369
+ /** A tool's answer as MCP content: the value as one JSON text block. */
370
+ export function mcpResult(value: unknown): {
371
+ readonly content: readonly { readonly type: "text"; readonly text: string }[]
372
+ } {
373
+ return { content: [{ type: "text", text: JSON.stringify(value) }] }
374
+ }
375
+
376
+ function projectDirectory(): string {
377
+ return claudeProjectDirectory(
378
+ { CLAUDE_PROJECT_DIR: process.env["CLAUDE_PROJECT_DIR"] },
379
+ process.cwd(),
380
+ )
381
+ }
382
+
383
+ export function channelToolDefinitions() {
384
+ return envoyToolSpecs.map((spec) => ({
385
+ name: spec.name,
386
+ description: spec.description,
387
+ inputSchema: z.toJSONSchema(argumentsSchema(spec)),
388
+ }))
389
+ }
390
+
391
+ class UnsupportedEnvoyToolError extends Error {
392
+ readonly name = "UnsupportedEnvoyToolError"
393
+
394
+ constructor(readonly toolName: string) {
395
+ super(`Unsupported Envoy tool: ${toolName}`)
396
+ }
397
+ }
398
+
399
+ /** Claude Code silently drops invalid keys, so remove them before writing a notification. */
400
+ export function sanitizeChannelMetadata(
401
+ metadata: Readonly<Record<string, string | undefined>>,
402
+ ): Record<string, string> {
403
+ const sanitized: Record<string, string> = {}
404
+ for (const [key, value] of Object.entries(metadata)) {
405
+ if (value !== undefined && ChannelMetaKey.test(key)) sanitized[key] = value
406
+ }
407
+ return sanitized
408
+ }
409
+
410
+ /**
411
+ * Answer a targeted Dispatch delivery this session could not honour, so the
412
+ * sender sees the attempt fail instead of waiting on a reply that never comes.
413
+ */
414
+ async function postDispatchReply(
415
+ identity: SessionIdentity,
416
+ configFile: SessionConfigFile,
417
+ delivery: DispatchDelivery,
418
+ result: { readonly error: string },
419
+ ): Promise<void> {
420
+ const config = sessionDispatchSettings(configFile, process.env, {
421
+ cwd: identity.directory,
422
+ session: () => identity.id,
423
+ }).dispatch
424
+ if (!config.enabled) throw new Error(config.error ?? config.notice)
425
+ await postDeliveryReply(config, identity.id, delivery, result)
426
+ }
427
+
428
+ /**
429
+ * Render inbound Envoy envelopes through the shared renderer and write them to the notifier one at
430
+ * a time, in the order they were queued. A frame's promise resolves once the notifier resolved its
431
+ * write: in a Legion pane, once the bridge answered that Claude Code took the message into a step;
432
+ * in channel mode, once the MCP transport took the `notifications/claude/channel` write, the
433
+ * strongest signal Claude Code gives there (it acknowledges no notification).
434
+ */
435
+ export function createChannelDelivery(input: {
436
+ readonly identity: SessionIdentity
437
+ readonly notifier: ChannelNotifier
438
+ /** The session's envoy.json, which a rejected Dispatch frame's reply reads. */
439
+ readonly configFile: SessionConfigFile
440
+ }): ChannelDeliveryPath {
441
+ const inbox: ChannelInboxEntry[] = []
442
+ let tail = Promise.resolve()
443
+
444
+ const queue = (notification: ChannelNotification): Promise<void> => {
445
+ const queued = tail.then(() => input.notifier.notification(notification))
446
+ tail = queued.catch((error: unknown) => {
447
+ process.stderr.write(`envoy-channel: channel notification failed — ${messageFor(error)}\n`)
448
+ })
449
+ return queued
450
+ }
451
+
452
+ /** Lists a rendered frame in the inbox and queues `content` for it. */
453
+ const show = (rendered: RenderInboundResult, subject: string, content: string): Promise<void> => {
454
+ const envelope = rendered.envelope
455
+ if (envelope !== undefined) {
456
+ inbox.unshift({
457
+ event_id: envelope.event_id ?? "unknown",
458
+ at: inboundTimestamp(envelope.issued_at),
459
+ from: envelope.source_session ?? envelope.source,
460
+ summary: envelope.payload_summary ?? "unknown",
461
+ })
462
+ if (inbox.length > CHANNEL_INBOX_LIMIT) inbox.pop()
463
+ }
464
+ return queue({
465
+ method: CHANNEL_NOTIFICATION_METHOD,
466
+ params: {
467
+ content,
468
+ // Claude Code stamps its own `source="<channel name>"` attribute on the
469
+ // <channel> tag, so the Envoy producer travels under `producer`.
470
+ meta: sanitizeChannelMetadata({
471
+ producer: envelope?.source ?? "unknown",
472
+ // A forward on the agent subject (a role message, or an ad-hoc topic's copy the
473
+ // publish route confirms with this session) carries the topic it was published to.
474
+ topic: envelope?.topic || subject,
475
+ event_id: envelope?.event_id,
476
+ dedupe_key: envelope?.dedupe_key,
477
+ urgency: envelope?.urgency,
478
+ from_session: envelope?.source_session,
479
+ expects_reply: envelope?.expects_reply,
480
+ in_reply_to: envelope?.in_reply_to,
481
+ }),
482
+ },
483
+ })
484
+ }
485
+
486
+ // A BTW never starts a turn here. The listener sends one only to a session that advertises
487
+ // `btw`, which this one never does, and neither delivery path can show a message without
488
+ // starting a turn (the Legion bridge's inbox starts one when Claude Code is idle). So a BTW that
489
+ // arrives replayed or late anyway is passed over, named on stderr.
490
+ const passOverBtw = (rendered: RenderInboundResult, how: "replayed" | "late"): boolean => {
491
+ if (rendered.delivery?.mode !== "btw") return false
492
+ process.stderr.write(
493
+ `envoy-channel: passing over ${how} BTW ${rendered.envelope?.event_id ?? "unknown"}: a BTW never starts a turn\n`,
494
+ )
495
+ return true
496
+ }
497
+
498
+ return {
499
+ enqueue({ subject, raw, late }) {
500
+ const rendered = renderInbound(raw, input.identity.id, subject)
501
+ if (rendered.skip) return tail.then(() => false)
502
+ // A targeted Dispatch delivery the shared renderer could not validate is
503
+ // never shown to the model: it is answered on Dispatch when it names a
504
+ // message, and dropped with a log line when it does not.
505
+ if (rendered.rejectedDelivery !== undefined) {
506
+ const rejected = rendered.rejectedDelivery
507
+ process.stderr.write(
508
+ `envoy-channel: rejecting malformed Dispatch targeted delivery ${rejected.id}\n`,
509
+ )
510
+ return postDispatchReply(input.identity, input.configFile, rejected, {
511
+ error: "Invalid Dispatch targeted delivery frame",
512
+ }).then(
513
+ () => false,
514
+ (error: unknown) => {
515
+ process.stderr.write(
516
+ `envoy-channel: could not report the rejected delivery to Dispatch — ${messageFor(error)}\n`,
517
+ )
518
+ return false
519
+ },
520
+ )
521
+ }
522
+ if (rendered.malformedDelivery === true) {
523
+ process.stderr.write(
524
+ "envoy-channel: dropping malformed Dispatch targeted delivery without a reply address\n",
525
+ )
526
+ return tail.then(() => false)
527
+ }
528
+ if (late === true && passOverBtw(rendered, "late")) return tail.then(() => false)
529
+ return show(rendered, subject, rendered.content).then(() => true)
530
+ },
531
+ async replay(frame) {
532
+ const rendered = renderInbound(frame.data, input.identity.id, frame.subject)
533
+ if (replaySuppressed(rendered)) return "suppressed"
534
+ if (passOverBtw(rendered, "replayed")) return "suppressed"
535
+ await show(rendered, frame.subject, replayedText(rendered.content, frame.time))
536
+ return "shown"
537
+ },
538
+ notice(content) {
539
+ return queue({
540
+ method: CHANNEL_NOTIFICATION_METHOD,
541
+ params: { content, meta: { producer: "envoy" } },
542
+ })
543
+ },
544
+ inbox() {
545
+ return [...inbox]
546
+ },
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Queues a channel event, then answers a forwarded lane's receipt, and resolves whether Claude
552
+ * Code was handed the event. The receipt is answered at once, before the write: it proves only
553
+ * that this process took the request off the bus. Which frames wait for one is
554
+ * `expectsLaneReceipt`'s rule. A duplicate, which includes a core copy of a frame the listener
555
+ * relays (consumer delivery), answers the receipt and queues nothing.
556
+ */
557
+ export async function enqueueChannelMessage(
558
+ delivery: ChannelDelivery,
559
+ connection: ChannelForwarderConnection,
560
+ directSubject: string,
561
+ message: ChannelInboundMessage,
562
+ ): Promise<boolean> {
563
+ const queued = message.duplicate
564
+ ? Promise.resolve(false)
565
+ : delivery.enqueue({ subject: message.subject, raw: message.raw })
566
+ const lane = {
567
+ subject: message.subject,
568
+ directSubject,
569
+ envelopeTopic: message.envelopeTopic,
570
+ reply: message.reply,
571
+ }
572
+ if (expectsLaneReceipt(lane)) connection.publish(lane.reply, EMPTY_RECEIPT)
573
+ return queued
574
+ }
575
+
576
+ /**
577
+ * Begin one real NATS consumer before advertising the route to Envoy. The
578
+ * channel server owns direct and followed subjects, and the listener's relay
579
+ * subject when it delivers the session's consumer, so no Monitor or helper
580
+ * process is needed between NATS and Claude Code.
581
+ */
582
+ export async function startChannelSession(options: ChannelSessionOptions): Promise<ChannelSession> {
583
+ const { identity } = options
584
+ let directSubject = agentSubject(identity.id)
585
+ const delivery = createChannelDelivery({
586
+ identity,
587
+ notifier: options.notifier,
588
+ configFile: options.configFile,
589
+ })
590
+ const userTopics = new Set<string>()
591
+ let heldRole: string | undefined
592
+ let shuttingDown = false
593
+ const handoffFile =
594
+ options.handoffPid === undefined
595
+ ? undefined
596
+ : sessionHandoffFile(options.stateDirectory, options.handoffPid)
597
+
598
+ /**
599
+ * Every registry write runs through this chain, one at a time. The listener
600
+ * merges each registration's topics into the entry and never removes one, so a
601
+ * registration sent after a topic's removal or after an id's deregistration
602
+ * writes it back for good. Queued writes run in order, and a registration reads
603
+ * the id and topics when it runs rather than when it was requested.
604
+ */
605
+ let registryWrites: Promise<unknown> = Promise.resolve()
606
+ const serialized = <T>(write: () => Promise<T>): Promise<T> => {
607
+ const result = registryWrites.then(() => write())
608
+ registryWrites = result.catch(() => undefined)
609
+ return result
610
+ }
611
+
612
+ /**
613
+ * The registration this process has on the wire, if any, settled either way. A
614
+ * `DELETE /v1/sessions/<id>` that overtakes one is undone when it lands: the listener writes the
615
+ * record again, and a session nothing heartbeats stays registered — live for the live window,
616
+ * then unresponsive for the gone horizon — so peers keep sending to a server that has stopped
617
+ * (dispatch://INFRA-392). Every deregistration waits it out first.
618
+ */
619
+ let registrationInFlight: Promise<void> | undefined
620
+
621
+ /** How long a deregistration waits for that registration, and for the writes the chain still
622
+ * holds. A listener call can take a timeout and a retry to fail, so the default is the whole
623
+ * of one attempt; past it the DELETE goes out anyway, since one never sent always leaves the
624
+ * record behind where one that races a slow registration only may. */
625
+ const settleMs = options.registrationSettleMs ?? REGISTRATION_SETTLE_MS
626
+
627
+ /** The shutdown path's whole settling budget, over both its waits (`SHUTDOWN_SETTLE_MS`). A
628
+ * test that shortens `settleMs` shortens this with it. */
629
+ const shutdownSettleMs = Math.min(settleMs, SHUTDOWN_SETTLE_MS)
630
+
631
+ /** Resolves when `settling` settles, or when `ms` have passed. */
632
+ const settledOrGivenUp = async (settling: Promise<unknown>, ms = settleMs): Promise<void> => {
633
+ const { promise, resolve } = Promise.withResolvers<void>()
634
+ const timer = setTimeout(resolve, ms)
635
+ try {
636
+ await Promise.race([settling.catch(() => undefined), promise])
637
+ } finally {
638
+ clearTimeout(timer)
639
+ }
640
+ }
641
+
642
+ /** Deregisters a session this server has stopped heartbeating: its own at shutdown, and the
643
+ * pre-`/clear` id a handoff leaves behind, once that session's registration has settled or
644
+ * `settleFor` has passed. The DELETE carries this process's client instance id, so the
645
+ * listener removes only a record this instance's registration wrote. */
646
+ const deregister = async (sessionID: string, settleFor = settleMs): Promise<void> => {
647
+ if (registrationInFlight !== undefined) await settledOrGivenUp(registrationInFlight, settleFor)
648
+ await options.client.unregisterSession({
649
+ sessionID,
650
+ clientInstanceID: options.clientInstanceID,
651
+ })
652
+ }
653
+
654
+ // The live path's claims, which a replay sweep reads to leave a frame being delivered alone.
655
+ const dedupe = createDeliveryDedupe()
656
+ /**
657
+ * The session id whose replay state the engine holds. Replay does nothing until it is the
658
+ * current id, so the work of one id never lands in another's file across a `/clear`.
659
+ */
660
+ let replayBound: string | undefined
661
+
662
+ /**
663
+ * A stored frame is shown as a live one is, and runs nothing. A removal closes a topic only when
664
+ * it was stored after the session started following that topic (`closeReplayedRemoval`).
665
+ *
666
+ * The frame is claimed in the live path's dedupe before it is handed to the write queue, as a
667
+ * live frame is (`createChannelForwarder`, `deliverRelayed`): a live copy that arrives while the
668
+ * channel write is still open finds the claim and is a repeat, rather than being shown beside
669
+ * it. The claim is released when the frame was not shown after all (the renderer suppressed it,
670
+ * the write failed), so a later copy still reaches the session, and settled once it was shown:
671
+ * the dedupe's record outlives the claim, so a copy that arrives before the engine records the
672
+ * frame in its ledger is a repeat too. A frame the live path is delivering now is that path's to
673
+ * show and record, so the sweep leaves the stream's copy for a later one (`queued`); a frame the
674
+ * dedupe recorded and let go of that the ledger does not hold (one shown before a `/clear` moved
675
+ * the engine, say) is handed over with no claim.
676
+ */
677
+ const showReplayed = async (frame: ReplayFrame): Promise<ReplayDeliveryOutcome> => {
678
+ const identityOf = deliveryIdentity(frame.data)
679
+ const claim = dedupe.claim(identityOf)
680
+ if (claim === undefined && identityOf !== undefined && dedupe.pending(identityOf)) {
681
+ return "queued"
682
+ }
683
+ try {
684
+ await closeReplayedRemoval(frame, {
685
+ sessionID: identity.id,
686
+ engine: replay,
687
+ close: (topic) => userTopics.delete(topic),
688
+ unregister: unfollowRemovedTopics,
689
+ followedChanged: () => replay.followedChanged(),
690
+ })
691
+ const outcome = await delivery.replay(frame)
692
+ if (outcome === "shown") claim?.settle()
693
+ else claim?.release()
694
+ return outcome
695
+ } catch (error) {
696
+ claim?.release()
697
+ throw error
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Tells the session a notice of the replay engine or of consumer delivery (a gap). That the
703
+ * listener does not replay goes where every start notice of this server goes, as pi-envoy raises
704
+ * it as a warning rather than a card for the model; every other one is a notice card.
705
+ */
706
+ const tellNotice = (notice: ReplayNotice): void => {
707
+ routeReplayNotice(notice, {
708
+ warn: (message) => process.stderr.write(`envoy-channel: ${message}\n`),
709
+ card: (content) => {
710
+ delivery.notice(content).catch((error: unknown) => {
711
+ process.stderr.write(
712
+ `envoy-channel: ${notice.message} (the session could not be told — ${messageFor(error)})\n`,
713
+ )
714
+ })
715
+ },
716
+ })
717
+ }
718
+
719
+ /** The writer of the bound id's state file (`replayStateWriter`), set by `restoreReplay`. */
720
+ let stateWriter: ReplayStateWriter | undefined
721
+
722
+ /**
723
+ * Shows the session each message the notification stream stored for it while its subscriptions
724
+ * were down, once (`@legion/envoy-client/replay`), with its state in the session id's file.
725
+ */
726
+ const replay = createReplayEngine({
727
+ host: {
728
+ sessionID: () => (shuttingDown || replayBound !== identity.id ? undefined : identity.id),
729
+ // What the session registers (`replayFollowedSubjects`).
730
+ followedSubjects: () => replayFollowedSubjects(directSubject, userTopics),
731
+ deliver: showReplayed,
732
+ showFailureNotice: (notice) => delivery.notice(envoyNoticeText(failureNoticeFields(notice))),
733
+ failureNoticeFailed: (notice, error) => {
734
+ process.stderr.write(
735
+ `envoy-channel: message ${notice.eventId} from ${notice.sender} was not shown, nor was the notice naming it — ${messageFor(error)}\n`,
736
+ )
737
+ },
738
+ notify: tellNotice,
739
+ log: (message, error) => {
740
+ process.stderr.write(
741
+ `envoy-channel: ${message}${error === undefined ? "" : ` — ${messageFor(error)}`}\n`,
742
+ )
743
+ },
744
+ write: (change) => stateWriter?.replay(change.snapshot()),
745
+ },
746
+ transport: options.client,
747
+ dedupe,
748
+ })
749
+
750
+ /**
751
+ * Loads the current id's state file: the replay state into the engine, and the consumer delivery
752
+ * snapshot into a new relay for the id, whose subject it subscribes (`openRelay`). Returns the
753
+ * topics it followed that a restore follows again (`rememberedTopics`). A file that cannot be
754
+ * read starts the session's replay over; its next write replaces the file.
755
+ */
756
+ const restoreReplay = async (): Promise<readonly string[]> => {
757
+ const sessionId = identity.id
758
+ const file = replayStateFile(options.stateDirectory, sessionId)
759
+ const snapshot = await readReplayState(file).catch((error: unknown): ReplayStateFile => {
760
+ process.stderr.write(
761
+ `envoy-channel: could not read the replay state (${messageFor(error)}); this session's replay starts over\n`,
762
+ )
763
+ return { ledger: [] }
764
+ })
765
+ replay.restore(snapshot)
766
+ stateWriter = replayStateWriter(file, snapshot)
767
+ openRelay(sessionId, stateWriter, snapshot.consumer)
768
+ replayBound = sessionId
769
+ return rememberedTopics(snapshot.state)
770
+ }
771
+
772
+ /** A sweep runs on the engine's own chain, never in the registry's. */
773
+ const triggerReplay = (): void => {
774
+ if (!shuttingDown) void replay.sweep()
775
+ }
776
+
777
+ /** A human Dispatch subscription removal naming this session drops its topics here too. */
778
+ const takeRemovals = (raw: string): void => {
779
+ const removedTopics = subscriptionRemovedTopics(raw, identity.id)
780
+ // An empty list would mean "every topic" to both unfollow and the listener.
781
+ if (removedTopics !== undefined && removedTopics.length > 0) {
782
+ for (const topic of removedTopics) userTopics.delete(topic)
783
+ void dropRemovedTopics(removedTopics)
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Consumer delivery (`@legion/envoy-client/consumer-delivery`). The current id's relay subject
789
+ * is subscribed before any registration under that id, and every registration asks the listener
790
+ * for consumer delivery (the relay's epoch and resume point). Answered `consumer`, the listener
791
+ * relays the session's durable consumer on that subject: its agent subject and every followed
792
+ * topic the stream stores, each frame handed to the channel's write queue in stream order and
793
+ * answered `done` once written (`deliverRelayed`). The core copies of those subjects then answer
794
+ * only a lane's receipt (`relaysCoreTopic`), and the replay engine makes no `/v1/replay` call.
795
+ * Answered `core`, or not at all, the core path runs as before.
796
+ */
797
+ interface Relay {
798
+ readonly sessionID: string
799
+ readonly delivery: ConsumerDelivery
800
+ /** The id's state file, which also holds the relay's snapshot. */
801
+ readonly state: ReplayStateWriter
802
+ subscription: ChannelTopicSubscription | undefined
803
+ }
804
+ let relay: Relay | undefined
805
+
806
+ const relaysCoreTopic = (topic: string): boolean =>
807
+ relay?.sessionID === identity.id && relay.delivery.relays(topic)
808
+
809
+ /**
810
+ * Hands one relay frame to the channel's write queue, and answers it once Claude Code has it:
811
+ * `done` once the notifier resolved the write (`createChannelDelivery`) and the ledger recorded
812
+ * the frame, at once for a frame skipped, suppressed (a late BTW), rejected (once the reply is
813
+ * posted) or already shown. A write that fails throws, which the relay answers `nak`, so the
814
+ * listener sends the frame again (from its third delivery on, `done` with a failure notice). The
815
+ * frame stops blocking the next one once it is queued: the write queue keeps the stream's order,
816
+ * and no frame waits on another's write longer than the queue makes it.
817
+ */
818
+ const deliverRelayed = async (owner: Relay, frame: ConsumerFrame): Promise<void> => {
819
+ // The session moved to another id (a `/clear`) or is shutting down: whoever takes it next
820
+ // gets the frame again.
821
+ if (shuttingDown || relay !== owner || owner.sessionID !== identity.id) {
822
+ frame.nak()
823
+ return
824
+ }
825
+ takeRemovals(frame.data)
826
+ const identityOf = deliveryIdentity(frame.data)
827
+ const claim = dedupe.claim(identityOf)
828
+ // A copy of a frame shown or being shown (another subject, a core frame of an unstored topic).
829
+ if (claim === undefined) {
830
+ frame.done()
831
+ return
832
+ }
833
+ let shown: boolean
834
+ try {
835
+ const queued = delivery.enqueue({ subject: frame.subject, raw: frame.data, late: frame.late })
836
+ frame.release()
837
+ shown = await queued
838
+ } catch (error) {
839
+ claim.release()
840
+ throw error
841
+ }
842
+ if (!shown) {
843
+ claim.release()
844
+ frame.done()
845
+ return
846
+ }
847
+ // Recorded before the claim settles and before `done`, as the core path records it.
848
+ if (identityOf !== undefined) await replay.recordDelivered(identityOf)
849
+ claim.settle()
850
+ frame.done()
851
+ owner.delivery.persist()
852
+ }
853
+
854
+ /**
855
+ * A new relay for session `sessionID`, its snapshot `restored` (before any `setFollowed`), its
856
+ * subject subscribed at once; another id's relay is closed first. Its epoch begins as it is
857
+ * made, on this process's one NATS connection; a reconnect begins another (`renewRelayEpoch`).
858
+ * Every later change of its snapshot goes into the id's state file: after a frame is written,
859
+ * after the followed topics change, and after a registration answer moves the stream.
860
+ */
861
+ const openRelay = (
862
+ sessionID: string,
863
+ state: ReplayStateWriter,
864
+ restored: ConsumerDeliverySnapshot | undefined,
865
+ ): void => {
866
+ closeRelay()
867
+ const consumer: ConsumerDelivery = createConsumerDelivery({
868
+ sessionID,
869
+ ledger: replay,
870
+ host: {
871
+ deliver: (frame) => deliverRelayed(owner, frame),
872
+ gap: tellNotice,
873
+ log: (message, error) => {
874
+ process.stderr.write(
875
+ `envoy-channel: ${message}${error === undefined ? "" : ` — ${messageFor(error)}`}\n`,
876
+ )
877
+ },
878
+ },
879
+ publish: (reply, body) => options.connection.publish(reply, encoder.encode(body)),
880
+ persist: (snapshot) => {
881
+ if (relay !== owner) return false
882
+ state.consumer(snapshot).catch((error: unknown) => {
883
+ process.stderr.write(
884
+ `envoy-channel: could not persist the consumer delivery state — ${messageFor(error)}\n`,
885
+ )
886
+ })
887
+ return true
888
+ },
889
+ })
890
+ if (restored !== undefined) consumer.restore(restored)
891
+ const owner: Relay = {
892
+ sessionID,
893
+ delivery: consumer,
894
+ state,
895
+ subscription: undefined,
896
+ }
897
+ relay = owner
898
+ const subscription = options.connection.subscribe(consumer.subject)
899
+ owner.subscription = subscription
900
+ void pumpRelay(owner, subscription)
901
+ }
902
+
903
+ /** Each relay message goes to the relay, which answers `received` at once and queues it. */
904
+ const pumpRelay = async (owner: Relay, subscription: ChannelTopicSubscription): Promise<void> => {
905
+ try {
906
+ for await (const message of subscription) {
907
+ owner.delivery.receive(message)
908
+ }
909
+ } catch (error) {
910
+ process.stderr.write(
911
+ `envoy-channel: the relay subscription of ${owner.sessionID} stopped — ${messageFor(error)}\n`,
912
+ )
913
+ }
914
+ }
915
+
916
+ /**
917
+ * The relay's session left (a `/clear`) or shuts down: its unsettled frames are answered `nak`,
918
+ * the replay engine leaves consumer mode, its subject is unsubscribed, and the core path runs
919
+ * until a registration answers otherwise.
920
+ */
921
+ function closeRelay(): void {
922
+ const owner = relay
923
+ if (owner === undefined) return
924
+ relay = undefined
925
+ owner.delivery.close()
926
+ owner.subscription?.unsubscribe()
927
+ owner.subscription = undefined
928
+ }
929
+
930
+ /**
931
+ * nats.js reconnected: its subscriptions are the same objects, but the listener must restart the
932
+ * relay at its ack floor, so a new epoch is registered at once.
933
+ */
934
+ const renewRelayEpoch = (): void => {
935
+ if (shuttingDown || relay === undefined) return
936
+ relay.delivery.beginEpoch()
937
+ serialized(register).catch((error: unknown) => {
938
+ process.stderr.write(
939
+ `envoy-channel: could not register again after a reconnect (${messageFor(error)}); the next heartbeat retries\n`,
940
+ )
941
+ })
942
+ }
943
+
944
+ /**
945
+ * What a registration's answer says of consumer delivery, for the id it registered with the
946
+ * topics it carried, and the relay it asked for. One that asked nothing (no relay open) is
947
+ * answered `core` by the listener, and nothing is relayed: the core path is on already, since
948
+ * `closeRelay` is the one place a relay is dropped and `delivery.close()` left consumer mode
949
+ * there. An answer for an id this process has left, or for a relay since closed, changes
950
+ * nothing.
951
+ */
952
+ const takeDeliveryAnswer = (
953
+ sessionID: string,
954
+ topics: readonly string[],
955
+ asked: Relay | undefined,
956
+ result: SubscribeResult,
957
+ ): void => {
958
+ if (sessionID !== identity.id || relay !== asked) return
959
+ asked?.delivery.answered(result.delivery, topics)
960
+ }
961
+
962
+ const forwarder: ChannelForwarder = createChannelForwarder(options.connection, {
963
+ dedupe,
964
+ ledger: replay,
965
+ relayed: relaysCoreTopic,
966
+ failed: (message, topic, error, receivedAt) => {
967
+ // A frame no sweep will show again gets its notice at once: a role forward, already
968
+ // acknowledged, one on a subscription the replay does not read (refused, not started, or
969
+ // the listener's replay failing), or one received before its subject's cursor started.
970
+ // The stream's copy of any other waits for a sweep.
971
+ const envelope = renderInbound(message.raw, identity.id, message.subject).envelope
972
+ if (envelope !== undefined) void replay.liveFailed(topic, envelope, error, receivedAt)
973
+ },
974
+ deliver: async (message) => {
975
+ takeRemovals(message.raw)
976
+ return enqueueChannelMessage(delivery, options.connection, directSubject, message)
977
+ },
978
+ })
979
+
980
+ /**
981
+ * A human Dispatch subscription removal, best-effort: drops the topics, which the caller has
982
+ * taken out of `userTopics`, and resolves once the replay state no longer follows them.
983
+ */
984
+ function dropRemovedTopics(topics: readonly string[]): Promise<void> {
985
+ unfollowRemovedTopics(topics)
986
+ return replay.followedChanged()
987
+ }
988
+
989
+ /**
990
+ * Starts dropping removed topics from the forwarder and the registry entry, each failure
991
+ * reported here: the drop half of `dropRemovedTopics`, and what a replayed removal takes out of
992
+ * the registry (`closeReplayedRemoval`).
993
+ */
994
+ function unfollowRemovedTopics(topics: readonly string[]): void {
995
+ const { dropping, unsubscribing } = dropFromForwarderAndRegistry(topics)
996
+ dropping.catch((error: unknown) => {
997
+ process.stderr.write(
998
+ `envoy-channel: could not drop a removed subscription — ${messageFor(error)}\n`,
999
+ )
1000
+ })
1001
+ // Dispatch has already removed them from the entry, but a registration in
1002
+ // flight at that moment adds them back; removing them again behind it undoes
1003
+ // that. A failure here is not retried by name: the heartbeat's own interest
1004
+ // reconciliation (below) brings the entry back in line with what this session
1005
+ // actually follows, whatever caused the drift.
1006
+ unsubscribing.catch((error: unknown) => {
1007
+ process.stderr.write(
1008
+ `envoy-channel: could not drop a removed subscription from the registry — ${messageFor(error)}; the next heartbeat reconciles it\n`,
1009
+ )
1010
+ })
1011
+ }
1012
+
1013
+ /**
1014
+ * Drops `topics` from the forwarder's subscriptions and the registry entry — the two
1015
+ * steps every removal here takes once a caller has already taken them out of
1016
+ * `userTopics`. `forwarder.unfollow` runs first and removes from `following`
1017
+ * before its first await, so no registration queued behind either promise
1018
+ * advertises a dropped topic again, and the relay stops taking them at once. Both
1019
+ * callers here — `dropRemovedTopics`, a removal's best-effort drop, and the public
1020
+ * `unfollow` below — take these same two steps in this order; only whether they
1021
+ * wait for them differs.
1022
+ */
1023
+ function dropFromForwarderAndRegistry(topics: readonly string[]): {
1024
+ readonly dropping: Promise<void>
1025
+ readonly unsubscribing: Promise<void>
1026
+ } {
1027
+ const dropping = forwarder.unfollow(topics)
1028
+ // The relay takes the topics the session follows now, before a registration carries them.
1029
+ if (relay?.sessionID === identity.id) relay.delivery.setFollowed(registeredTopics())
1030
+ return {
1031
+ dropping,
1032
+ unsubscribing: serialized(() =>
1033
+ options.client.unsubscribe({ sessionID: identity.id, topics }),
1034
+ ),
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * The topics a registration carries: every subject followed, the direct one included, but a
1040
+ * followed topic reaching another session's agent subject (a wildcard over them, or another
1041
+ * session's by name), which the listener refuses to store.
1042
+ */
1043
+ function registeredTopics(): string[] {
1044
+ return forwarder.topics().filter((topic) => !reachesPrivateStreamSubject(directSubject, topic))
1045
+ }
1046
+
1047
+ /** The title last stamped, so a heartbeat stamps nothing new. */
1048
+ let stampedTitle: { readonly sessionID: string; readonly title: string } | undefined
1049
+ /** Whether a failure to stamp the title has been reported since the last success. */
1050
+ let stampFailureReported = false
1051
+
1052
+ /**
1053
+ * Advertise the current id and topics; runs only inside `serialized`. The title is also stamped
1054
+ * for the `dispatch` command (`options.stampTitle`) whenever it or the id changes. With the id's
1055
+ * relay open, the registration asks for consumer delivery (`delivery`: the relay's epoch and
1056
+ * resume point), the relay taking the topics it carries as followed first; its answer says
1057
+ * whether the listener relays them (`takeDeliveryAnswer`).
1058
+ */
1059
+ const register = async (): Promise<void> => {
1060
+ const title = options.title?.(identity.id) ?? ""
1061
+ if (
1062
+ options.stampTitle !== undefined &&
1063
+ title !== "" &&
1064
+ (stampedTitle?.sessionID !== identity.id || stampedTitle.title !== title)
1065
+ ) {
1066
+ try {
1067
+ options.stampTitle(identity.id, title)
1068
+ stampedTitle = { sessionID: identity.id, title }
1069
+ stampFailureReported = false
1070
+ } catch (error) {
1071
+ // Said once per failure run: every heartbeat retries it.
1072
+ if (!stampFailureReported) {
1073
+ stampFailureReported = true
1074
+ process.stderr.write(
1075
+ `envoy-channel: could not write the session title for the dispatch command (${messageFor(error)}); retrying every heartbeat, and the command names this session by its id\n`,
1076
+ )
1077
+ }
1078
+ }
1079
+ }
1080
+ const sessionID = identity.id
1081
+ const topics = registeredTopics()
1082
+ const asking = relay?.sessionID === identity.id ? relay : undefined
1083
+ asking?.delivery.setFollowed(topics)
1084
+ const registering = options.client.subscribe({
1085
+ sessionID,
1086
+ directory: identity.directory,
1087
+ topics,
1088
+ port: 0,
1089
+ title,
1090
+ capabilities: options.capabilities,
1091
+ driving: false,
1092
+ selfSubscribed: true,
1093
+ clientInstanceID: options.clientInstanceID,
1094
+ plugin: { name: packageName, version: options.pluginVersion },
1095
+ ...(asking === undefined ? {} : { delivery: asking.delivery.registration() }),
1096
+ })
1097
+ // Held as a promise that settles either way, so a deregistration waits for the call to leave
1098
+ // the wire without taking on its rejection.
1099
+ const settled = registering.then(
1100
+ () => undefined,
1101
+ () => undefined,
1102
+ )
1103
+ registrationInFlight = settled
1104
+ void settled.then(() => {
1105
+ if (registrationInFlight === settled) registrationInFlight = undefined
1106
+ })
1107
+ const result = await registering
1108
+ takeDeliveryAnswer(sessionID, topics, asking, result)
1109
+ }
1110
+
1111
+ /**
1112
+ * Take back the role persisted for `previousSessionID` (this id on `--resume`,
1113
+ * the pre-`/clear` id on a handoff) with a soft claim naming that predecessor. The listener
1114
+ * grants such a claim only when that predecessor has no live registry entry, when its entry
1115
+ * carries this process's own client instance id, or when either side carries none; a
1116
+ * predecessor still live under another client instance keeps the role.
1117
+ *
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
1121
+ * predecessor demonstrably no longer holds it, so its record goes — the one path that removes
1122
+ * a role record by hand.
1123
+ */
1124
+ const restoreRole = async (previousSessionID = identity.id): Promise<void> => {
1125
+ const previousFile = roleStateFile(options.stateDirectory, previousSessionID)
1126
+ const persisted = await readPersistedRole(previousFile)
1127
+ if (persisted === undefined) return
1128
+ const claim = await options.client.setRole({
1129
+ sessionID: identity.id,
1130
+ role: persisted.role,
1131
+ soft: true,
1132
+ previousSessionID: persisted.session_id,
1133
+ })
1134
+ if (!claim.claimed) {
1135
+ if (claim.holder !== persisted.session_id) await rm(previousFile, { force: true })
1136
+ return
1137
+ }
1138
+ heldRole = persisted.role
1139
+ const currentFile = roleStateFile(options.stateDirectory, identity.id)
1140
+ if (previousFile !== currentFile) await rm(previousFile, { force: true })
1141
+ await writePersistedRole(currentFile, { session_id: identity.id, role: persisted.role })
1142
+ }
1143
+
1144
+ let regained: RoleRegainHook | undefined
1145
+ /** Runs `regained` off the heartbeat's chain: it is a daemon round trip this side cannot bound,
1146
+ * and every registry write queued behind the tick would wait on it. */
1147
+ const fireRegained = (role: string, reason: RoleRegainReason): void => {
1148
+ const hook = regained
1149
+ if (hook === undefined) return
1150
+ void Promise.resolve()
1151
+ .then(() => hook(role, reason))
1152
+ .catch((error: unknown) => {
1153
+ process.stderr.write(
1154
+ `envoy-channel: role ${role} regain hook failed (${reason}) — ${messageFor(error)}\n`,
1155
+ )
1156
+ })
1157
+ }
1158
+
1159
+ /** Whether a failure to refresh the role record has been reported since the last success. */
1160
+ let roleRefreshFailureReported = false
1161
+ /**
1162
+ * Marks the role record of a holder the heartbeat has just confirmed, so its mtime is the last
1163
+ * time this session was seen holding the role, which is what `pruneStaleSessionRoles`'s age
1164
+ * rule takes it for: nothing else touches the record of a session that keeps its role, and
1165
+ * without this the sweep expires a live holder's only way back to it.
1166
+ *
1167
+ * A timestamp touch and not a rewrite: the record's content has not changed, and an atomic
1168
+ * rewrite would leave a temporary file beside it in `roles/` every two minutes. Best-effort,
1169
+ * because a state directory that cannot be written must not cost the session its heartbeat;
1170
+ * the record it could not touch is still there to restore from until the sweep's age.
1171
+ */
1172
+ const refreshRoleRecord = async (): Promise<void> => {
1173
+ const roleFile = roleStateFile(options.stateDirectory, identity.id)
1174
+ try {
1175
+ await touchStateFile(roleFile)
1176
+ roleRefreshFailureReported = false
1177
+ } catch (error) {
1178
+ // Said once per failure run: every heartbeat retries it.
1179
+ if (!roleRefreshFailureReported) {
1180
+ roleRefreshFailureReported = true
1181
+ process.stderr.write(
1182
+ `envoy-channel: could not refresh the role record ${roleFile} (${messageFor(error)}); retrying every heartbeat\n`,
1183
+ )
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ /** `afterOutage`: the last tick's registration failed, so a holder the listener still names
1189
+ * may have been answered "no holder" meanwhile. */
1190
+ const reassertRole = async (afterOutage: boolean): Promise<void> => {
1191
+ if (heldRole === undefined) return
1192
+ const role = heldRole
1193
+ const holder = await options.client
1194
+ .getRole(role)
1195
+ .then((info) => info.holder, undefinedOnNotFound)
1196
+ if (holder === identity.id) {
1197
+ await refreshRoleRecord()
1198
+ if (afterOutage) fireRegained(role, "reregistered")
1199
+ return
1200
+ }
1201
+ const result = await options.client.setRole({
1202
+ sessionID: identity.id,
1203
+ role,
1204
+ soft: true,
1205
+ })
1206
+ if (!result.claimed) {
1207
+ heldRole = undefined
1208
+ await rm(roleStateFile(options.stateDirectory, identity.id), { force: true })
1209
+ return
1210
+ }
1211
+ fireRegained(role, "reclaimed")
1212
+ }
1213
+
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
+ /** Whether a failure to take back the queued role has been reported since the last success. */
1220
+ let roleTransferFailureReported = false
1221
+ /**
1222
+ * 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.
1229
+ */
1230
+ const transferRole = async (): Promise<void> => {
1231
+ const from = roleTransferFrom
1232
+ if (from === undefined) return
1233
+ try {
1234
+ await restoreRole(from)
1235
+ roleTransferFrom = undefined
1236
+ roleTransferFailureReported = false
1237
+ } catch (error) {
1238
+ // Said once per failure run: every heartbeat retries it.
1239
+ if (!roleTransferFailureReported) {
1240
+ roleTransferFailureReported = true
1241
+ process.stderr.write(
1242
+ `envoy-channel: could not take back the role held by ${from} (${messageFor(error)}); retrying every heartbeat\n`,
1243
+ )
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Claude Code mints a new session id on `/clear` while this process keeps the
1250
+ * one it was spawned with; the SessionStart hook writes the current id for our
1251
+ * shared parent process, and we move every binding to it. The new direct
1252
+ * subject is consumed before anything is advertised under the new id, and the
1253
+ * old one leaves the topic list before the first registration under it. Runs
1254
+ * only inside `serialized`.
1255
+ *
1256
+ * The deregistration of the old id carries this process's client instance id, so the listener
1257
+ * removes that entry only while it is still this instance's: a later process that resumed the
1258
+ * old session id and registered it keeps its registration, and this handoff takes nothing
1259
+ * from it.
1260
+ */
1261
+ const adoptHandoff = async (): Promise<void> => {
1262
+ if (handoffFile === undefined) return
1263
+ const next = await readSessionHandoff(handoffFile)
1264
+ if (next === undefined || next === identity.id) return
1265
+ const previous = identity.id
1266
+ const previousSubject = directSubject
1267
+ const nextSubject = agentSubject(next)
1268
+ forwarder.follow(nextSubject)
1269
+ try {
1270
+ // Bounded: this runs inside the registry chain, and a flush on a stalled NATS
1271
+ // connection never settles, which would hold every later write and shutdown.
1272
+ await withTimeout(
1273
+ options.connection.flush(),
1274
+ options.handoffFlushTimeoutMs ?? HANDOFF_FLUSH_TIMEOUT_MS,
1275
+ "flushing NATS",
1276
+ )
1277
+ // The old id's own registration first: one still on the wire writes the record back after
1278
+ // this DELETE, and the id Claude Code has left stays registered with nothing heartbeating
1279
+ // it.
1280
+ await deregister(previous)
1281
+ } catch (error) {
1282
+ // The session stays on the old id until a later tick retries, so the new
1283
+ // subject leaves the topic list; otherwise the next registration would
1284
+ // advertise it under the old id.
1285
+ void forwarder.unfollow([nextSubject])
1286
+ throw error
1287
+ }
1288
+ identity.set(next)
1289
+ // The last call for the old id has been made (its deregistration above; the role moves to
1290
+ // the new id under the new id's own credential, `restoreRole`), so the key pair and
1291
+ // renewing token it traded under go with it rather than renewing for the life of this
1292
+ // process, which `/clear` would otherwise add one of per session.
1293
+ forgetBrokerSession(previous)
1294
+ directSubject = nextSubject
1295
+ // `unfollow` removes the subject from the topic list before it first awaits.
1296
+ // What it then awaits is the drain of deliveries already in flight, and the
1297
+ // handoff does not wait for it: a stuck notification would otherwise hold
1298
+ // every registry write queued behind this one. The drain never rejects.
1299
+ void forwarder.unfollow([previousSubject])
1300
+ // The new id's replay state: none after a `/clear`, which starts afresh as a new session
1301
+ // does, so nothing published before it existed comes back. Until it is loaded, replay does
1302
+ // nothing, and the old id's sweep in flight writes nothing more. Loading it closes the old
1303
+ // id's relay (its unanswered frames `nak`) and subscribes the new id's relay subject.
1304
+ await restoreReplay()
1305
+ roleTransferFrom ??= previous
1306
+ // The new relay subject reaches the server before the registration that asks for it. Not
1307
+ // fatal: a relayed frame nobody answers pauses the relay, which sends again after its backoff
1308
+ // and at the registration below.
1309
+ await withTimeout(
1310
+ options.connection.flush(),
1311
+ options.handoffFlushTimeoutMs ?? HANDOFF_FLUSH_TIMEOUT_MS,
1312
+ "flushing NATS",
1313
+ ).catch((error: unknown) => {
1314
+ process.stderr.write(
1315
+ `envoy-channel: could not confirm the relay subscription of ${next} — ${messageFor(error)}\n`,
1316
+ )
1317
+ })
1318
+ await register()
1319
+ await transferRole()
1320
+ process.stderr.write(`envoy: session id changed ${previous} -> ${next}; re-registered\n`)
1321
+ }
1322
+
1323
+ /** Set by the first read `syncRegisteredInterests` completes, never cleared. */
1324
+ let interestsRead = false
1325
+
1326
+ /**
1327
+ * Brings the registry entry and this process's own NATS subscriptions into line
1328
+ * with each other. The first successful read adopts every topic the entry lists;
1329
+ * every later read subtracts every topic the entry lists that
1330
+ * `forwarder.topics()` no longer names.
1331
+ *
1332
+ * Subtracting is safe only while `forwarder.topics()` is authoritative for this
1333
+ * session, so both windows where it is not are checked after the read, together,
1334
+ * because both open while the read is in flight. `interestsRead` is false until
1335
+ * a read has succeeded, when the forwarder holds only the direct subject while
1336
+ * the entry legitimately holds more. `shuttingDown` is set as teardown begins,
1337
+ * and `forwarder.close()` empties the topic list in that same synchronous step,
1338
+ * so a shutdown landing mid-read would leave an emptied list looking like total
1339
+ * drift. Neither is read before the await: the early return at the top only
1340
+ * saves a round trip, and guards nothing.
1341
+ *
1342
+ * The drift filter names the direct subject even though `followed` covers it in
1343
+ * every state this can subtract in — it is followed before the first
1344
+ * registration, and `adoptHandoff` follows the new subject before assigning it.
1345
+ * That redundancy is deliberate, because it is not what the reasoning costs if
1346
+ * it is ever wrong again: an empty topic set makes the listener delete the whole
1347
+ * entry (`removeInterestTopics` calls `deleteInterest`), where a followed topic
1348
+ * slipping through loses one topic.
1349
+ *
1350
+ * A 404 completes the read: it is the genuinely-new-session case, and the
1351
+ * forwarder is already complete. Any other failure leaves `interestsRead` false
1352
+ * and the next heartbeat adopts again, so a permanent outage means the entry is
1353
+ * never reconciled. That is the correct direction, because nothing undoes a
1354
+ * wrong subtraction: `unregisterSession` deletes only the sessions row, so a
1355
+ * stripped entry stands until `Registry.Reap` and is what a `--resume` inside
1356
+ * that window reads back. The listener refuses the same inference from the other
1357
+ * side of the wire — `mergeForUpsert` (packages/envoy/internal/store/kv.go)
1358
+ * merges and never removes because a transient read once truncated a live
1359
+ * subscription out of durable state.
1360
+ *
1361
+ * Reading live state each heartbeat, rather than replaying a topic list a
1362
+ * failure was recorded against, is what keeps a topic re-followed since a failed
1363
+ * removal from being stripped again, and keeps one topic's registry outage from
1364
+ * blocking any other: there is no per-topic queue, only this session's own entry.
1365
+ *
1366
+ * Runs inside `serialized` from the heartbeat, so it never races a concurrent
1367
+ * registration; the startup call runs before the first one is queued.
1368
+ * Quiet only at startup, where the caller swallows a failed read; from the
1369
+ * heartbeat an outage is what the heartbeat's own report exists to surface.
1370
+ */
1371
+ const syncRegisteredInterests = async (): Promise<void> => {
1372
+ if (shuttingDown) return
1373
+ const registry = await options.client.getInterest(identity.id).catch(undefinedOnNotFound)
1374
+ if (shuttingDown) return
1375
+ if (!interestsRead) {
1376
+ for (const topic of registry?.topics ?? []) {
1377
+ if (topic === directSubject || topic.startsWith(ROLE_TOPIC_PREFIX)) continue
1378
+ userTopics.add(topic)
1379
+ forwarder.follow(topic)
1380
+ }
1381
+ interestsRead = true
1382
+ return
1383
+ }
1384
+ if (registry === undefined) return
1385
+ const followed = new Set(forwarder.topics())
1386
+ const drifted = registry.topics.filter(
1387
+ (topic) =>
1388
+ topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic),
1389
+ )
1390
+ if (drifted.length === 0) return
1391
+ await options.client.unsubscribe({ sessionID: identity.id, topics: drifted })
1392
+ }
1393
+
1394
+ // One tick at a time, and at most one pending behind it. `serialized` already
1395
+ // orders the registry writes across ticks; what these two flags buy is
1396
+ // coalescing — a tick requested while one runs (a handoff file written
1397
+ // mid-tick) runs once, straight after.
1398
+ let heartbeatInFlight = false
1399
+ let heartbeatRequested = false
1400
+ let outageReported = false
1401
+ // Set only by a failed registration, never by a failed role check after it: the listener answers
1402
+ // "no holder" for a session whose registry entry lapsed, and only that warrants a
1403
+ // `"reregistered"` regain. Cleared once a whole tick has succeeded.
1404
+ let registrationFailed = false
1405
+ // Whether the one extra call a failed tick buys is already owed, and how to drop it: armed by
1406
+ // a failed interval tick, spent by the call it schedules, and cleared by a tick that succeeds
1407
+ // or by shutdown.
1408
+ let retryArmed = false
1409
+ let dropRetry: (() => void) | undefined
1410
+ const scheduleRetry: NonNullable<ChannelSessionOptions["scheduleRetry"]> =
1411
+ options.scheduleRetry ??
1412
+ ((run, delayMs) => {
1413
+ const timer = setTimeout(run, delayMs)
1414
+ return () => {
1415
+ clearTimeout(timer)
1416
+ }
1417
+ })
1418
+ const clearRetry = (): void => {
1419
+ retryArmed = false
1420
+ dropRetry?.()
1421
+ dropRetry = undefined
1422
+ }
1423
+ const heartbeat = async (fromRetry = false): Promise<void> => {
1424
+ if (shuttingDown || heartbeatInFlight) return
1425
+ heartbeatInFlight = true
1426
+ try {
1427
+ await serialized(async () => {
1428
+ await adoptHandoff()
1429
+ try {
1430
+ await register()
1431
+ } catch (error) {
1432
+ registrationFailed = true
1433
+ throw error
1434
+ }
1435
+ await reassertRole(registrationFailed)
1436
+ await transferRole()
1437
+ await syncRegisteredInterests()
1438
+ })
1439
+ registrationFailed = false
1440
+ outageReported = false
1441
+ // Back to the plain interval, and a pending extra call goes with it: the registration it
1442
+ // would refresh is fresh now, and a listener that is still slow is owed nothing.
1443
+ clearRetry()
1444
+ } catch (error) {
1445
+ // The extra call this failure buys, armed only from an interval tick: a retry that fails
1446
+ // is followed by the next tick, not by a second retry, so a listener that keeps refusing
1447
+ // sees one extra call per interval and no more. A handoff that unregistered the old id and
1448
+ // then failed to register the new one leaves the session unreachable under both until that
1449
+ // call, which is why it does not wait the whole interval out; the delay is drawn per
1450
+ // session, so a listener that failed many of them is not met by all of them at once.
1451
+ const retryIn = fromRetry ? undefined : armRetry()
1452
+ if (outageReported) return
1453
+ outageReported = true
1454
+ const retrying =
1455
+ retryIn === undefined
1456
+ ? "retrying at the next heartbeat"
1457
+ : `retrying once in ${Math.round(retryIn / 1000)} s, then every heartbeat`
1458
+ process.stderr.write(
1459
+ `envoy-channel: registry heartbeat failed (${messageFor(error)}); ${retrying}\n`,
1460
+ )
1461
+ } finally {
1462
+ heartbeatInFlight = false
1463
+ // Every tick also sweeps the stream, so a missed trigger is caught up, and a listener that
1464
+ // does not replay yet keeps being asked.
1465
+ triggerReplay()
1466
+ if (heartbeatRequested) {
1467
+ heartbeatRequested = false
1468
+ void heartbeat()
1469
+ }
1470
+ }
1471
+ }
1472
+ /** Schedules the extra call a failed heartbeat buys and answers how far out it is, or nothing
1473
+ * when one is already owed. The delay is the shared draw (`heartbeatRetryDelayMs`), the one
1474
+ * pi-envoy's pane plugin retries on too, so the sessions a slow listener failed together come
1475
+ * back spread rather than in one burst. */
1476
+ const armRetry = (): number | undefined => {
1477
+ if (retryArmed || shuttingDown) return undefined
1478
+ retryArmed = true
1479
+ const delayMs = heartbeatRetryDelayMs()
1480
+ dropRetry = scheduleRetry(() => {
1481
+ // A tick settled while the delay ran: the registration is fresh and the call is dropped.
1482
+ if (!retryArmed) return
1483
+ retryArmed = false
1484
+ dropRetry = undefined
1485
+ void heartbeat(true)
1486
+ }, delayMs)
1487
+ return delayMs
1488
+ }
1489
+ const requestHeartbeat = (): void => {
1490
+ if (heartbeatInFlight) heartbeatRequested = true
1491
+ else void heartbeat()
1492
+ }
1493
+
1494
+ // The session's replay state comes first, so a live repeat of a frame it was shown before is
1495
+ // dropped against it. The topics it followed come back with it: a session the listener reaped
1496
+ // (ten minutes after it stopped) still follows them, and its sweep still reads them. Its relay
1497
+ // comes with it too, restored and subscribed before the first registration asks for it.
1498
+ const remembered = await restoreReplay()
1499
+ // The NATS subscription precedes registration so the listener never routes
1500
+ // a direct delivery to an advertised but deaf channel process.
1501
+ forwarder.follow(directSubject)
1502
+ for (const topic of remembered) {
1503
+ userTopics.add(topic)
1504
+ forwarder.follow(topic)
1505
+ }
1506
+ // Best-effort: a listener outage must not fail startup. It leaves
1507
+ // `interestsRead` false, so the heartbeat retries the adopt and never
1508
+ // subtracts against a set this read could not populate.
1509
+ await syncRegisteredInterests().catch((error: unknown) => {
1510
+ process.stderr.write(
1511
+ `envoy-channel: could not read the registered interests (${messageFor(error)}); the next heartbeat retries\n`,
1512
+ )
1513
+ })
1514
+ await options.connection.flush()
1515
+ await serialized(async () => {
1516
+ await register()
1517
+ // Best-effort, as the interests read above is, and for a sharper reason: registration has
1518
+ // already happened, so a throw here takes down a channel the registry is advertising and
1519
+ // leaves the Agents page a row that never beats again.
1520
+ //
1521
+ // A failure is queued as a transfer from this session's own id, so the heartbeat's
1522
+ // `transferRole` runs `restoreRole` again on every tick until it resolves. That covers every
1523
+ // failure but one: a listener answer that is not JSON or misses the schema, an unreadable
1524
+ // role file, any transient error. The one is a role file whose bytes cannot be parsed, which
1525
+ // 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) => {
1528
+ if (error instanceof UnparseableRoleRecordError) {
1529
+ process.stderr.write(
1530
+ `envoy-channel: ${messageFor(error)}; the session starts without a role\n`,
1531
+ )
1532
+ return
1533
+ }
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
+ })
1542
+ })
1543
+ // The first sweep, once the listener knows what the session follows: what was stored while it
1544
+ // was away (a resume included), or, for a new session, its cursors' start at the stream's end.
1545
+ triggerReplay()
1546
+ // Housekeeping of the plugin's own state directories, each best-effort for the reason the
1547
+ // interests read above is: the heartbeat timer below is not armed yet, so a sweep that threw
1548
+ // here would take the session's whole channel down over a dead pid's handoff, a cosmetic title
1549
+ // or a role the session can restore anyway. Everything else this state adds degrades the same
1550
+ // way — the reader swallows a failed read, the hook always exits 0 — and a directory this run
1551
+ // could not sweep is swept by the next session that starts.
1552
+ for (const prune of [
1553
+ () => pruneStaleSessionHandoffs(options.stateDirectory),
1554
+ () => pruneStaleSessionTitles(options.stateDirectory),
1555
+ // The role sweep is told whose session this is: see `pruneStaleSessionRoles`.
1556
+ () => pruneStaleSessionRoles(options.stateDirectory, identity.id),
1557
+ ]) {
1558
+ await prune().catch((error: unknown) => {
1559
+ process.stderr.write(
1560
+ `envoy-channel: could not prune stale session state (${messageFor(error)})\n`,
1561
+ )
1562
+ })
1563
+ }
1564
+ const heartbeatTimer = setInterval(() => {
1565
+ void heartbeat()
1566
+ }, options.heartbeatMs)
1567
+ // A `/clear` is adopted when the SessionStart hook writes the handoff file, not
1568
+ // at the next heartbeat minutes later. The hook replaces the file by rename,
1569
+ // which a directory watch reports only as its temporary file, so the file is
1570
+ // polled by path; the first tick picks up a file written before polling began.
1571
+ //
1572
+ // `watchFile` also calls the listener, with every stat field zeroed, when the file
1573
+ // is missing as polling begins and when it is removed. A missing file names no id
1574
+ // to adopt, and the request below already covers startup: a tick there would be a
1575
+ // second one for the same moment, and in an outage one more attempt than the one
1576
+ // extra call a failed tick buys.
1577
+ const handoffFileChanged = (current: Stats): void => {
1578
+ if (current.ino !== 0) requestHeartbeat()
1579
+ }
1580
+ if (handoffFile !== undefined) {
1581
+ watchFile(handoffFile, { interval: HANDOFF_POLL_MS }, handoffFileChanged)
1582
+ requestHeartbeat()
1583
+ }
1584
+ // nats.js resubscribes by itself after a reconnect; what was published while it was away is in
1585
+ // the stream: the relay restarts at its ack floor on a new epoch's registration, and the core
1586
+ // path's sweep reads it. The status events end when the connection closes.
1587
+ void (async () => {
1588
+ try {
1589
+ for await (const status of options.connection.status()) {
1590
+ if (status.type !== "reconnect") continue
1591
+ renewRelayEpoch()
1592
+ triggerReplay()
1593
+ }
1594
+ } catch (error) {
1595
+ process.stderr.write(
1596
+ `envoy-channel: the NATS connection's status reader failed — ${messageFor(error)}\n`,
1597
+ )
1598
+ }
1599
+ })()
1600
+
1601
+ return {
1602
+ delivery,
1603
+ topics: () => forwarder.topics(),
1604
+ async follow(topics) {
1605
+ if (shuttingDown) return []
1606
+ const fresh: string[] = []
1607
+ for (const topic of expandSubscriptionTopics(topics)) {
1608
+ if (topic === directSubject || userTopics.has(topic)) continue
1609
+ userTopics.add(topic)
1610
+ forwarder.follow(topic)
1611
+ fresh.push(topic)
1612
+ }
1613
+ await options.connection.flush()
1614
+ await serialized(register)
1615
+ if (fresh.length > 0) {
1616
+ // A new follow's cursor starts where the stream ends at the next sweep: no history.
1617
+ await replay.followedChanged()
1618
+ triggerReplay()
1619
+ }
1620
+ return fresh
1621
+ },
1622
+ async unfollow(topics) {
1623
+ const requested = topics.length === 0 ? [...userTopics] : expandSubscriptionTopics(topics)
1624
+ const removed = requested.filter((topic) => userTopics.delete(topic))
1625
+ if (removed.length === 0) return []
1626
+ const { dropping, unsubscribing } = dropFromForwarderAndRegistry(removed)
1627
+ // Recorded at once, so a restart does not follow the topics again from the replay state.
1628
+ await replay.followedChanged()
1629
+ await unsubscribing
1630
+ await dropping
1631
+ return removed
1632
+ },
1633
+ onRoleRegained(hook) {
1634
+ regained = hook
1635
+ },
1636
+ async rememberRole(role) {
1637
+ heldRole = role
1638
+ await writePersistedRole(roleStateFile(options.stateDirectory, identity.id), {
1639
+ session_id: identity.id,
1640
+ role,
1641
+ })
1642
+ },
1643
+ async shutdown() {
1644
+ if (shuttingDown) return
1645
+ shuttingDown = true
1646
+ clearInterval(heartbeatTimer)
1647
+ // The extra call a failed tick scheduled goes too: nothing may register behind the DELETE,
1648
+ // and a timer still pending would hold this process open for the whole of its delay.
1649
+ clearRetry()
1650
+ if (handoffFile !== undefined) unwatchFile(handoffFile, handoffFileChanged)
1651
+ // The relay's unanswered frames `nak`, so the listener sends them to whoever resumes.
1652
+ closeRelay()
1653
+ await forwarder.close()
1654
+ // The heartbeat is stopped and `follow` refuses while shutting down, so no registration
1655
+ // starts from here. What the chain still holds is waited out — a registration it runs after
1656
+ // the DELETE would write the record back — and so is one already on the wire, both bounded,
1657
+ // and on this path bounded together by `shutdownSettleMs`: a write this listener has
1658
+ // stalled on must not keep the deregistration off the wire entirely, since a record left
1659
+ // behind outlives this process by the whole gone horizon, and the process is not given the
1660
+ // two full waits before it is killed.
1661
+ const settleBy = Date.now() + shutdownSettleMs
1662
+ await settledOrGivenUp(registryWrites, shutdownSettleMs)
1663
+ await deregister(identity.id, Math.max(0, settleBy - Date.now())).catch((error: unknown) => {
1664
+ process.stderr.write(
1665
+ `envoy-channel: session deregistration failed — ${messageFor(error)}\n`,
1666
+ )
1667
+ })
1668
+ // What was written meanwhile (a resume point, a ledger record) lands before the process goes.
1669
+ await stateWriter?.idle()
1670
+ // The handoff directory stays: Claude Code restarts this server inside the
1671
+ // same `claude` process (plugin reload, changed config), and the hook does
1672
+ // not rewrite the file until the next SessionStart. Startup pruning removes
1673
+ // the directory once its Claude process is gone.
1674
+ },
1675
+ }
1676
+ }
1677
+
1678
+ export interface ChannelToolRuntime {
1679
+ readonly identity: SessionIdentity
1680
+ readonly client: EnvoyClient
1681
+ readonly session: ChannelSession
1682
+ }
1683
+ export async function executeEnvoyTool(
1684
+ runtime: ChannelToolRuntime,
1685
+ name: string,
1686
+ input: unknown,
1687
+ ): Promise<unknown> {
1688
+ const { identity } = runtime
1689
+ const spec = envoyToolSpecs.find((candidate) => candidate.name === name)
1690
+ if (spec === undefined) throw new UnsupportedEnvoyToolError(name)
1691
+ switch (spec.operation) {
1692
+ case EnvoyToolOperation.send: {
1693
+ const args = parseArguments(spec, input)
1694
+ const result = await runtime.client.send({
1695
+ source: "agent",
1696
+ sourceSessionID: identity.id,
1697
+ targetSessionID: args.session_id,
1698
+ message: args.message,
1699
+ ...(args.idempotency_key === undefined ? {} : { idempotencyKey: args.idempotency_key }),
1700
+ ...toMessageMetadata(args),
1701
+ })
1702
+ return {
1703
+ message: sendConfirmationText(result),
1704
+ event_id: result.envelope.event_id,
1705
+ recipient: result.recipient,
1706
+ recipient_state: result.recipientState,
1707
+ confirmed: result.confirmed,
1708
+ }
1709
+ }
1710
+ case EnvoyToolOperation.publish: {
1711
+ const args = parseArguments(spec, input)
1712
+ const result = await runtime.client.publish({
1713
+ source: "agent",
1714
+ sourceSessionID: identity.id,
1715
+ topic: args.topic,
1716
+ message: args.message,
1717
+ ...toMessageMetadata(args),
1718
+ })
1719
+ // The answer the model reads, its line first. A topic send whose subscriber stopped
1720
+ // answering was accepted and stored for that session, and this result is the one place
1721
+ // the model is told so; a kept answer is a success of its own, which the line says in the
1722
+ // words the Oh My Pi plugin uses (`publishConfirmationText`).
1723
+ return {
1724
+ message: publishConfirmationText(result),
1725
+ event_id: result.envelope.event_id,
1726
+ topic: result.envelope.topic,
1727
+ ...(result.holder === undefined ? {} : { holder: result.holder }),
1728
+ ...(result.holderState === undefined ? {} : { holder_state: result.holderState }),
1729
+ unresponsive: result.unresponsive,
1730
+ ...(result.kept === undefined ? {} : { kept: result.kept }),
1731
+ ...(result.keptReason === undefined ? {} : { kept_reason: result.keptReason }),
1732
+ }
1733
+ }
1734
+ case EnvoyToolOperation.subscribe: {
1735
+ const args = parseArguments(spec, input)
1736
+ await runtime.session.follow(args.topics)
1737
+ return runtime.client.getInterest(identity.id)
1738
+ }
1739
+ case EnvoyToolOperation.unsubscribe: {
1740
+ const args = parseArguments(spec, input)
1741
+ return { removed: await runtime.session.unfollow(args.topics ?? []) }
1742
+ }
1743
+ case EnvoyToolOperation.listInterests: {
1744
+ parseArguments(spec, input)
1745
+ const registry = await runtime.client.getInterest(identity.id)
1746
+ const interests = mergeInterestSources(registry.topics, runtime.session.topics())
1747
+ return {
1748
+ ...registry,
1749
+ topics: [...interests.keys()],
1750
+ interests: [...interests].map(([topic, source]) => ({ topic, source })),
1751
+ }
1752
+ }
1753
+ case EnvoyToolOperation.whoami:
1754
+ parseArguments(spec, input)
1755
+ return { session_id: identity.id, machine_id: machineID(), dir: identity.directory }
1756
+ case EnvoyToolOperation.listSessions: {
1757
+ const args = parseArguments(spec, input)
1758
+ // The stalled sessions are asked for by name: a peer that missed its heartbeats is still
1759
+ // running, and a list that leaves it out is read as the peer having died.
1760
+ const sessions = await runtime.client.listSessions({
1761
+ ...(args.dir === undefined ? {} : { directory: args.dir }),
1762
+ ...(args.title === undefined ? {} : { title: args.title }),
1763
+ include: "unresponsive",
1764
+ })
1765
+ return sessionsOnMachine(sessions, args.machine)
1766
+ }
1767
+ case EnvoyToolOperation.setRole: {
1768
+ const args = parseArguments(spec, input)
1769
+ const result = await runtime.client.setRole({ sessionID: identity.id, role: args.role })
1770
+ if (!result.claimed) throw new Error(`role ${args.role} is held by ${result.holder}`)
1771
+ await runtime.session.rememberRole(args.role)
1772
+ return result.interest
1773
+ }
1774
+ case EnvoyToolOperation.getRole: {
1775
+ const args = parseArguments(spec, input)
1776
+ const role = await runtime.client.getRole(args.role)
1777
+ // A listener that names only one of the two states still shows the model one answer: what
1778
+ // state the holder is in, and when it was last seen.
1779
+ return { ...role, holder_state: holderStateOf(role) }
1780
+ }
1781
+ case EnvoyToolOperation.inbox:
1782
+ parseArguments(spec, input)
1783
+ return runtime.session.delivery.inbox()
1784
+ case EnvoyToolOperation.slackPost: {
1785
+ const args = parseArguments(spec, input)
1786
+ const posted = await postSlackReply({
1787
+ channel: args.channel,
1788
+ threadTs: args.thread_ts,
1789
+ text: args.text,
1790
+ })
1791
+ return { channel: posted.channel, thread_ts: posted.threadTs, ts: posted.ts }
1792
+ }
1793
+ }
1794
+ }
1795
+
1796
+ function pluginStateDirectory(): string {
1797
+ const pluginData = configuredValue(process.env["CLAUDE_PLUGIN_DATA"])
1798
+ if (pluginData === undefined) {
1799
+ throw new Error(
1800
+ "CLAUDE_PLUGIN_DATA is required to preserve an Envoy role across channel server restarts",
1801
+ )
1802
+ }
1803
+ return pluginData
1804
+ }
1805
+
1806
+ /** Start the stdio MCP server Claude Code invokes for this plugin, on the session's envoy.json. With
1807
+ * Envoy off it serves no tool (`serveEnvoyOff`, `./envoy-off`). With Envoy on and inbound messages
1808
+ * off (`inbound.off`: no NATS server, or its seed refused), it serves the tools, which call the
1809
+ * listener, on an inbound-off session (`inboundOffChannelSession`, `./envoy-off`); the start notice
1810
+ * has said why on stderr.
1811
+ *
1812
+ * In a Legion pane (a boot token on its environment, `legionPane`) it also serves the `legion` tool
1813
+ * and boots the pane's claim (`./legion-claim`); a Legion pane whose Envoy is off, or takes no
1814
+ * inbound messages, is stopped, since its task could never reach it. When `legion claude-bridge`
1815
+ * names its inbox (`LEGION_CLAUDE_INBOX`), deliveries go there as user turns instead of channel
1816
+ * notifications (`./legion-inbox`). */
1817
+ export async function runEnvoyChannelServer(): Promise<void> {
1818
+ const directory = projectDirectory()
1819
+ const pane = legionPane(process.env)
1820
+ // The session's envoy.json, by Claude Code's rule (`$CLAUDE_CONFIG_DIR/envoy.json` when that is
1821
+ // set), worked out once as the server starts; Dispatch's URL and token are re-read from it on each
1822
+ // use, so a file that moved its server takes effect without a restart.
1823
+ const configFile = sessionConfigFile("claude", process.env)
1824
+ // The session this server drives, read at every broker trade: Claude Code mints a new id on
1825
+ // /clear and `identity` is set to it, so a token this session presents names this session.
1826
+ const currentSessionId = (): string => identity.id
1827
+ const { dispatch, envoy, notice } = sessionSettings(configFile, process.env, {
1828
+ cwd: directory,
1829
+ session: currentSessionId,
1830
+ })
1831
+ // Claude Code logs the server's stderr: why a section is off, once.
1832
+ if (notice !== null) process.stderr.write(`envoy-channel: ${notice}\n`)
1833
+ const overrideSessionId = process.env["ENVOY_SESSION_ID"]
1834
+ // The title every registration sends, and the one the `dispatch` command stamps on this
1835
+ // session's Dispatch writes. Registration stamps it; with Envoy or its inbound messages off,
1836
+ // nothing registers, so those paths stamp it once themselves.
1837
+ // `CLAUDE_PLUGIN_DATA` is read here, rather than through `pluginStateDirectory` below, because
1838
+ // that throws when it is unset and runs only after the Envoy-off return: a session with Envoy
1839
+ // off and no plugin data must still serve.
1840
+ const pluginData = configuredValue(process.env["CLAUDE_PLUGIN_DATA"])
1841
+ const title = await sessionTitle(
1842
+ {
1843
+ ENVOY_SESSION_TITLE: process.env["ENVOY_SESSION_TITLE"],
1844
+ TMUX: process.env["TMUX"],
1845
+ TMUX_PANE: process.env["TMUX_PANE"],
1846
+ },
1847
+ directory,
1848
+ {
1849
+ chosen: (sessionId) =>
1850
+ pluginData === undefined
1851
+ ? undefined
1852
+ : readChosenTitleSync(sessionTitleFile(pluginData, sessionId)),
1853
+ },
1854
+ )
1855
+ const stampTitle = (sessionId: string, value: string): void => {
1856
+ writeSessionTitle(dispatchSessionDirectory(process.env, sessionId), value)
1857
+ }
1858
+
1859
+ // Only the `dispatch` command reads the stamp, so nothing is written while the session starts with
1860
+ // Dispatch off. Nothing is written under the QA override ENVOY_SESSION_ID either, although that
1861
+ // command acts as the override's id when it is set (`runDispatchCli` in envoy-client): a QA
1862
+ // session's Dispatch writes carry no session title unless its shell sets DISPATCH_SESSION_TITLE.
1863
+ const stamping = dispatch.enabled && configuredValue(overrideSessionId) === undefined
1864
+ /** Stamp the title for the session id `resolveId` names, once, where nothing will register it. */
1865
+ const stampOnce = (resolveId: () => string): void => {
1866
+ if (!stamping) return
1867
+ try {
1868
+ const sessionId = resolveId()
1869
+ const own = title(sessionId)
1870
+ if (own !== "") stampTitle(sessionId, own)
1871
+ } catch (error) {
1872
+ process.stderr.write(
1873
+ `envoy-channel: could not write the session title for the dispatch command — ${messageFor(error)}; the dispatch command's writes carry no session title\n`,
1874
+ )
1875
+ }
1876
+ }
1877
+ if (!envoy.enabled) {
1878
+ // The `dispatch` command still runs with Envoy off, so it still gets the session's title.
1879
+ stampOnce(() =>
1880
+ claudeSessionId({
1881
+ ENVOY_SESSION_ID: overrideSessionId,
1882
+ CLAUDE_CODE_SESSION_ID: process.env["CLAUDE_CODE_SESSION_ID"],
1883
+ }),
1884
+ )
1885
+ const refusal = `Envoy is off for this session: ${envoy.error ?? envoy.notice}`
1886
+ if (pane !== undefined) stopPane(`a Legion pane needs Envoy for its task; ${refusal}`)
1887
+ await serveEnvoyOff(MCP_SERVER_INFO, refusal)
1888
+ return
1889
+ }
1890
+ const identity = new SessionIdentity(
1891
+ claudeSessionId({
1892
+ ENVOY_SESSION_ID: overrideSessionId,
1893
+ CLAUDE_CODE_SESSION_ID: process.env["CLAUDE_CODE_SESSION_ID"],
1894
+ }),
1895
+ directory,
1896
+ )
1897
+ const stateDirectory = pluginStateDirectory()
1898
+ // The Legion bridge's inbox takes the events itself; otherwise they go to Claude Code's channel.
1899
+ const inboxSocket = configuredValue(process.env[INBOX_VARIABLE])
1900
+ // The plugin's own bundle, which Claude Code runs from its plugin cache when installed.
1901
+ const bundlePath = process.argv[1] ?? ""
1902
+ // One connection to the bridge carries every line this server writes into the session: each
1903
+ // Envoy delivery, and the reminders and notices the phase endpoint writes.
1904
+ const inbox = inboxSocket === undefined ? undefined : createInboxNotifier(inboxSocket)
1905
+ // Claude Code drops a notification from a channel it did not load without telling this server,
1906
+ // so such a session advertises no delivery mode and Dispatch refuses a message to it rather than
1907
+ // reporting it delivered. The bridge's inbox needs no channel.
1908
+ const channel: ChannelGate =
1909
+ inboxSocket === undefined
1910
+ ? detectClaudeChannel({
1911
+ claudePid: process.ppid,
1912
+ bundlePath,
1913
+ environment: process.env,
1914
+ })
1915
+ : { loaded: true }
1916
+ if (!channel.loaded) {
1917
+ process.stderr.write(
1918
+ `envoy-channel: Claude Code did not load this server as a channel: ${channel.why}; the session advertises no delivery mode, so Dispatch refuses messages to it instead of losing them\n`,
1919
+ )
1920
+ }
1921
+
1922
+ const server = new Server(MCP_SERVER_INFO, {
1923
+ capabilities: { tools: {}, experimental: { "claude/channel": {} } },
1924
+ instructions: channel.loaded
1925
+ ? CHANNEL_INSTRUCTIONS
1926
+ : `${CHANNEL_INSTRUCTIONS} Claude Code did not load this channel for this session (${channel.why}), so no Envoy event or Dispatch message reaches you as a <channel> message, and the session advertises no delivery mode; envoy_inbox still lists what arrived.`,
1927
+ })
1928
+ const client = createEnvoyClient({
1929
+ baseUrl: envoy.url,
1930
+ fetch: globalThis.fetch,
1931
+ credential: envoy.credential,
1932
+ })
1933
+ // INFRA-404: this process, as the listener knows it. Minted once here and sent on every
1934
+ // registration and every deregistration, so the listener can tell the entry this process made
1935
+ // from one another process made under the same session id: a soft role claim against a live
1936
+ // predecessor goes only to the instance that registered it, and a deregistration removes only
1937
+ // this instance's entry. One per process, not per session id: this server drives one session
1938
+ // at a time, and a restart is a new instance with a new id.
1939
+ const clientInstanceID = newClientInstanceID()
1940
+ let runtime: ChannelToolRuntime | undefined
1941
+ let claim: LegionClaim | undefined
1942
+ const daemon = pane === undefined ? undefined : createLegionDaemonClient(pane.daemonUrl)
1943
+ // INFRA-220: the phase reminder's Claude Code host (`./legion-phase-hook`), which Claude Code's
1944
+ // own hooks drive through the `legion_phase_hook` tool. It is started here, beside the claim it
1945
+ // gates on, and restores this session's record as it starts, before any hook can be answered.
1946
+ const phaseLog = paneReminderLog(logLegion)
1947
+ // The claim's boot, for the endpoint's resume reminder and its recreated-workspace notice: both
1948
+ // need the claim, and the claim starts further down, once the channel session exists.
1949
+ const claimBooted = Promise.withResolvers<void>()
1950
+ const phaseHook =
1951
+ pane === undefined || daemon === undefined
1952
+ ? undefined
1953
+ : createPhaseHookEndpoint({
1954
+ stateFile: phaseStallFile(pane.configDir, identity.id),
1955
+ claim: () => claim?.capability(),
1956
+ booted: claimBooted.promise,
1957
+ readState: (signal) => daemon.state({ signal }),
1958
+ reportActivity: (input) => daemon.activity(input),
1959
+ inbox:
1960
+ inbox === undefined
1961
+ ? undefined
1962
+ : (text) =>
1963
+ inbox.notification({
1964
+ method: CHANNEL_NOTIFICATION_METHOD,
1965
+ params: { content: text, meta: {} },
1966
+ }),
1967
+ publish: client.publish,
1968
+ env: process.env,
1969
+ log: phaseLog,
1970
+ })
1971
+ const toolDefinitions = [
1972
+ ...channelToolDefinitions(),
1973
+ ...(daemon === undefined ? [] : [legionToolDefinition(), phaseHookToolDefinition()]),
1974
+ ]
1975
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions }))
1976
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1977
+ if (phaseHook !== undefined && request.params.name === PHASE_HOOK_TOOL_NAME) {
1978
+ return mcpResult(await phaseHook.handle(request.params.arguments))
1979
+ }
1980
+ if (daemon !== undefined && request.params.name === LEGION_TOOL_NAME) {
1981
+ const legionRuntime: LegionToolRuntime = {
1982
+ daemon,
1983
+ claim: () => claim?.capability(),
1984
+ // The pane's own environment and PATH's jj, and the Dispatch this session's envoy.json
1985
+ // names, which the pane's `dispatch handoff-write` wrote the record to.
1986
+ handoffReport: paneHandoffReport(configFile, process.env, directory, identity.id),
1987
+ branchHead: () => findPushedBranchHead({ env: process.env }),
1988
+ }
1989
+ // The request's signal aborts on the client's notifications/cancelled, which ends a wait
1990
+ // for a restarting daemon.
1991
+ const answer = await executeLegionTool(legionRuntime, request.params.arguments, extra.signal)
1992
+ // A call that returned without throwing: a completion, and a backward move, which hands the
1993
+ // issue to another phase. Either way this session's phase is no longer open, and the
1994
+ // reminder must stop — the same two operations the Oh My Pi host closes its phase on
1995
+ // (`createLegionTool` in `packages/pi-legion/src/tools.ts`). `ready_for_update` ends no
1996
+ // phase: it answers the pod's update task, and the worker then waits for its process to be
1997
+ // replaced, so the `Stop` of the turn it answered in blocks nothing (`updateAnswered`,
1998
+ // `./legion-phase-hook`), as the Oh My Pi host's `onUpdateAnswered` does.
1999
+ const op = request.params.arguments?.["op"]
2000
+ if (op === "handoff_complete" || op === "request_backward_move") phaseHook?.phaseClosed()
2001
+ else if (op === "ready_for_update") phaseHook?.updateAnswered()
2002
+ return mcpResult(answer)
2003
+ }
2004
+ if (runtime === undefined) throw new Error("Envoy channel server is still starting")
2005
+ return mcpResult(await executeEnvoyTool(runtime, request.params.name, request.params.arguments))
2006
+ })
2007
+ // The endpoint is told every line this server writes for a delivery: the `UserPromptSubmit`
2008
+ // that carries one back is that delivery's turn, not a fresh assignment, and this server, which
2009
+ // writes them all, is the only place that knows.
2010
+ const notifier: ChannelNotifier =
2011
+ inbox === undefined
2012
+ ? server
2013
+ : phaseHook === undefined
2014
+ ? inbox
2015
+ : {
2016
+ notification: async (notification) => {
2017
+ phaseHook.deliveryWritten(notification.params.content)
2018
+ await inbox.notification(notification)
2019
+ },
2020
+ }
2021
+ // A session whose deliveries are channel notifications Claude Code did not load shows the model
2022
+ // nothing, so it advertises no delivery mode at all.
2023
+ const capabilities =
2024
+ inboxSocket !== undefined ? INBOX_CAPABILITIES : channel.loaded ? CHANNEL_CAPABILITIES : []
2025
+ const booting = new AbortController()
2026
+
2027
+ const stopped = Promise.withResolvers<void>()
2028
+ let stopping = false
2029
+ let session: ChannelSession | undefined
2030
+ const stop = (): void => {
2031
+ if (stopping) return
2032
+ stopping = true
2033
+ booting.abort()
2034
+ // A reminder waiting on a 15-minute WAITING wait holds no shutting-down server open.
2035
+ phaseHook?.stop()
2036
+ if (session === undefined) {
2037
+ stopped.resolve()
2038
+ return
2039
+ }
2040
+ void session.shutdown().finally(stopped.resolve)
2041
+ }
2042
+ process.stdin.once("end", stop)
2043
+ process.once("SIGTERM", stop)
2044
+ process.once("SIGINT", stop)
2045
+ // The server shares `claude`'s process group, so a closing terminal or
2046
+ // `tmux kill-session` hangs us up directly; deregister instead of dying.
2047
+ process.once("SIGHUP", stop)
2048
+ await server.connect(new StdioServerTransport())
2049
+
2050
+ let connection: ChannelForwarderConnection | undefined
2051
+ try {
2052
+ const { inbound } = envoy
2053
+ if ("off" in inbound) {
2054
+ if (pane !== undefined) {
2055
+ stopPane(
2056
+ `a Legion pane's task reaches it over NATS, and inbound messages are off: ${inbound.off}`,
2057
+ )
2058
+ }
2059
+ // No registration happens here, so the title is stamped directly.
2060
+ stampOnce(() => identity.id)
2061
+ session = inboundOffChannelSession({ identity, stateDirectory, why: inbound.off })
2062
+ } else {
2063
+ connection = await connect(
2064
+ natsConnectionOptions({
2065
+ servers: inbound.natsUrls,
2066
+ name: `claude-envoy-channel-${identity.id}`,
2067
+ seed: inbound.natsSeed,
2068
+ }),
2069
+ )
2070
+ session = await startChannelSession({
2071
+ identity,
2072
+ connection,
2073
+ notifier,
2074
+ capabilities,
2075
+ client,
2076
+ heartbeatMs: envoy.heartbeatMs,
2077
+ configFile,
2078
+ stateDirectory,
2079
+ clientInstanceID,
2080
+ title,
2081
+ pluginVersion: installedPluginVersion(bundlePath) ?? "source",
2082
+ ...(stamping ? { stampTitle } : {}),
2083
+ // The QA override names a fixed identity; only a Claude-minted id follows `/clear`.
2084
+ ...(configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}),
2085
+ })
2086
+ }
2087
+ runtime = { identity, client, session }
2088
+ if (pane !== undefined && daemon !== undefined && !stopping) {
2089
+ claim = startLegionClaim({
2090
+ pane,
2091
+ sessionID: identity.id,
2092
+ daemon,
2093
+ client,
2094
+ session,
2095
+ signal: booting.signal,
2096
+ log: logLegion,
2097
+ stop: endPane,
2098
+ })
2099
+ // What `claimSession.bootstrap` returning is to the Oh My Pi host: the point the phase
2100
+ // endpoint may read the claim, arm its resume reminder and tell the worker its workspace
2101
+ // was recreated.
2102
+ void claim.booted.then(claimBooted.resolve)
2103
+ }
2104
+ if (stopping) await session.shutdown()
2105
+ await stopped.promise
2106
+ } finally {
2107
+ if (session === undefined && connection !== undefined) {
2108
+ await connection.drain().catch(() => undefined)
2109
+ await connection.close().catch(() => undefined)
2110
+ }
2111
+ // Release stdin so the process can exit once the session is deregistered,
2112
+ // even when the parent has not closed its end yet.
2113
+ await server.close().catch(() => undefined)
2114
+ }
2115
+ }