@sjawhar/claude-envoy 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/plugin.json +6 -0
- package/AGENTS.md +343 -0
- package/README.md +639 -0
- package/bin/dispatch +6 -0
- package/bin/envoy-channel.ts +9 -0
- package/bin/envoy-send.ts +33 -0
- package/dist/THIRD_PARTY_NOTICES +816 -0
- package/dist/dispatch.js +27707 -0
- package/dist/envoy-channel.js +57695 -0
- package/dist/session-hook.js +26328 -0
- package/dist/session-title-hook.js +116 -0
- package/hooks/hooks.json +60 -0
- package/hooks/session-hook.ts +512 -0
- package/hooks/session-title-hook.ts +46 -0
- package/legion/.claude-plugin/plugin.json +4 -0
- package/legion/agents/deep-worker.md +68 -0
- package/legion/agents/oracle.md +35 -0
- package/legion/agents/plan-gap-analyst.md +65 -0
- package/legion/agents/plan-reviewer.md +104 -0
- package/legion/agents/thermonuclear-code-quality.md +30 -0
- package/legion/agents/thermonuclear-deep-review.md +30 -0
- package/legion/hooks/hooks.json +89 -0
- package/legion/skills/ce-simplify-code/LICENSE +21 -0
- package/legion/skills/ce-simplify-code/SKILL.md +66 -0
- package/legion/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/legion/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/legion/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/legion/skills/leading-a-work-stream/SKILL.md +96 -0
- package/legion/skills/leading-a-work-stream/references/daily-plan.md +8 -0
- package/legion/skills/leading-a-work-stream/references/decision-rights.md +6 -0
- package/legion/skills/leading-a-work-stream/references/direction-and-coaching.md +13 -0
- package/legion/skills/leading-a-work-stream/references/dispatch-upkeep.md +8 -0
- package/legion/skills/leading-a-work-stream/references/sources.md +9 -0
- package/legion/skills/leading-a-work-stream/references/speed-tick.md +44 -0
- package/legion/skills/leading-a-work-stream/references/user-quotes.md +7 -0
- package/legion/skills/legion-architect/SKILL.md +86 -0
- package/legion/skills/legion-architect/references/acceptance.md +208 -0
- package/legion/skills/legion-architect/references/decomposition.md +129 -0
- package/legion/skills/legion-architect/references/design-gate.md +206 -0
- package/legion/skills/legion-architect/references/merge-notices.md +91 -0
- package/legion/skills/legion-architect/references/wake-routing.md +41 -0
- package/legion/skills/legion-controller/SKILL.md +151 -0
- package/legion/skills/legion-controller/references/admission.md +22 -0
- package/legion/skills/legion-controller/references/backlog-stewardship.md +101 -0
- package/legion/skills/legion-controller/references/daily-report.md +75 -0
- package/legion/skills/legion-controller/references/escalation.md +82 -0
- package/legion/skills/legion-controller/references/finding-asks.md +181 -0
- package/legion/skills/legion-controller/references/parked-decisions.md +198 -0
- package/legion/skills/legion-controller/references/session.md +100 -0
- package/legion/skills/legion-controller/references/slack-mentions.md +154 -0
- package/legion/skills/legion-controller/references/triage.md +96 -0
- package/legion/skills/legion-controller/references/wakes.md +132 -0
- package/legion/skills/legion-oracle/SKILL.md +48 -0
- package/legion/skills/legion-retro/SKILL.md +112 -0
- package/legion/skills/legion-retro/references/learnings.md +60 -0
- package/legion/skills/legion-retro/references/pr-body-content.md +56 -0
- package/legion/skills/legion-worker/SKILL.md +69 -0
- package/legion/skills/legion-worker/references/cleanup-deletion.md +22 -0
- package/legion/skills/legion-worker/references/conflicts-and-rewrites.md +164 -0
- package/legion/skills/legion-worker/references/default-branch-check.md +53 -0
- package/legion/skills/legion-worker/references/findings.md +199 -0
- package/legion/skills/legion-worker/references/merge-gate.md +353 -0
- package/legion/skills/legion-worker/references/pr-body.md +218 -0
- package/legion/skills/legion-worker/references/review-threads.md +77 -0
- package/legion/skills/legion-worker/references/systematic-rename.md +19 -0
- package/legion/skills/legion-worker/references/tools.md +708 -0
- package/legion/skills/thermonuclear-code-quality/LICENSE +21 -0
- package/legion/skills/thermonuclear-code-quality/SKILL.md +60 -0
- package/legion/skills/thermonuclear-deep-review/LICENSE +21 -0
- package/legion/skills/thermonuclear-deep-review/SKILL.md +80 -0
- package/package.json +49 -1
- package/scripts/build-inputs.txt +84 -0
- package/scripts/build.ts +415 -0
- package/scripts/check-packed-skills.sh +50 -0
- package/scripts/pack-skills.sh +146 -0
- package/scripts/smoke-channel.sh +178 -0
- package/scripts/smoke-clear-rebind.sh +164 -0
- package/scripts/smoke-session-title.sh +245 -0
- package/scripts/verify-build.sh +88 -0
- package/skills/agent-secrets/SKILL.md +180 -0
- package/skills/asd-ste100/LICENSE +21 -0
- package/skills/asd-ste100/SKILL.md +140 -0
- package/skills/asd-ste100/examples/before-after.md +92 -0
- package/skills/asd-ste100/references/writing-rules.md +59 -0
- package/skills/dispatch/SKILL.md +89 -0
- package/skills/dispatch-first/SKILL.md +46 -0
- package/skills/dispatch-writing-specs/SKILL.md +80 -0
- package/skills/envoy/SKILL.md +82 -0
- package/skills/envoy/references/check-settlement.md +62 -0
- package/skills/envoy/references/deliveries.md +67 -0
- package/skills/envoy/references/legion.md +14 -0
- package/skills/envoy/references/roles.md +12 -0
- package/skills/envoy/references/sources.md +41 -0
- package/skills/outside-message/SKILL.md +111 -0
- package/skills/plain-words/SKILL.md +88 -0
- package/src/channel-forwarder.ts +262 -0
- package/src/claude-channel.ts +705 -0
- package/src/claude-session.ts +38 -0
- package/src/envoy-channel-server.ts +2115 -0
- package/src/envoy-client.ts +26 -0
- package/src/envoy-off.ts +62 -0
- package/src/legion-claim.ts +211 -0
- package/src/legion-inbox.ts +110 -0
- package/src/legion-phase-hook.ts +554 -0
- package/src/legion-tool.ts +69 -0
- package/src/replay-state.ts +97 -0
- package/src/send-arguments.ts +13 -0
- package/src/session-identity.ts +165 -0
- package/src/session-state.ts +142 -0
- package/src/session-title.ts +251 -0
- 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
|
+
}
|