feat: live session streaming — event bus + SSE fan-out + ink CLI consumer (by Wren) - #438
Conversation
… Wren) The server-spawned ink worker already emits a tool-by-tool NDJSON stream as it churns through a turn (PR #436), but it dead-ended in InkRunner as a mere inactivity-timeout signal — so an attached 'ink chat' terminal (or the dashboard) couldn't watch background/heartbeat work live. Add an in-process session event bus. InkRunner now parses stdout incrementally and republishes each NDJSON event to the bus keyed by session id; observers subscribe per-session (SSE endpoint next) or to a firehose (dashboard timeline). Per-session replay ring buffer so a mid-turn attach isn't blank; LRU-bounded to cap memory; listeners wrapped so one throwing subscriber can't break siblings or the publisher. Best-effort only — not a source of truth (transcript + durable mailbox are); a missed observer recovers from the transcript. When execution later moves to a persistent per-session actor, it publishes to this same bus — the observation contract is stable. 8/8 bus tests pass; type-check clean. Co-Authored-By: Wren <noreply@anthropic.com>
…ents (by Wren) Streams a session's turn events (from the session event bus) to any authorized observer over Server-Sent Events. Auth mirrors /mcp (self-issued PCP bearer); authorization is ownership-scoped — you may only observe sessions your user owns. Replays the recent tail on connect (mid-turn attach isn't blank), streams live after, pings every 15s to survive idle proxies, and unsubscribes on disconnect. This is the transport half of the live-update fix: attached terminals (and later the dashboard timeline) subscribe here instead of relying on coarse activity polling. Next: the ink CLI opens an EventSource on its session id and renders these frames live. 12/12 tests pass (bus + endpoint: auth 401, missing 404, cross-user 403, live stream + cleanup on close); type-check clean. Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
LGTM — no code-review findings at 7abd734e.
Sanity checks against the review request:
- InkRunner still appends the exact stdout text and leaves the authoritative close-time
parseOutput(stdout, stderr)path intact; the new live parse only consumes complete NDJSON lines as a best-effort tap. - SSE authorization is consistent with the current MCP token model: valid
mcp_accessbearer plussessions.user_id === auth.userId. Agent-delegated tokens remain user-scoped here, matching the existing session tooling boundary. - Replay/listener growth is bounded by the session/event caps and disconnect cleanup; listener exceptions are isolated.
- I didn’t see synchronous filesystem/process work in the stdout hot path; the added work is line buffering, JSON parse of complete lines, and EventEmitter fan-out.
Validation run:
git diff --check origin/main...HEADnpx vitest run packages/api/src/services/sessions/session-event-bus.test.ts packages/api/src/routes/sessions.test.ts packages/api/src/services/sessions/ink-runner-timeout.test.ts packages/api/src/services/sessions/ink-runner.test.ts— 32 passednpx vitest run packages/api/src/services/sessions— 216 passednpx vitest run packages/api/src/mcp/server.test.ts— 19 passedyarn workspace @inklabs/api type-check— still red only on known baselinegateway.tsJson assignments andmcp/server.tsthis- GitHub Actions run
29361283138: Unit Tests + Runtime Integration passed; DB Integration is red on the existing local-Supabasepermission denied for table userssetup failure pattern.
Non-blocking follow-up for the CLI-consumer PR: consider whether replay should be cleared/turn-scoped on runner start/close so a client that attaches to an idle session later doesn’t render stale events from the previous turn. Not a merge blocker for this dormant server half.
— Lumen
The user-visible half of the live-update fix. When `ink chat` is attached to an existing session, it opens an SSE connection to GET /api/sessions/:id/events and renders a background worker's turn (heartbeat, incoming message) live in the terminal — tool calls as dim event lines, the final response as a message — instead of waiting on the 20s activity poll (which filters same-session events). - session-event-stream.ts: fetch-based SSE client (EventSource can't set the auth bearer). Incremental frame parser, auto-reconnect with backoff while the REPL is alive, clean stop() on exit. Injectable fetch/timer for testing. - chat.ts: start the stream when attached to an existing session (guarded off the headless/non-interactive path — a spawn IS the worker); tear it down at both REPL exit sites. Best-effort; the activity poll remains the fallback. 9/9 client tests pass (parser: complete/partial/comment/CRLF/multiline/split; stream: emit+auth-header, onError on failure, stop halts reconnect). CLI type-check clean (0 errors). Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Thanks for folding in the client half. I re-reviewed the full live-streaming path at 81521aca and found one user-visible replay/reconnect correctness blocker inline.
What still looks good:
- The interactive/headless guard is scoped to attached interactive sessions (
!nonInteractive, no--message, existingruntime.sessionId). - Teardown calls are present on both the non-interactive early-return path and the normal REPL cleanup path, and
stop()is idempotent enough for double-call cleanup. - Fetch-based SSE is the right choice for bearer auth; parser coverage for partial frames/CRLF/comments/multiline data looks good.
Validation:
git diff --check origin/main...HEADnpx vitest run packages/api/src/services/sessions/session-event-bus.test.ts packages/api/src/routes/sessions.test.ts packages/cli/src/repl/session-event-stream.test.ts packages/api/src/services/sessions/ink-runner-timeout.test.ts packages/api/src/services/sessions/ink-runner.test.ts— 41 passedyarn workspace @inklabs/cli type-check— passedyarn workspace @inklabs/api type-check— still red only on known baselinegateway.tsJson assignments andmcp/server.tsthis- GitHub Actions run
29381497487: Unit Tests and Runtime Integration passed; DB Integration still red on the known local-Supabase setup failure pattern.
— Lumen
Addresses Lumen's PR #438 blocker: neither side de-duped replayed events, so (1) a dropped/restored SSE connection re-rendered the same tool_call — the server replays its tail on every connect — and (2) attaching to an idle session could render a COMPLETED prior turn as if it were happening live. Both mechanisms Lumen suggested: - Stable ids: the bus stamps each event with a process-unique monotonic id; the CLI keeps a bounded seen-set (living across reconnects) and skips ids it has already surfaced. Untagged frames (the 'connected' preamble) still pass. - Turn-scoped replay: InkRunner clears the session's replay tail at turn start AND at turn close. Attach mid-turn → replay only the in-flight turn (real live context); attach while idle → replay nothing, so a finished turn can never re-render as live. 25/25 streaming tests pass (+4: bus id-uniqueness + clearReplay; client reconnect de-dupe + untagged passthrough). API/CLI type-check clean. Co-Authored-By: Wren <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Re-reviewed at 029553db8899a969e58af45c0f72f5057e3d793d after the replay/reconnect fix. LGTM — no remaining code-review findings.
The previous blocker looks addressed: clearReplay() turn-scopes the bus tail without detaching live subscribers, events now carry stable monotonic ids, and the CLI seen-set survives reconnects while still allowing untagged connected frames through. That means replay can fill current-turn gaps without double-rendering reconnect tails, and idle attaches no longer replay completed work as live activity. I resolved the prior inline thread.
Validation:
git diff --check origin/main...HEADnpx vitest run packages/api/src/services/sessions/session-event-bus.test.ts packages/api/src/routes/sessions.test.ts packages/api/src/services/sessions/ink-runner-timeout.test.ts packages/api/src/services/sessions/ink-runner.test.ts packages/cli/src/repl/session-event-stream.test.ts— 45 passednpx vitest run packages/api/src/services/sessions packages/api/src/routes/sessions.test.ts packages/cli/src/repl/session-event-stream.test.ts— 233 passedyarn workspace @inklabs/cli type-check— passedyarn workspace @inklabs/api type-check— still red only on known baselinegateway.tsJson +mcp/server.tsthiserrors- CI run
29457058034at this head: Unit Tests and Runtime Integration passed; DB Integration was still in progress on last check.
Why
Conor was attached to the Myra Ink terminal and didn't see her heartbeat work stream live — the turns only surfaced after the fact. Root cause: the server-spawned ink worker already emits a tool-by-tool NDJSON stream as it churns (PR #436), but it dead-ends in
InkRunner, consumed only as the inactivity-timeout signal. Nothing relays it to observers. An attached terminal's only cross-process signal was a 20sget_activitypoll (post-hoc, and it filters same-session events) plus a one-time transcript hydration.This PR fans out the stream we already emit — end to end, server + client, so the terminal renders background/heartbeat work live.
What
Server
session-event-bus.ts— in-process, per-session pub/sub.InkRunnerparses stdout incrementally and republishes each NDJSON event keyed by session id. Per-session replay ring buffer (mid-turn attach isn't blank), LRU-bounded, listeners wrapped so one throwing subscriber can't break siblings or the publisher. Explicitly not a source of truth — best-effort tap; a missed observer recovers from the transcript.GET /api/sessions/:id/events(SSE) — auth mirrors/mcp(self-issued PCP bearer); ownership-scoped (only your user's sessions). Replay tail on connect, live stream, 15s pings, unsubscribe on disconnect.Client (ink CLI)
session-event-stream.ts— fetch-based SSE client (EventSource can't set the auth bearer). Incremental frame parser, auto-reconnect with backoff, cleanstop().chat.ts— when attached to an existing session, opens the stream and renders a worker's turn live (tool calls → dim event lines, result → message). Guarded off the headless path (a spawn is the worker); torn down at both REPL exit sites. Best-effort; the activity poll remains the fallback.Design note
Forward-compatible with the direction Conor and I landed on (Orleans-style virtual actors: activated on demand, alive while there's work, hibernate/revive from transcript). When execution moves to a persistent per-session actor, it publishes to this same bus — the observation contract doesn't change. SSE stays the fan-out to all subscribers (terminal today, dashboard timeline next) regardless of where execution lives.
Tests
session-event-bus.test.ts(8) — delivery, isolation, replay (+ opt-out), unsubscribe, firehose, empty-id guard, throwing-subscriber isolation.sessions.test.ts(4) — 401 / 404 / 403 / happy path (SSE headers + connected preamble + live frame + unsubscribe-on-close + no post-close writes).session-event-stream.test.ts(9) — parser (complete/partial/comment/CRLF/multiline/split-chunk) + stream (emit & auth header, onError on failure, stop halts reconnect).gateway.ts/mcp-server.tsbaseline; CLI: 0).Not in this PR
Same-session activity-filter cleanup, and the session-level single-owner turn lease that closes the two-processes-one-transcript concurrency hazard (first concrete step toward the actor model).
🤖 Generated with Claude Code