@sjawhar/claude-envoy 1.18.0 → 1.20.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 +2 -2
- package/.omp-plugin/plugin.json +2 -2
- package/AGENTS.md +167 -42
- package/README.md +95 -177
- package/dist/dispatch.js +54 -6
- package/dist/envoy-channel.js +2413 -875
- package/dist/session-hook.js +54 -6
- package/hooks/claude-mod.d.ts +500 -0
- package/hooks/hooks.json +2 -1
- package/hooks/mod-protocol.ts +728 -0
- package/hooks/mod-writer.ts +506 -0
- package/hooks/mod.ts +716 -0
- package/package.json +2 -2
- package/scripts/smoke-clear-rebind.sh +1 -1
- package/scripts/smoke-session-title.sh +1 -1
- package/skills/agent-secrets/SKILL.md +55 -4
- package/src/agent-stream-claude.ts +558 -0
- package/src/agent-stream-runner.ts +340 -0
- package/src/bounded-set.ts +54 -0
- package/src/channel-forwarder.ts +1 -1
- package/src/claude-rows.ts +195 -0
- package/src/envoy-channel-server.ts +394 -136
- package/src/envoy-off.ts +6 -1
- package/src/installed-build.ts +26 -0
- package/src/legion-inbox.ts +22 -11
- package/src/mod-link.ts +562 -0
- package/src/mod-session.ts +499 -0
- package/src/replay-state.ts +2 -18
- package/src/state-file.ts +39 -0
- package/src/user-turn-router.ts +496 -0
- package/src/user-turn-state.ts +134 -0
- package/scripts/smoke-channel.sh +0 -178
- package/src/claude-channel.ts +0 -705
package/hooks/mod.ts
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code mod this plugin loads through the `modules` key of `./hooks.json`: Claude Code
|
|
3
|
+
* runs it from source, in an environment of its own, and it sees every hook of the session.
|
|
4
|
+
*
|
|
5
|
+
* It carries the session to this plugin's MCP server over the Unix socket both sides name the
|
|
6
|
+
* same way (`./mod-protocol`): events one way, the server's requests the other. The server makes
|
|
7
|
+
* the Agents page's live view out of those events (`../src/agent-stream-runner.ts`) and routes a
|
|
8
|
+
* person's message back through those requests (`../src/user-turn-router.ts`); nothing of either
|
|
9
|
+
* is here.
|
|
10
|
+
*
|
|
11
|
+
* Nor is what a message becomes once a request carries it in: a row of the turn running or the
|
|
12
|
+
* prompt of a new turn, the one prompt at the rows no request read, and the write that happens
|
|
13
|
+
* once however many copies of an Envoy event arrive. Those rules are `./mod-writer.ts`, which
|
|
14
|
+
* this file gives the host, the event queue, the clock and the session id; what is left here is
|
|
15
|
+
* the transport and the hooks.
|
|
16
|
+
*
|
|
17
|
+
* Four rules hold everywhere below:
|
|
18
|
+
*
|
|
19
|
+
* - A hook always calls `next(e)` and returns its result. Every call on the engine and every
|
|
20
|
+
* post is wrapped, so a throw of the link's can never end a turn or a hook.
|
|
21
|
+
* - A mod that finds no server is silent: it connects over a bounded backoff and then stops for
|
|
22
|
+
* good, having changed nothing about the session. That is the release window, where a person
|
|
23
|
+
* has this mod beside the previously built server. It also has nowhere to complain to: the
|
|
24
|
+
* only surfaces a mod can write are the person's screen and the model's context, and this
|
|
25
|
+
* change adds to neither. The server says on its stderr what it could not do.
|
|
26
|
+
* - `$` is never passed, returned or read: Claude Code scans this module's source before it
|
|
27
|
+
* loads it and refuses one that spells `$` as anything but `$.noun.method(...)` at a call
|
|
28
|
+
* site (`claude plugin validate`). So the link below is handed a `ModHost`, one closure per
|
|
29
|
+
* call, built in the `session.start` hook where `$` is in scope. The writer is handed the
|
|
30
|
+
* part of it that writes (`./mod-writer`, `WriterHost`), for the same reason.
|
|
31
|
+
* - A hook whose event carries `agentId` passes it through and does nothing else: that event
|
|
32
|
+
* is a subagent's loop, not this session's turn (`ofSubagent`).
|
|
33
|
+
*
|
|
34
|
+
* It imports nothing but `./mod-protocol` and `./mod-writer`, which import nothing else at all:
|
|
35
|
+
* a mod can import neither a `node:` builtin nor an npm package, and `$.http.fetch` is its only
|
|
36
|
+
* way out.
|
|
37
|
+
*/
|
|
38
|
+
import type {
|
|
39
|
+
CommandInfo,
|
|
40
|
+
CommandRunArgs,
|
|
41
|
+
CommandRunResult,
|
|
42
|
+
ConfigRow,
|
|
43
|
+
ConfigSetArgs,
|
|
44
|
+
ConfigSetResult,
|
|
45
|
+
HttpInit,
|
|
46
|
+
HttpResponse,
|
|
47
|
+
ModelForkRequest,
|
|
48
|
+
ModelForkResult,
|
|
49
|
+
On,
|
|
50
|
+
SessionAppendDoor,
|
|
51
|
+
SessionAppendHook,
|
|
52
|
+
SessionVersion,
|
|
53
|
+
TurnStepChunk,
|
|
54
|
+
TurnStepInput,
|
|
55
|
+
TurnStepResult,
|
|
56
|
+
} from "claude-code"
|
|
57
|
+
import {
|
|
58
|
+
MOD_APPEND_DOORS,
|
|
59
|
+
MOD_HEALTH_MARKER,
|
|
60
|
+
MOD_INSTANCE_HEADER,
|
|
61
|
+
MOD_MIN_CLAUDE_VERSION,
|
|
62
|
+
MOD_ROUTE_EVENTS,
|
|
63
|
+
MOD_ROUTE_HEALTH,
|
|
64
|
+
MOD_ROUTE_REQUESTS,
|
|
65
|
+
MOD_ROUTE_RESULTS,
|
|
66
|
+
MOD_STATUS_TAKEN,
|
|
67
|
+
MOD_URL_BASE,
|
|
68
|
+
type ModAppendDoor,
|
|
69
|
+
type ModAppendMessage,
|
|
70
|
+
type ModAppendOrigin,
|
|
71
|
+
type ModEffort,
|
|
72
|
+
type ModEvent,
|
|
73
|
+
type ModEventsBody,
|
|
74
|
+
type ModRequest,
|
|
75
|
+
type ModResult,
|
|
76
|
+
type ModResultsBody,
|
|
77
|
+
type ModResultValue,
|
|
78
|
+
type ModStepPart,
|
|
79
|
+
type ModStopReason,
|
|
80
|
+
type ModTurnReason,
|
|
81
|
+
modVersionAtLeast,
|
|
82
|
+
resolveModSocketPath,
|
|
83
|
+
} from "./mod-protocol"
|
|
84
|
+
import { createMessageWriter, type WriterHost } from "./mod-writer"
|
|
85
|
+
|
|
86
|
+
/** Whether a row's door is one of the conversation's that this mod watches: the eight it
|
|
87
|
+
* registers for, which is every door but the ones the host hides from a mod a person installed
|
|
88
|
+
* and `queue`, which is no row of the conversation. */
|
|
89
|
+
function isWatchedDoor(door: SessionAppendDoor): door is ModAppendDoor {
|
|
90
|
+
const watched: readonly string[] = MOD_APPEND_DOORS
|
|
91
|
+
return watched.includes(door)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How long to wait before each further attempt at `GET /healthz`, in milliseconds: eleven probes
|
|
95
|
+
* over about 31 seconds, growing. The server binds its socket while the MCP server starts, which
|
|
96
|
+
* is after Claude Code has run this mod's `session.start`, so the first probes normally miss. */
|
|
97
|
+
const CONNECT_DELAYS_MS = [250, 500, 1_000, 2_000, 3_000, 4_000, 5_000, 5_000, 5_000, 5_000]
|
|
98
|
+
|
|
99
|
+
/** The shortest gap between two snapshots of one `turn.step` while it streams. */
|
|
100
|
+
const STEP_SNAPSHOT_MS = 100
|
|
101
|
+
|
|
102
|
+
/** The shortest a poll pass may take when it answered nothing, so a server that answers at once
|
|
103
|
+
* without holding (a wrong one, or one that is refusing) cannot spin this loop. */
|
|
104
|
+
const POLL_FLOOR_MS = 250
|
|
105
|
+
|
|
106
|
+
/** How long to watch for the new session id after `session.end`, in tries and their gap: it is
|
|
107
|
+
* there within about 100 ms of the hook, and the process lives on only after a `/clear`. */
|
|
108
|
+
const SESSION_ID_TRIES = 10
|
|
109
|
+
const SESSION_ID_WAIT_MS = 30
|
|
110
|
+
|
|
111
|
+
/** The most events held while no server is connected. The live view's own frames are a later
|
|
112
|
+
* task's and are rebuilt from the session, so the oldest of an overflow is the one to drop. */
|
|
113
|
+
const EVENT_QUEUE_MAX = 2048
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Everything the link does to the session, one closure per call on `$`: the transport's own
|
|
117
|
+
* calls, and the writer's (`./mod-writer`, `WriterHost`).
|
|
118
|
+
*
|
|
119
|
+
* It exists because `$` may not leave the hook it was given to: the host reads this module's
|
|
120
|
+
* source and refuses a `$` that is passed, bound, spread, returned or read. Each closure spells
|
|
121
|
+
* its call in full at its own call site, which is what the host asks for, and what it then lists
|
|
122
|
+
* as the module's calls and the variables it reads.
|
|
123
|
+
*/
|
|
124
|
+
interface ModHost extends WriterHost {
|
|
125
|
+
readonly fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
|
|
126
|
+
readonly sleep: (ms: number) => Promise<void>
|
|
127
|
+
readonly sessionId: () => Promise<string>
|
|
128
|
+
readonly version: () => Promise<SessionVersion>
|
|
129
|
+
readonly model: () => Promise<string>
|
|
130
|
+
/** `XDG_RUNTIME_DIR`, the first base the socket path is looked for in. */
|
|
131
|
+
readonly runtimeDirectory: () => Promise<string | undefined>
|
|
132
|
+
/** `CLAUDE_CONFIG_DIR`, Claude Code's own configuration directory, the second base. */
|
|
133
|
+
readonly configDirectory: () => Promise<string | undefined>
|
|
134
|
+
/** `HOME`, whose `.claude` is that configuration directory when the variable names none. */
|
|
135
|
+
readonly homeDirectory: () => Promise<string | undefined>
|
|
136
|
+
readonly runCommand: (args: CommandRunArgs) => Promise<CommandRunResult>
|
|
137
|
+
readonly listCommands: () => Promise<CommandInfo[]>
|
|
138
|
+
readonly fork: (request: ModelForkRequest) => Promise<ModelForkResult>
|
|
139
|
+
readonly setConfig: (args: ConfigSetArgs) => Promise<ConfigSetResult>
|
|
140
|
+
readonly listConfig: () => Promise<ConfigRow[]>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What the hooks below drive: the session's half of the link. The server's half is `ModLink` in
|
|
144
|
+
* `../src/mod-link.ts`; the two meet on the socket alone. */
|
|
145
|
+
interface SessionLink {
|
|
146
|
+
/** Name the socket and start the poll loop, once, at the first `session.start`. */
|
|
147
|
+
readonly begin: (host: ModHost, interactive: boolean) => Promise<void>
|
|
148
|
+
/** Queue one event and start it on its way; never throws. */
|
|
149
|
+
readonly send: (event: ModEvent) => void
|
|
150
|
+
/** A turn started: reported, and taken as the turn a message handed over now is a row of. */
|
|
151
|
+
readonly turnStarted: (turnId: string) => void
|
|
152
|
+
/** A turn ended: reported, and every row of this mod's that no request of that turn read is
|
|
153
|
+
* pointed at by one prompt (`unreadRowPrompt`). */
|
|
154
|
+
readonly turnEnded: (turnId: string, reason: ModTurnReason) => void
|
|
155
|
+
/** A request of the turn is going out, counted at the top of the `turn.step` hook: a row
|
|
156
|
+
* stored before this moment is in this request, and one stored after it is in none. */
|
|
157
|
+
readonly stepRequested: () => void
|
|
158
|
+
/** One row of the conversation as the `session.append` hook handed it: stamped where this mod
|
|
159
|
+
* wrote it, and sent. */
|
|
160
|
+
readonly row: (row: {
|
|
161
|
+
readonly uuid: string
|
|
162
|
+
readonly door: ModAppendDoor
|
|
163
|
+
readonly origin: ModAppendOrigin
|
|
164
|
+
readonly message: ModAppendMessage
|
|
165
|
+
}) => void
|
|
166
|
+
/** Watch for the id a `/clear` moves the session to and send it when it arrives. */
|
|
167
|
+
readonly followSessionId: () => Promise<void>
|
|
168
|
+
/** The effort the server last asked for, for the next `turn.step` that names one of its own;
|
|
169
|
+
* null for none. */
|
|
170
|
+
readonly effortNow: () => ModEffort | null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `error`'s text, bounded: it crosses as one JSON string into a result the server rejects with. */
|
|
174
|
+
function errorText(error: unknown): string {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
176
|
+
return message.slice(0, 500)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The requests one `POST /requests` answered with. The only writer of this socket is the server
|
|
180
|
+
* that bound it, so an item that is shaped like a request at all is the request it says it is;
|
|
181
|
+
* anything else, and a body that is not an answer at all, is nothing to do. */
|
|
182
|
+
function readRequests(text: string): readonly ModRequest[] {
|
|
183
|
+
let body: unknown
|
|
184
|
+
try {
|
|
185
|
+
body = JSON.parse(text)
|
|
186
|
+
} catch {
|
|
187
|
+
return []
|
|
188
|
+
}
|
|
189
|
+
if (typeof body !== "object" || body === null || !("requests" in body)) return []
|
|
190
|
+
const requests = body.requests
|
|
191
|
+
if (!Array.isArray(requests)) return []
|
|
192
|
+
return requests.filter((request: unknown): request is ModRequest => {
|
|
193
|
+
if (typeof request !== "object" || request === null) return false
|
|
194
|
+
if (!("id" in request) || !("call" in request)) return false
|
|
195
|
+
return typeof request.id === "string" && typeof request.call === "string"
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The link to the server, all of it closure state: one mod instance drives one session. */
|
|
200
|
+
function createLink(): SessionLink {
|
|
201
|
+
/** This Claude Code process's mod, minted here: the link serves the first instance that names
|
|
202
|
+
* itself and refuses every other, so two processes that booted on one session id (`claude
|
|
203
|
+
* -c`, and `claude --resume <id>` in a second terminal) no longer share one server, one live
|
|
204
|
+
* view and one person's Send (INFRA-1026 review). The time and the random part together are
|
|
205
|
+
* as distinct as two processes of one session need: they rule out the one other instance this
|
|
206
|
+
* socket can see. */
|
|
207
|
+
const instance = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
208
|
+
let host: ModHost | undefined
|
|
209
|
+
let socketPath: string | undefined
|
|
210
|
+
/** True while the last probe or post succeeded. */
|
|
211
|
+
let connected = false
|
|
212
|
+
/** True once the link gave up for good, or decided never to start. */
|
|
213
|
+
let finished = false
|
|
214
|
+
let started = false
|
|
215
|
+
/** The id the socket is named for, which a `/clear` does not move. */
|
|
216
|
+
let currentSessionId: string | undefined
|
|
217
|
+
/** The effort the server last asked for, put on every later `turn.step` that names an effort
|
|
218
|
+
* of its own; null for none. */
|
|
219
|
+
let effort: ModEffort | null = null
|
|
220
|
+
const queued: ModEvent[] = []
|
|
221
|
+
let flushing = false
|
|
222
|
+
|
|
223
|
+
const sleep = async (ms: number): Promise<void> => {
|
|
224
|
+
try {
|
|
225
|
+
await host?.sleep(ms)
|
|
226
|
+
} catch {
|
|
227
|
+
// A refused wait is the host's business; the caller's loop carries on.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The response body of a request that succeeded, or undefined for any failure at all: a socket
|
|
232
|
+
* that has gone, a refused request, a body that never arrived. A post the server refuses
|
|
233
|
+
* because another mod holds the link ends this one for good: that server belongs to the
|
|
234
|
+
* process that got there first, and there is no second socket to try. */
|
|
235
|
+
const fetch = async (route: string, body: string | undefined): Promise<string | undefined> => {
|
|
236
|
+
if (host === undefined || socketPath === undefined) return undefined
|
|
237
|
+
try {
|
|
238
|
+
const response = await host.fetch(
|
|
239
|
+
`${MOD_URL_BASE}${route}`,
|
|
240
|
+
body === undefined
|
|
241
|
+
? { socketPath }
|
|
242
|
+
: {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "content-type": "application/json", [MOD_INSTANCE_HEADER]: instance },
|
|
245
|
+
body,
|
|
246
|
+
socketPath,
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
if (response.status === MOD_STATUS_TAKEN) {
|
|
250
|
+
finished = true
|
|
251
|
+
connected = false
|
|
252
|
+
// Nothing will read these again: `send` refuses every later event and `flush` posts only
|
|
253
|
+
// while connected.
|
|
254
|
+
queued.length = 0
|
|
255
|
+
return undefined
|
|
256
|
+
}
|
|
257
|
+
return response.ok ? response.text : undefined
|
|
258
|
+
} catch {
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Whether a server of ours answers the socket now. */
|
|
264
|
+
const probe = async (): Promise<boolean> => {
|
|
265
|
+
const text = await fetch(MOD_ROUTE_HEALTH, undefined)
|
|
266
|
+
if (text === undefined) return false
|
|
267
|
+
let body: unknown
|
|
268
|
+
try {
|
|
269
|
+
body = JSON.parse(text)
|
|
270
|
+
} catch {
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
if (typeof body !== "object" || body === null || !("link" in body)) return false
|
|
274
|
+
return body.link === MOD_HEALTH_MARKER
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Probe until a server answers or the backoff is spent; false means stop for good. */
|
|
278
|
+
const connect = async (): Promise<boolean> => {
|
|
279
|
+
for (const delay of CONNECT_DELAYS_MS) {
|
|
280
|
+
if (await probe()) {
|
|
281
|
+
connected = true
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
284
|
+
await sleep(delay)
|
|
285
|
+
}
|
|
286
|
+
connected = await probe()
|
|
287
|
+
return connected
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Post everything queued, oldest first, one batch at a time so no event overtakes another. A
|
|
291
|
+
* batch that fails is dropped and the link counts as lost: the poll loop reconnects. */
|
|
292
|
+
const flush = async (): Promise<void> => {
|
|
293
|
+
if (flushing) return
|
|
294
|
+
flushing = true
|
|
295
|
+
try {
|
|
296
|
+
while (connected && queued.length > 0) {
|
|
297
|
+
const events = queued.splice(0, queued.length)
|
|
298
|
+
const body = { events } satisfies ModEventsBody
|
|
299
|
+
if ((await fetch(MOD_ROUTE_EVENTS, JSON.stringify(body))) === undefined) {
|
|
300
|
+
connected = false
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
flushing = false
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Queue one event and start it on its way. Never throws: every hook calls this. */
|
|
310
|
+
const send = (event: ModEvent): void => {
|
|
311
|
+
if (finished) return
|
|
312
|
+
queued.push(event)
|
|
313
|
+
if (queued.length > EVENT_QUEUE_MAX) queued.splice(0, queued.length - EVENT_QUEUE_MAX)
|
|
314
|
+
void flush().catch(() => undefined)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** What a message becomes in this session, and what points the model at a row no request
|
|
318
|
+
* read: the rules this link carries between the socket and the session, in a file of their
|
|
319
|
+
* own (`./mod-writer`). A write is handed the request's own `engine` (`invoke`); the writer
|
|
320
|
+
* reads `engine` below only for the prompt it submits at a turn's end, where no request is
|
|
321
|
+
* in hand, and holds the turn's state itself. */
|
|
322
|
+
const writer = createMessageWriter({
|
|
323
|
+
engine: () => host,
|
|
324
|
+
send,
|
|
325
|
+
sleep,
|
|
326
|
+
sessionId: () => currentSessionId,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
/** Make the call a request names and answer with its own value. */
|
|
330
|
+
const invoke = async (engine: ModHost, request: ModRequest): Promise<ModResultValue> => {
|
|
331
|
+
switch (request.call) {
|
|
332
|
+
case "session.message": {
|
|
333
|
+
const { eventId } = request.args
|
|
334
|
+
return eventId === undefined
|
|
335
|
+
? await writer.writeMessage(engine, request.args)
|
|
336
|
+
: await writer.writeOnce(engine, eventId, request.args)
|
|
337
|
+
}
|
|
338
|
+
case "command.run":
|
|
339
|
+
return await engine.runCommand(request.args)
|
|
340
|
+
case "model.fork":
|
|
341
|
+
return await engine.fork(request.args)
|
|
342
|
+
case "config.set": {
|
|
343
|
+
const answer = await engine.setConfig(request.args)
|
|
344
|
+
return answer.deny === undefined ? { value: answer.value } : { deny: answer.deny }
|
|
345
|
+
}
|
|
346
|
+
case "session.model":
|
|
347
|
+
return await engine.model()
|
|
348
|
+
case "config.list":
|
|
349
|
+
return await engine.listConfig()
|
|
350
|
+
case "command.list":
|
|
351
|
+
return await engine.listCommands()
|
|
352
|
+
case "set-effort":
|
|
353
|
+
effort = request.args.effort
|
|
354
|
+
return { effort }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Run one request and post its result. A call that throws answers the throw, not nothing. */
|
|
359
|
+
const answer = async (request: ModRequest): Promise<void> => {
|
|
360
|
+
let result: ModResult
|
|
361
|
+
try {
|
|
362
|
+
if (host === undefined) throw new Error("the session is gone")
|
|
363
|
+
result = { id: request.id, ok: true, value: await invoke(host, request) }
|
|
364
|
+
} catch (error) {
|
|
365
|
+
result = { id: request.id, ok: false, error: errorText(error) }
|
|
366
|
+
}
|
|
367
|
+
const body = { results: [result] } satisfies ModResultsBody
|
|
368
|
+
await fetch(MOD_ROUTE_RESULTS, JSON.stringify(body))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Connect, then poll for work until the server goes for good. */
|
|
372
|
+
const pump = async (): Promise<void> => {
|
|
373
|
+
while (!finished) {
|
|
374
|
+
if (!connected && !(await connect())) {
|
|
375
|
+
finished = true
|
|
376
|
+
// Dropped rather than kept: `send` refuses every later event now, and `flush` only posts
|
|
377
|
+
// while connected, so nothing will ever read these again. They are not free to hold - a
|
|
378
|
+
// queued `step` carries its whole accumulated text, so a step that streamed through the
|
|
379
|
+
// backoff leaves one full copy of itself per snapshot pinned for the session's life.
|
|
380
|
+
queued.length = 0
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
const pollStartedAt = Date.now()
|
|
384
|
+
await flush()
|
|
385
|
+
const text = await fetch(MOD_ROUTE_REQUESTS, "{}")
|
|
386
|
+
if (text === undefined) {
|
|
387
|
+
// The server stopped, or refused: back to the bounded backoff rather than spinning.
|
|
388
|
+
connected = false
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
const requests = readRequests(text)
|
|
392
|
+
// Each request runs on its own: `session.message` serialises what has to be serialised
|
|
393
|
+
// inside itself — the write for one Envoy event id, and the choice between a row and a
|
|
394
|
+
// prompt, which is made in one tick (`writeOnce`, `writeMessage`). A chain here would
|
|
395
|
+
// instead hold a call behind a `$.command.run` waiting on a dialog at the terminal.
|
|
396
|
+
for (const request of requests) void answer(request).catch(() => undefined)
|
|
397
|
+
if (requests.length === 0 && Date.now() - pollStartedAt < POLL_FLOOR_MS) {
|
|
398
|
+
await sleep(POLL_FLOOR_MS)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Read the session's model and send it; a failure costs nothing. */
|
|
404
|
+
const sendModel = async (engine: ModHost): Promise<void> => {
|
|
405
|
+
try {
|
|
406
|
+
send({ kind: "model", model: await engine.model() })
|
|
407
|
+
} catch {
|
|
408
|
+
// The model is one event, not a reason to fail a hook.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Watch for the id a `/clear` moves the session to, which arrives shortly after the hook. */
|
|
413
|
+
const followSessionId = async (): Promise<void> => {
|
|
414
|
+
for (let attempt = 0; attempt < SESSION_ID_TRIES; attempt += 1) {
|
|
415
|
+
await sleep(SESSION_ID_WAIT_MS)
|
|
416
|
+
let id: string
|
|
417
|
+
try {
|
|
418
|
+
if (host === undefined) return
|
|
419
|
+
id = await host.sessionId()
|
|
420
|
+
} catch {
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
if (id !== currentSessionId) {
|
|
424
|
+
currentSessionId = id
|
|
425
|
+
send({ kind: "session-id", sessionId: id })
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Name the socket, refuse a Claude Code too old for the hooks and calls this mod is written
|
|
432
|
+
* against, and start the poll loop. Never throws, and never waits on the connect: a session
|
|
433
|
+
* does not start more slowly because a server is not up yet. */
|
|
434
|
+
const begin = async (engine: ModHost, interactive: boolean): Promise<void> => {
|
|
435
|
+
if (started) return
|
|
436
|
+
started = true
|
|
437
|
+
host = engine
|
|
438
|
+
try {
|
|
439
|
+
const version = await engine.version()
|
|
440
|
+
if (!modVersionAtLeast(version.version, MOD_MIN_CLAUDE_VERSION)) {
|
|
441
|
+
finished = true
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
// The boot id: `$.session.id()` at the first `session.start` is the id Claude Code gave the
|
|
445
|
+
// MCP server as CLAUDE_CODE_SESSION_ID, and a later `/clear` does not move the socket.
|
|
446
|
+
const sessionId = await engine.sessionId()
|
|
447
|
+
currentSessionId = sessionId
|
|
448
|
+
const resolved = resolveModSocketPath(sessionId, {
|
|
449
|
+
XDG_RUNTIME_DIR: await engine.runtimeDirectory(),
|
|
450
|
+
CLAUDE_CONFIG_DIR: await engine.configDirectory(),
|
|
451
|
+
HOME: await engine.homeDirectory(),
|
|
452
|
+
})
|
|
453
|
+
if (resolved.path === undefined) {
|
|
454
|
+
finished = true
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
socketPath = resolved.path
|
|
458
|
+
send({ kind: "hello", sessionId, claudeVersion: version.version, interactive })
|
|
459
|
+
void sendModel(engine).catch(() => undefined)
|
|
460
|
+
void pump().catch(() => undefined)
|
|
461
|
+
} catch {
|
|
462
|
+
finished = true
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** A turn started: the writer takes it as the turn a message handed over now is a row of,
|
|
467
|
+
* and the server is told. */
|
|
468
|
+
const turnStarted = (turnId: string): void => {
|
|
469
|
+
writer.turnStarted(turnId)
|
|
470
|
+
send({ kind: "turn-start", turnId })
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The turn ended: the server is told, and the writer carries every row of this mod's that
|
|
474
|
+
* no request of that turn read into one prompt of its own (`unreadRowPrompt`). The event
|
|
475
|
+
* goes first, as it did when the two were one function: the prompt the writer submits is a
|
|
476
|
+
* row of the conversation after this turn, never inside it. */
|
|
477
|
+
const turnEnded = (turnId: string, reason: ModTurnReason): void => {
|
|
478
|
+
send({ kind: "turn-complete", turnId, stopReason: reason })
|
|
479
|
+
writer.turnEnded(turnId)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** One row of the conversation, stamped where this mod wrote it. */
|
|
483
|
+
const row = (arrived: {
|
|
484
|
+
readonly uuid: string
|
|
485
|
+
readonly door: ModAppendDoor
|
|
486
|
+
readonly origin: ModAppendOrigin
|
|
487
|
+
readonly message: ModAppendMessage
|
|
488
|
+
}): void => {
|
|
489
|
+
const wrote = arrived.door === "prompt" ? writer.claimRow(arrived.message.content) : undefined
|
|
490
|
+
send({ kind: "append", ...arrived, ...(wrote === undefined ? {} : { wrote }) })
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
begin,
|
|
495
|
+
send,
|
|
496
|
+
turnStarted,
|
|
497
|
+
turnEnded,
|
|
498
|
+
stepRequested: writer.stepRequested,
|
|
499
|
+
row,
|
|
500
|
+
followSessionId,
|
|
501
|
+
effortNow: (): ModEffort | null => effort,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The link's own accumulation of one `turn.step`: the parts as they stream, and which part each
|
|
506
|
+
* content block of the response is building. */
|
|
507
|
+
interface StepParts {
|
|
508
|
+
readonly parts: ModStepPart[]
|
|
509
|
+
readonly blocks: Map<number, number>
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Add one text or thinking piece to the part its block is building, opening one if it is new. */
|
|
513
|
+
function appendText(
|
|
514
|
+
step: StepParts,
|
|
515
|
+
index: number,
|
|
516
|
+
kind: "text" | "reasoning",
|
|
517
|
+
text: string,
|
|
518
|
+
): void {
|
|
519
|
+
const at = step.blocks.get(index)
|
|
520
|
+
const existing = at === undefined ? undefined : step.parts[at]
|
|
521
|
+
const merged = existing !== undefined && existing.type === kind ? existing.text + text : text
|
|
522
|
+
const part: ModStepPart =
|
|
523
|
+
kind === "text" ? { type: "text", text: merged } : { type: "reasoning", text: merged }
|
|
524
|
+
if (at !== undefined && existing !== undefined && existing.type === kind) step.parts[at] = part
|
|
525
|
+
else {
|
|
526
|
+
step.blocks.set(index, step.parts.length)
|
|
527
|
+
step.parts.push(part)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Fold one streamed chunk into the step's parts. The engine's own items and the stop carry
|
|
532
|
+
* nothing the live view shows, so they only pass through. */
|
|
533
|
+
function accumulate(step: StepParts, chunk: TurnStepChunk): void {
|
|
534
|
+
if (chunk.kind === "text") appendText(step, chunk.index, "text", chunk.text)
|
|
535
|
+
else if (chunk.kind === "thinking") appendText(step, chunk.index, "reasoning", chunk.text)
|
|
536
|
+
else if (chunk.kind === "tool") {
|
|
537
|
+
step.blocks.set(chunk.index, step.parts.length)
|
|
538
|
+
step.parts.push({ type: "tool-call", id: chunk.id, name: chunk.name, args: "" })
|
|
539
|
+
} else if (chunk.kind === "input") {
|
|
540
|
+
const at = step.blocks.get(chunk.index)
|
|
541
|
+
const existing = at === undefined ? undefined : step.parts[at]
|
|
542
|
+
if (at !== undefined && existing !== undefined && existing.type === "tool-call") {
|
|
543
|
+
step.parts[at] = { ...existing, args: existing.args + chunk.json }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** This session's link, one per loaded module, so the hook the `session.append` registrations
|
|
549
|
+
* share can be declared here: the host resolves a named hook only against this file's own
|
|
550
|
+
* top-level functions. */
|
|
551
|
+
const link = createLink()
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Whether an event is a subagent's rather than this session's own main loop, which every hook
|
|
555
|
+
* whose event can carry that key reads before it does anything else.
|
|
556
|
+
*
|
|
557
|
+
* A subagent runs a loop of its own: its own turn, its own requests, its own rows. None of it
|
|
558
|
+
* is the session's turn - a foreground subagent's whole turn opens and closes *inside* the main
|
|
559
|
+
* turn, between two of its steps, and a background subagent's turn can still be open after the
|
|
560
|
+
* main turn completed - so counted as the main turn's it moves where the next message goes,
|
|
561
|
+
* what counts as read, and what the live view draws, for a loop the person is not in.
|
|
562
|
+
*
|
|
563
|
+
* Claude Code says which loop an event is in by one key: measured on 2.1.310, a subagent's
|
|
564
|
+
* `turn.start`, `turn.step`, `turn.complete`, `tool.call`, `tool.check`, `prompt.attachment`,
|
|
565
|
+
* `session.receive` and `session.append` (on every door seen) carry `agentId` - `a` and 16 hex
|
|
566
|
+
* digits - while the main loop's carry no such key at all, in the same run and the same log
|
|
567
|
+
* (`/legion/scratch/tmp/infra-1026-turn`). So the rule is the key and not a turn this mod
|
|
568
|
+
* watched start: when the plugin itself spawns a subagent, that loop's `turn.start` and
|
|
569
|
+
* `turn.step` never reach the spawning plugin and only its `turn.complete` does, which a rule
|
|
570
|
+
* keyed off "I saw this turn start" would take for the main turn's end.
|
|
571
|
+
*/
|
|
572
|
+
function ofSubagent(e: { readonly agentId?: string }): boolean {
|
|
573
|
+
return e.agentId !== undefined
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** One row of the conversation, by whichever door it came in. The door is read off the row
|
|
577
|
+
* rather than the registration, so the eight registrations share this one hook. A subagent's
|
|
578
|
+
* row is its loop's and reaches neither the live view nor this mod's watch of the turn. */
|
|
579
|
+
const appended: SessionAppendHook = async (_, e, next) => {
|
|
580
|
+
if (!ofSubagent(e) && isWatchedDoor(e.door)) {
|
|
581
|
+
link.row({ uuid: e.uuid, door: e.door, origin: e.origin, message: e.message })
|
|
582
|
+
}
|
|
583
|
+
return await next(e)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The hooks this mod registers, and nothing else. In particular no `classic.*` hook: on a
|
|
587
|
+
* machine with managed settings Claude Code's built-in guard writes a notice into what the model
|
|
588
|
+
* reads for every hook it skips, and this change adds nothing to what the agent reads. For the
|
|
589
|
+
* same reason `session.append` is registered for the doors of the conversation alone. */
|
|
590
|
+
export function register(on: On): void {
|
|
591
|
+
on("session.start", async ($, e, next) => {
|
|
592
|
+
const result = await next(e)
|
|
593
|
+
// The one place `$` is spelled: every call the link makes is a closure of its own, built
|
|
594
|
+
// here, so `$` itself never leaves this hook.
|
|
595
|
+
await link.begin(
|
|
596
|
+
{
|
|
597
|
+
fetch: (url, init) => $.http.fetch(url, init),
|
|
598
|
+
sleep: (ms) => $.clock.sleep(ms),
|
|
599
|
+
sessionId: () => $.session.id(),
|
|
600
|
+
version: () => $.session.version(),
|
|
601
|
+
model: () => $.session.model(),
|
|
602
|
+
runtimeDirectory: () => $.env.get("XDG_RUNTIME_DIR"),
|
|
603
|
+
configDirectory: () => $.env.get("CLAUDE_CONFIG_DIR"),
|
|
604
|
+
homeDirectory: () => $.env.get("HOME"),
|
|
605
|
+
append: (args) => $.session.append(args),
|
|
606
|
+
submit: (args) => $.prompt.submit(args),
|
|
607
|
+
runCommand: (args) => $.command.run(args),
|
|
608
|
+
listCommands: () => $.command.list(),
|
|
609
|
+
fork: (request) => $.model.fork(request),
|
|
610
|
+
setConfig: (args) => $.config.set(args),
|
|
611
|
+
listConfig: () => $.config.list(),
|
|
612
|
+
storeGet: (key) => $.store.get(key),
|
|
613
|
+
storeSet: (key, value) => $.store.set(key, value),
|
|
614
|
+
storeDelete: (key) => $.store.delete(key),
|
|
615
|
+
storeKeys: () => $.store.keys(),
|
|
616
|
+
},
|
|
617
|
+
e.isInteractive,
|
|
618
|
+
)
|
|
619
|
+
return result
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
on("session.end", async (_, e, next) => {
|
|
623
|
+
link.send({ kind: "session-end", reason: e.reason })
|
|
624
|
+
const result = await next(e)
|
|
625
|
+
void link.followSessionId().catch(() => undefined)
|
|
626
|
+
return result
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
on("turn.start", async (_, e, next) => {
|
|
630
|
+
if (ofSubagent(e)) return await next(e)
|
|
631
|
+
link.turnStarted(e.turnId)
|
|
632
|
+
return await next(e)
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
on("turn.complete", async (_, e, next) => {
|
|
636
|
+
if (ofSubagent(e)) return await next(e)
|
|
637
|
+
link.turnEnded(e.turnId, e.reason)
|
|
638
|
+
return await next(e)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
on("turn.step", async function* (_, e, next) {
|
|
642
|
+
// The effort the request really carries. Claude Code names the effort of the model the
|
|
643
|
+
// request is for, and names none for a model that takes none: measured on 2.1.310, a request
|
|
644
|
+
// of `claude-opus-5-5` before any pick carried `output_config.effort: medium`, while with a
|
|
645
|
+
// pick of `low` in force a request of `claude-haiku-4-5` carried `thinking=None` and dropped
|
|
646
|
+
// the effort set here (the stack's model-request log, 2026-11-05). So the server's pick rides
|
|
647
|
+
// on an effort Claude Code named and never fills in an absent one, and the step reports what
|
|
648
|
+
// went out rather than what was set: a step that echoed a pick its own request did not carry
|
|
649
|
+
// held the live view's header on a level nothing was thinking at (INFRA-1026).
|
|
650
|
+
const set = link.effortNow()
|
|
651
|
+
const request: TurnStepInput =
|
|
652
|
+
set === null || e.effort === undefined ? e : { ...e, effort: set }
|
|
653
|
+
// The pick is the session's, so it is on this request whichever loop made it: a subagent's
|
|
654
|
+
// step carries the session's own effort (measured on 2.1.310), and a pick left off it ran
|
|
655
|
+
// every Task subagent at the level Claude Code had saved while the header read the pick
|
|
656
|
+
// (INFRA-1026 review, round five). What is skipped for a subagent is this session's
|
|
657
|
+
// bookkeeping, which its loop is none of: its request raises no count here - a row stored
|
|
658
|
+
// after the main turn's last request looked read by a subagent's step and was never quoted -
|
|
659
|
+
// and the live view publishes nothing of it, which is where the spec puts a subagent's work
|
|
660
|
+
// ("their work shows as the parent's tool call").
|
|
661
|
+
if (ofSubagent(e)) return yield* next(request)
|
|
662
|
+
const step: StepParts = { parts: [], blocks: new Map() }
|
|
663
|
+
const snapshot = (done: boolean, stopReason?: ModStopReason): void => {
|
|
664
|
+
link.send({
|
|
665
|
+
kind: "step",
|
|
666
|
+
turnId: request.turnId,
|
|
667
|
+
index: request.index,
|
|
668
|
+
model: request.model,
|
|
669
|
+
...(request.effort === undefined ? {} : { effort: request.effort }),
|
|
670
|
+
parts: [...step.parts],
|
|
671
|
+
done,
|
|
672
|
+
...(stopReason === undefined ? {} : { stopReason }),
|
|
673
|
+
})
|
|
674
|
+
}
|
|
675
|
+
// Before the request: the rows stored by now are the ones it carries, and a row stored
|
|
676
|
+
// after this moment is in no request of this step. Counted here rather than reported,
|
|
677
|
+
// because the one reader of that count is this mod (`SessionLink.stepRequested`).
|
|
678
|
+
link.stepRequested()
|
|
679
|
+
const stream = next(request)
|
|
680
|
+
let snapshotAt = 0
|
|
681
|
+
for await (const chunk of stream) {
|
|
682
|
+
accumulate(step, chunk)
|
|
683
|
+
const now = Date.now()
|
|
684
|
+
if (now - snapshotAt >= STEP_SNAPSHOT_MS) {
|
|
685
|
+
snapshotAt = now
|
|
686
|
+
snapshot(false)
|
|
687
|
+
}
|
|
688
|
+
yield chunk
|
|
689
|
+
}
|
|
690
|
+
let result: TurnStepResult | undefined
|
|
691
|
+
try {
|
|
692
|
+
result = await stream.result
|
|
693
|
+
} catch {
|
|
694
|
+
result = undefined
|
|
695
|
+
}
|
|
696
|
+
if (result === undefined) {
|
|
697
|
+
// The step failed beneath: say it is over and leave the result to the chain.
|
|
698
|
+
snapshot(true)
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
snapshot(true, result.stopReason)
|
|
702
|
+
return result
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
// One registration per door, each door spelled where it is registered: the host reads the
|
|
706
|
+
// matchers off this source (`claude plugin validate` prints them), and a door it cannot read
|
|
707
|
+
// is a door it cannot attest this mod stays away from.
|
|
708
|
+
on("session.append", { door: "prompt" }, appended)
|
|
709
|
+
on("session.append", { door: "command" }, appended)
|
|
710
|
+
on("session.append", { door: "response" }, appended)
|
|
711
|
+
on("session.append", { door: "tool-result" }, appended)
|
|
712
|
+
on("session.append", { door: "tool-message" }, appended)
|
|
713
|
+
on("session.append", { door: "delivery" }, appended)
|
|
714
|
+
on("session.append", { door: "note" }, appended)
|
|
715
|
+
on("session.append", { door: "notice" }, appended)
|
|
716
|
+
}
|