Summary
Workflow 4 of 5 from the 2026-05-03 performance review of main. Parent: #46. Sibling: #75 (extension JSONL ingestion). Six findings on scripts/relay.ts and the webview message bus. Two are correctness bugs (cleanup leak, hook-event duplication); the rest are throughput / lifecycle wins.
Critical
CR-5 · relay.ts dispose() leaks subagent watchers, dir watchers, and permission timers
scripts/relay.ts:633-661 vs extension/src/session-watcher.ts:559-587 (the working reference)
- Relay loops
for (const session of sessions.values()) and only closes fileWatcher, pollTimer, inactivityTimer. NOT closed:
session.subagentsDirWatcher (fs.FSWatcher set in subagent-watcher.ts:55)
- Each
session.subagentWatchers[*].watcher (per-subagent-file watchers)
session.permissionTimer and per-subagent permission timers
parser.clearSessionState(...) is not called
- The two dispose paths share the same
WatchedSession shape but the cleanup logic diverged — the extension's is correct.
- Also: there is no per-session cleanup hook. When a session ends, watchers and parser state stay alive until full process dispose.
- Fix: mirror
SessionWatcher.dispose() cleanup loop into the relay; add a per-session disposeSession(sessionId) invoked from agent_complete/inactivity paths. Pairs naturally with IR-21.
CR-6 · Hook events bypass eventBuffer + agentSnapshots and skip dedup
scripts/relay.ts:470-472:
hookServer.onEvent((event) => {
broadcast(JSON.stringify({ type: 'agent-event', event }))
})
- All other event paths route through
broadcastEvent (relay.ts:148-168) which (a) increments sessionEventCount, (b) tracks observedModels, (c) pushes into eventBuffer for replay, (d) updates agentSnapshots for late-connecting clients
- The hook handler skips all of this. Consequences:
- Late-connecting SSE clients miss hook-originated events on replay
agentSnapshots doesn't capture hook-only spawns
sessionEventCount undercounts → telemetry wrong
- Hook + transcript watcher both fire for the same session in the common case →
agent_spawn/agent_complete/subagent_dispatch/subagent_return double-fire to all clients
- The dedup logic that should be applied was deliberately written for the extension at
extension/src/claude-runtime.ts:88-119 — it filters subagent lifecycle events when the watcher is already handling the session — but was not ported to the relay.
- Fix: route hook events through
broadcastEvent AND port the dedup filter (predicate is sessions.has(eventSessionId)).
- Open: is hook delivery from the relay still useful (standalone-app users without the extension), or vestigial? If vestigial, simpler fix is to remove the entire hookServer block from
relay.ts.
Important
IR-18 · No SSE backpressure handling
scripts/relay.ts:73: res.write(...) return value ignored
- Node's HTTP
write() returns false when the kernel buffer is full (backpressure signal). A slow SSE client accumulates events in Node's write buffer unbounded; the only client-removal mechanism is the synchronous throw from res.write on a closed socket.
- Fix: track return value; on
false set a per-client "paused" flag and re-enable on drain. Pairs well with IR-19 if both adopt the existing agent-event-batch envelope (relay.ts:610 already uses it for replay; extending to live with a 16 ms flush window kills two birds).
IR-19 · Webview postMessage is per-event with no batching
extension/src/webview-provider.ts:110-121 (postMessage and sendEvent)
- Called from
extension/src/extension.ts:234, session-runtime.ts:77, claude-runtime.ts:103-117
- Each call is structured-clone over an IPC pipe; in dev mode (Next iframe in webview) there's a triple hop (extension → outer webview → iframe at
webview-provider.ts:206-227)
- Stress scenario at 200 evt/s → 200-600 IPC crossings/s
- Fix: 16 ms flush window or N-event batch using the same
agent-event-batch envelope as IR-18.
IR-20 · Relay parses full burst inline without yields
scripts/relay.ts:329-331 (and the equivalent session-watcher.ts:453-455 belongs to WG-5)
for (const line of result.lines) loop runs JSON.parse + delegate.emit → broadcastEvent → JSON.stringify + res.write to every SSE client per line, all in one tick
- Compaction event flushing 2 k lines stalls SSE delivery, hook server, and scan interval simultaneously
- Fix:
setImmediate yield every 100 lines, or move parsing to a Worker.
IR-21 · eventBuffer and agentSnapshots never evict
scripts/relay.ts:81-94
- No
eventBuffer.delete or agentSnapshots.delete anywhere. Per-session ring is bounded (5000 events) but the maps themselves grow with distinct session IDs.
- Long-running relay (days): ~5000 events × ~500 B = ~2.5 MB per session × 1000 sessions = ~2.5 GB.
- Fix: evict on
agent_complete + grace period (1 hour). Pairs with CR-5 in the same per-session-cleanup PR.
Minor
MR-12 · app/src/static.ts:62 re-reads index.js / index.css per request
- No in-memory cache, no
If-None-Match, no compression
- Cache the bytes at startup; emit
ETag / Cache-Control: public, max-age=.... Note: this is in the app/ package, not strictly the relay — bundling here because it's the same standalone-app process.
Parallelism
Independent of WG-1 (#71), WG-2 (#72), WG-3 (#73), WG-5 (#74). This workflow owns scripts/relay.ts end-to-end, including the relay's caller of CR-4 (the JSONL tail contract) at relay.ts:326. WG-5 owns the rest of extension/src/ and does NOT touch scripts/relay.ts or extension/src/webview-provider.ts.
Test plan
Summary
Workflow 4 of 5 from the 2026-05-03 performance review of
main. Parent: #46. Sibling: #75 (extension JSONL ingestion). Six findings onscripts/relay.tsand the webview message bus. Two are correctness bugs (cleanup leak, hook-event duplication); the rest are throughput / lifecycle wins.Critical
CR-5 ·
relay.tsdispose()leaks subagent watchers, dir watchers, and permission timersscripts/relay.ts:633-661vsextension/src/session-watcher.ts:559-587(the working reference)for (const session of sessions.values())and only closesfileWatcher,pollTimer,inactivityTimer. NOT closed:session.subagentsDirWatcher(fs.FSWatcherset insubagent-watcher.ts:55)session.subagentWatchers[*].watcher(per-subagent-file watchers)session.permissionTimerand per-subagent permission timersparser.clearSessionState(...)is not calledWatchedSessionshape but the cleanup logic diverged — the extension's is correct.SessionWatcher.dispose()cleanup loop into the relay; add a per-sessiondisposeSession(sessionId)invoked fromagent_complete/inactivity paths. Pairs naturally with IR-21.CR-6 · Hook events bypass
eventBuffer+agentSnapshotsand skip dedupscripts/relay.ts:470-472:broadcastEvent(relay.ts:148-168) which (a) incrementssessionEventCount, (b) tracksobservedModels, (c) pushes intoeventBufferfor replay, (d) updatesagentSnapshotsfor late-connecting clientsagentSnapshotsdoesn't capture hook-only spawnssessionEventCountundercounts → telemetry wrongagent_spawn/agent_complete/subagent_dispatch/subagent_returndouble-fire to all clientsextension/src/claude-runtime.ts:88-119— it filters subagent lifecycle events when the watcher is already handling the session — but was not ported to the relay.broadcastEventAND port the dedup filter (predicate issessions.has(eventSessionId)).relay.ts.Important
IR-18 · No SSE backpressure handling
scripts/relay.ts:73:res.write(...)return value ignoredwrite()returnsfalsewhen the kernel buffer is full (backpressure signal). A slow SSE client accumulates events in Node's write buffer unbounded; the only client-removal mechanism is the synchronous throw fromres.writeon a closed socket.falseset a per-client "paused" flag and re-enable ondrain. Pairs well with IR-19 if both adopt the existingagent-event-batchenvelope (relay.ts:610already uses it for replay; extending to live with a 16 ms flush window kills two birds).IR-19 · Webview postMessage is per-event with no batching
extension/src/webview-provider.ts:110-121(postMessageandsendEvent)extension/src/extension.ts:234,session-runtime.ts:77,claude-runtime.ts:103-117webview-provider.ts:206-227)agent-event-batchenvelope as IR-18.IR-20 · Relay parses full burst inline without yields
scripts/relay.ts:329-331(and the equivalentsession-watcher.ts:453-455belongs to WG-5)for (const line of result.lines)loop runs JSON.parse +delegate.emit→broadcastEvent→JSON.stringify+res.writeto every SSE client per line, all in one ticksetImmediateyield every 100 lines, or move parsing to a Worker.IR-21 ·
eventBufferandagentSnapshotsnever evictscripts/relay.ts:81-94eventBuffer.deleteoragentSnapshots.deleteanywhere. Per-session ring is bounded (5000 events) but the maps themselves grow with distinct session IDs.agent_complete+ grace period (1 hour). Pairs with CR-5 in the same per-session-cleanup PR.Minor
MR-12 ·
app/src/static.ts:62re-readsindex.js/index.cssper requestIf-None-Match, no compressionETag/Cache-Control: public, max-age=.... Note: this is in theapp/package, not strictly the relay — bundling here because it's the same standalone-app process.Parallelism
Independent of WG-1 (#71), WG-2 (#72), WG-3 (#73), WG-5 (#74). This workflow owns
scripts/relay.tsend-to-end, including the relay's caller of CR-4 (the JSONL tail contract) atrelay.ts:326. WG-5 owns the rest ofextension/src/and does NOT touchscripts/relay.tsorextension/src/webview-provider.ts.Test plan
concurrentsim, restart relay 5 times; check FD count vials /proc/$(pgrep -f dev-relay)/fd | wc -lstays boundedagentSnapshots.sizeandeventBuffer.sizeshould plateau, not grow