@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/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
+ }