Skip to content

feat: live session streaming — event bus + SSE fan-out + ink CLI consumer (by Wren) - #438

Merged
conoremclaughlin merged 4 commits into
mainfrom
wren/feat/session-event-stream
Jul 15, 2026
Merged

feat: live session streaming — event bus + SSE fan-out + ink CLI consumer (by Wren)#438
conoremclaughlin merged 4 commits into
mainfrom
wren/feat/session-event-stream

Conversation

@conoremclaughlin

@conoremclaughlin conoremclaughlin commented Jul 14, 2026

Copy link
Copy Markdown
Owner

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 20s get_activity poll (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. InkRunner parses 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, clean stop().
  • 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).
  • 21/21 pass. API + CLI type-check clean (API: only the pre-existing gateway.ts/mcp-server.ts baseline; 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

conoremclaughlin and others added 2 commits July 14, 2026 12:12
… 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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_access bearer plus sessions.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...HEAD
  • npx 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 passed
  • npx vitest run packages/api/src/services/sessions — 216 passed
  • npx vitest run packages/api/src/mcp/server.test.ts — 19 passed
  • yarn workspace @inklabs/api type-check — still red only on known baseline gateway.ts Json assignments and mcp/server.ts this
  • GitHub Actions run 29361283138: Unit Tests + Runtime Integration passed; DB Integration is red on the existing local-Supabase permission denied for table users setup 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 conoremclaughlin changed the title feat: session event bus + SSE fan-out — live session streaming, server half (by Wren) feat: live session streaming — event bus + SSE fan-out + ink CLI consumer (by Wren) Jul 15, 2026

@conoremclaughlin conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, existing runtime.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...HEAD
  • npx 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 passed
  • yarn workspace @inklabs/cli type-check — passed
  • yarn workspace @inklabs/api type-check — still red only on known baseline gateway.ts Json assignments and mcp/server.ts this
  • GitHub Actions run 29381497487: Unit Tests and Runtime Integration passed; DB Integration still red on the known local-Supabase setup failure pattern.

— Lumen

Comment thread packages/cli/src/commands/chat.ts
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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...HEAD
  • npx 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 passed
  • npx vitest run packages/api/src/services/sessions packages/api/src/routes/sessions.test.ts packages/cli/src/repl/session-event-stream.test.ts — 233 passed
  • yarn workspace @inklabs/cli type-check — passed
  • yarn workspace @inklabs/api type-check — still red only on known baseline gateway.ts Json + mcp/server.ts this errors
  • CI run 29457058034 at this head: Unit Tests and Runtime Integration passed; DB Integration was still in progress on last check.

@conoremclaughlin
conoremclaughlin merged commit fde3b09 into main Jul 15, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant