Skip to content

feat: stream feedback over SSE with threaded replies and unread thread chips - #295

Open
eloise-idealab wants to merge 78 commits into
kunchenguid:mainfrom
eloise-idealab:feat/realtime-sse-threading
Open

feat: stream feedback over SSE with threaded replies and unread thread chips#295
eloise-idealab wants to merge 78 commits into
kunchenguid:mainfrom
eloise-idealab:feat/realtime-sse-threading

Conversation

@eloise-idealab

@eloise-idealab eloise-idealab commented Aug 25, 2026

Copy link
Copy Markdown

Intent

Make the realtime SSE stream thread replies correctly: a reply should attach to the message it answers, and the per-thread unseen count should stay accurate when optimistic replies are retracted, retried, or carry only an image - and interrupting the stream with Ctrl-C should flush what it has already produced instead of truncating mid-record. Re-raising on head a3beba3 so the PR body's no-mistakes attestation names the current head: the previous run's ci auto-fix pushed a commit after the pr step had written the marker. Skipping ci because all five GitHub checks are already green on this head and a ci auto-fix push is what invalidated the last attestation. The branch's full suite has already been run green on this exact head (1251 pass / 0 fail), so verify the threading behaviour with targeted, already-existing tests rather than building a new end-to-end browser driver; the previous attempt timed out doing that.

What Changed

  • Real-time streaming delivery: adds the lavish-axi stream <file> command and the /api/stream SSE endpoint, which hold a connection open and emit one NDJSON line per user message (message / feedback / ended) instead of draining one batch per poll. It shares the poll's atomic take-and-restore contract so an interrupted drain requeues whatever it had not written, supports --once, --agent-reply and --reply-to, caps concurrent streams (503 + Retry-After), counts an open stream as a live consumer for presence and idle shutdown, and flushes buffered stdout on SIGINT/SIGTERM under STREAM_FLUSH_CEILING_MS so Ctrl-C cannot truncate a record the server already considers delivered.
  • Server-owned message ids and threading: session-store.js mints a UUID for every transcript message, strips client-supplied ids, and drops a reply_to that does not name a message already in the session, with the new shared isTranscriptMessage predicate used by the store, /api/stream, and streamMessageRecord so an image-only send is recorded and delivered as a message; addAgentReply now returns the persisted message and /api/:key/agent-reply echoes the reply_to it actually resolved (the CLI reports on stderr when a reply falls back to top level). Every store mutation runs under one AsyncMutex, and chat-sync broadcasts the transcript so the chrome reconciles optimistic bubbles against server ids.
  • Threaded conversation UI and annotation actions: the chrome gains a slide-out thread pane with per-message reply targeting, thread chips with unread counts, a Back badge for activity in other threads, and phone-sheet layout that keeps the dock reachable with a thread open. Per-thread seen counts are credited back when an optimistic reply is retracted or its batch is refused, held while a prompt is still in flight, and re-applied when a retry delivers. The annotation card now ends in explicit Cancel / Queue / Send. README and AGENTS.md document the stream command, threading, and Enter-to-reply; new coverage lands in test/chrome-client-threading.test.js, test/helpers/chrome-harness.js, and expanded server, session-store, and CLI-output suites, alongside the design specs and plans under docs/superpowers/.

How this merges with your recent work

main moved a long way while this sat. In several places we had independently solved the same problem, and in every one of those I kept your implementation as the trunk and dropped mine. Nothing you fixed on main was reverted to make room for this branch.

Concern Kept Dropped
Store serialization your AsyncMutex / store.lock / runExclusive my ad-hoc withLock promise chain
Undelivered-batch restore your queuePrompts(..., { restore: true }) / restoreClosedFeedback, including the prepend-order, newer-snapshot-wins, mergeArtifactFailures dedupe and MAX_REQUEST_ATTACHMENT_REFS exemption rules my requeueFeedback(key, batch)
Always-on send your version mine

git grep confirms requeueFeedback and withLock no longer appear anywhere in src/ or test/.

Also carried through untouched from main: the passive layout-warning inbox (applyDiagnosticPass, layoutWarningFingerprintlayout_warnings is no longer a poll field, and nothing here emits feedback for a detection), admitAttachmentCharge, the mobile conversation sheet (syncVisualViewport, MOBILE_SHEET_MEDIA), isStalePluginLocation, and validateSkillMarkdown.

Docs were resolved the same way. skills/lavish/SKILL.md is your minimal generated stub verbatim — stream is deliberately not restated there, per the Documentation ownership rule that the skill points at lavish-axi --help. In AGENTS.md, your item-7 restore paragraphs are kept as written and stream was inserted as a new item 8 that references restoreClosedFeedback; the agent-presence block became item 9 and gained "poll or stream has attached", --reply-to, and chat-sync. README.md takes your command table with the stream row re-inserted after poll.


Risk Assessment

⚠️ Medium: Large change (~1,600 source lines across four core files plus ~4,000 test lines) that adds a new agent-facing HTTP route and shares the poll's destructive take/restore delivery contract with it, plus a new per-thread unread accounting model in the chrome. The concurrency-sensitive parts - drain serialization (draining/drainAgain/stopped), deliver-then-restore on a detected disconnect, and the --once tail requeue - are the places a defect would cost a permanently lost user message, and they are intricate. Offsetting that: nine prior review rounds have already adjudicated this surface, the three intent behaviors each have targeted tests, stopped is provably set before every restore, the guarded-emitter monkey-patch covers every registration site, and this pass surfaced no error-severity defect. Residual risk is concentrated in multi-consumer interleaving (two streams, or a stream beside a poll), which is unit-tested but not soaked.

Testing

Ran the four targeted existing test files that own this change (91 tests, all passing) and then demonstrated each intent clause end-to-end with the real CLI and server: a browser reply streams back with reply_to pointing at the agent message it answers, --reply-to threads the agent's own reply, an image-only send still arrives as a real user message with its own id and thread, and Ctrl-C on a stream whose consumer had stopped reading still flushed the entire 168k-character record (exit 130, every NDJSON line complete). Screenshots of the actual chrome page show the unread "1 new" chip appearing on a closed thread when a reply lands over SSE and clearing once the thread is opened, plus the thread pane with replies attached to their parent. One observation, consistent with existing design rather than this change: a chat bubble for an image-only reply shows the "Image message" label without a thumbnail — the image itself rides the queued prompt to the agent. The retraction/retry seen-credit paths need a refused POST mid-flight, which only the chrome-client tests can stage, so those are covered by tests rather than screenshots. No failures, no flakiness observed; the full suite was deliberately not re-run.

  • Evidence: Unread thread chip after a reply arrives over SSE into a closed thread (local file: /Users/ezeng/.no-mistakes/evidence/01M19XCGQ8G1WH1CP4A6S0E84H/02-unread-chip-after-live-reply.png)
  • Evidence: Thread pane open: replies attached under the message they answer (local file: /Users/ezeng/.no-mistakes/evidence/01M19XCGQ8G1WH1CP4A6S0E84H/03-thread-pane-open.png)
  • Evidence: Threads render read on load (baseline), both chips show reply counts (local file: /Users/ezeng/.no-mistakes/evidence/01M19XCGQ8G1WH1CP4A6S0E84H/01-threads-read-on-load.png)
  • Evidence: Chip repaints as read after the thread is opened (3 replies) (local file: /Users/ezeng/.no-mistakes/evidence/01M19XCGQ8G1WH1CP4A6S0E84H/04-chip-read-after-open.png)
  • Evidence: An image-only reply is a real threaded message in the thread pane (local file: /Users/ezeng/.no-mistakes/evidence/01M19XCGQ8G1WH1CP4A6S0E84H/05-image-only-reply-in-thread.png)
Evidence: End-to-end CLI transcript: SSE threading, image-only reply, agent --reply-to, persisted thread tree

==============================================================================
1. The user opens the artifact (real CLI, real detached server)
==============================================================================
$ lavish-axi checkout.html --no-open   (exit 0)
session:
  file: /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html
  url: "http://127.0.0.1:4487/session/91609f5c7a347b8b"
  status: opened
next_step: "Do not respond to the user just yet. Now you must run `lavish-axi poll /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html`. This command long-polls until the user sends feedback or ends the session, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag \"layout-warnings\" prompt. Do not pass --timeout-ms during normal agent use. Keep the poll in the foreground by default and let it return the feedback directly to the agent. A background poll is allowed only through a harness-native tracked background-job facility whose completion result is guaranteed to resume or notify the same agent. Never use `nohup`, shell `&`, `disown`, redirected fire-and-forget processes, or a detached terminal without an explicit verified callback merely to keep polling alive. If the harness has no completion-aware background facility, use the foreground poll or first wire a verified wake callback into the surrounding supervisor. Do not tell the user the artifact is being monitored until that wake path is live. If the poll gets killed or times out before feedback arrives, re-run it - feedback remains queued until delivery. Poll delivery consumes the response, so read it completely. After applying feedback, run `lavish-axi poll /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html --agent-reply \"<message for the user>\"` without --timeout-ms to show your response in Lavish Editor and wait for more feedback. If the user ends the session, stop polling and do not reopen it by re-running `lavish-axi /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run `lavish-axi /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html --reopen`."

==============================================================================
2. The agent posts a question and starts the realtime stream
==============================================================================
$ lavish-axi stream checkout.html --agent-reply "Which headline do you want?"
  agent root message persisted with server-minted id: 27fe2d0d-de9f-4cfd-9b61-3f6278dc8703
  stderr banner from the stream command:
    [lavish-axi] Streaming user messages for /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html over a live push connection. Each user message prints as one NDJSON line on stdout - read them line-by-line and handle each (e.g. fan out a subagent per message). This stays open until the session ends or the connection drops; never kill it. If it drops, re-run `lavish-axi stream /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html` - anything not yet delivered stays queued until delivery, but delivery consumes it, so a message already printed here is never repeated.

==============================================================================
3. The user replies IN THREAD from the browser (reply_to = the agent's message)
==============================================================================
  POST /api/91609f5c7a347b8b/prompts -> 200
  NDJSON the agent read on stdout:
    {"type":"message","id":"363e9020-2f1d-485f-92d6-8671dbcd84ae","reply_to":"27fe2d0d-de9f-4cfd-9b61-3f6278dc8703","text":"Option B. 'Checkout in one tap' reads better.","prompts":[{"uid":"","prompt":"Option B. 'Checkout in one tap' reads better.","selector":"","tag":"message","text":"Freeform message","reply_to":"27fe2d0d-de9f-4cfd-9b61-3f6278dc8703","id":"363e9020-2f1d-485f-92d6-8671dbcd84ae"}],"dom_snapshot":""}
  => the streamed record's reply_to (27fe2d0d-de9f-4cfd-9b61-3f6278dc8703) IS the agent message it answers.

==============================================================================
4. The user sends an IMAGE-ONLY reply in the same thread
==============================================================================
  POST /api/91609f5c7a347b8b/attachments -> 200  id=faab2552a64cbf7c13947c96110f74fafb2bd07da878ae7ec2bf0158311315af.png
  POST /api/91609f5c7a347b8b/prompts (no text, one image) -> 200
  NDJSON record for the image-only reply:
    {"type":"message","id":"04ff1c2c-ed89-4340-b767-16200f7b4d31","reply_to":"27fe2d0d-de9f-4cfd-9b61-3f6278dc8703","text":"","prompts":[{"uid":"","prompt":"","selector":"","tag":"message","text":"Freeform message","reply_to":"27fe2d0d-de9f-4cfd-9b61-3f6278dc8703","attachments":[{"id":"faab2552a64cbf7c13947c96110f74fafb2bd07da878ae7ec2bf0158311315af.png","type":"image","path":"/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/state/attachments/91609f5c7a347b8b/faab2552a64cbf7c13947c96110f74fafb2bd07da878ae7ec2bf0158311315af.png","mime":"image/png","bytes":73,"width":4,"height":4,"name":"headline-b.png"}],"id":"04ff1c2c-ed89-4340-b767-16200f7b4d31"}],"dom_snapshot":""}
  => an image-only send is still a real message: type=message, id=04ff1c2c-ed89-4340-b767-16200f7b4d31, reply_to=27fe2d0d-de9f-4cfd-9b61-3f6278dc8703

==============================================================================
5. Ctrl-C the stream: it flushes what it already produced instead of truncating
==============================================================================
  exit code: 130 (130 = interrupted by SIGINT), signal: null
  stderr guidance shown to the operator:
    
    [lavish-axi] Stream interrupted. The user may still be reviewing - re-run `lavish-axi stream /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-thread-ktF6n1/checkout.html` to keep receiving messages; anything not yet delivered stays queued until delivery. Messages this stream already printed were consumed and will not arrive again, so act on what you have already read.
  stdout after the interrupt: 2 NDJSON records, every one complete and parseable
  (1113 bytes delivered before the signal, 1113 after - nothing was cut off)

==============================================================================
6. The agent threads ITS reply under the user's message (--reply-to)
==============================================================================
$ lavish-axi stream checkout.html --agent-reply "Locking Option B." --reply-to 363e9020-2f1d-485f-92d6-8671dbcd84ae --once
  persisted: agent message 6dc32289-135c-4d09-a97e-377cb90a08a4 carries reply_to=363e9020-2f1d-485f-92d6-8671dbcd84ae
  no unthreaded-reply warning on stderr, so the server resolved the requested target.

==============================================================================
7. Persisted transcript in state.json, rendered as the thread tree the user sees
==============================================================================
- [agent] Which headline do you want, A or B?   (id 27fe2d0d-de9f-4cfd-9b61-3f6278dc8703)
    +-- [user] Option B. 'Checkout in one tap' reads better.   (id 363e9020-2f1d-485f-92d6-8671dbcd84ae, reply_to 27fe2d0d-de9f-4cfd-9b61-3f6278dc8703)
    +-- [user] Image message   (id 04ff1c2c-ed89-4340-b767-16200f7b4d31, reply_to 27fe2d0d-de9f-4cfd-9b61-3f6278dc8703)
    +-- [agent] Locking Option B. Updating the headline now.   (id 6dc32289-135c-4d09-a97e-377cb90a08a4, reply_to 363e9020-2f1d-485f-92d6-8671dbcd84ae -> replying to 363e9020-2f1d-485f-92d6-8671dbcd84ae)

Every reply is attached to the message it answers; nothing landed as a stray root.

session URL for visual capture: http://127.0.0.1:4487/session/91609f5c7a347b8b
Evidence: Ctrl-C flush transcript (full log)
==============================================================================
Ctrl-C during a delivery the consumer has not drained yet
==============================================================================
$ lavish-axi report.html --no-open   -> session e90ca6eda617a53f
$ lavish-axi stream report.html            # started, then deliberately NOT read from
  user sends a 164 KB message from the browser -> POST /prompts 200
  Ctrl-C (SIGINT) while stdout is still blocked on the unread pipe...
  exit code: 130  (130 = SIGINT)
  bytes recovered from stdout after the interrupt: 336226
  NDJSON records: 1, all complete JSON
  delivered record: type=message id=884aa5c6-34e8-437f-8d45-7913833c4c60 text length=168000 (sent 168000)
  => the whole message survived the interrupt; delivery is destructive, so a cut-off record would have been lost for good.

  stderr shown to the operator:
    [lavish-axi] Stream interrupted. The user may still be reviewing - re-run `lavish-axi stream /private/var/folders/ms/lh_tht_x0m56trhqxg24sqcw0000gq/T/lavish-e2e-flush-kLJcz6/report.html` to keep receiving messages; anything not yet delivered stays queued until delivery. Messages this stream already printed were consumed and will not arrive again, so act on what you have already read.
Evidence: Ctrl-C during an undrained delivery keeps the whole message
$ lavish-axi report.html --no-open -> session e90ca6eda617a53f
$ lavish-axi stream report.html # started, then deliberately NOT read from
user sends a 164 KB message from the browser -> POST /prompts 200
Ctrl-C (SIGINT) while stdout is still blocked on the unread pipe...
exit code: 130 (130 = SIGINT)
bytes recovered from stdout after the interrupt: 336226
NDJSON records: 1, all complete JSON
delivered record: type=message id=884aa5c6-34e8-437f-8d45-7913833c4c60 text length=168000 (sent 168000)
=> the whole message survived the interrupt; delivery is destructive, so a cut-off record would have been lost for good.
Evidence: Threaded reply on the live stream (NDJSON the agent reads on stdout)
POST /api/91609f5c7a347b8b/prompts -> 200
NDJSON the agent read on stdout:
{"type":"message","id":"363e9020-2f1d-485f-92d6-8671dbcd84ae","reply_to":"27fe2d0d-de9f-4cfd-9b61-3f6278dc8703","text":"Option B. 'Checkout in one tap' reads better.",...}
=> the streamed record's reply_to (27fe2d0d-...) IS the agent message it answers.

Persisted transcript (state.json), rendered as the thread tree the user sees:
- [agent] Which headline do you want, A or B? (id 27fe2d0d-...)
+-- [user] Option B. 'Checkout in one tap' reads better. (reply_to 27fe2d0d-...)
+-- [user] Image message (reply_to 27fe2d0d-...)
+-- [agent] Locking Option B. Updating the headline now. (reply_to 363e9020-... -> the user's message)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped

Step was skipped.

⚠️ **Review** - 2 infos
  • ℹ️ src/chrome-client.js:3842 - ingestIncoming still drops a message with empty text (if (!message || !message.text) return;), but the rebuild path no longer does: base's syncChat went through addChat, whose if (!text) return; filtered empties, and the new setMessages (src/chrome-client.js:858) copies every transcript entry unconditionally. POST /api/:key/agent-reply accepts an unvalidated text and addAgentReply persists String(text || &#34;&#34;) (src/session-store.js:652), so an empty agent reply is invisible when it arrives over SSE but renders as an empty bubble after the next chat-sync rebuild or a page reload. The only write path is a hand-crafted local POST - the CLI's flagValue returns null for --agent-reply &#34;&#34;, so streamCommand/pollCommand never post one - which is why this is recorded rather than fixed.
  • ℹ️ src/cli.js:2041 - The new flagTokens helper truncates at -- but, unlike its siblings flagValue (src/cli.js:2067) and firstPositionalArg (src/cli.js:2046), does not skip the operand belonging to a value flag. So lavish-axi stream a.html --agent-reply --once has flagValue read --once as the reply text while flagTokens(args).includes(&#34;--once&#34;) (src/cli.js:417) simultaneously reads it as the mode flag: the CLI posts a chat message whose body is literally --once and also runs single-shot. The same shape exists at the share call site (src/cli.js:1019), though there it is not a regression - the previous args.includes(...) behaved identically. Pathological input with a cosmetic outcome, so no change proposed.
✅ **Test** - passed

✅ No issues found.

  • node --test test/chrome-client-threading.test.js — 52/52 pass (reply targeting, unread read-model, retraction/retry seen-credit, refused-send bubble cleanup)
  • node --test --test-name-pattern &#34;reply_to|image-only|threading&#34; test/session-store.test.js — 5/5 pass (server-owned ids, dropped unknown reply_to, image-only transcript entry)
  • node --test --test-name-pattern &#34;stream|reply_to|reply-to|agent-reply&#34; test/server.test.js — 21/21 pass (per-message SSE frames, --once requeue, mixed text+image batches, ended-frame termination)
  • node --test --test-name-pattern &#34;stream|reply-to|reply_to|flush|image-only&#34; test/cli-output.test.js — 13/13 pass (streamMessageRecord typing, unthreaded-reply notice, signal flush within the ceiling)
  • Manual end-to-end: real lavish-axi &lt;file&gt; --no-open + lavish-axi stream --agent-reply against a real server, browser-side same-origin POSTs to /api/:key/attachments and /api/:key/prompts, transcript at e2e-threading-transcript.txt
  • Manual end-to-end Ctrl-C: 164 KB user message delivered into an undrained stdout pipe, then SIGINT — full record recovered, transcript at e2e-stream-flush-transcript.txt
  • Visual capture via headless Chrome over CDP of the real /session/:key page: thread chips on load, unread chip after a live SSE reply, thread pane with the reply attached to its parent, chip read again after opening, and an image-only threaded reply
⚠️ **Document** - 1 info
  • ℹ️ VISION.md:61 - VISION.md still describes waiting/continuous-session delivery as exclusively the long poll, which this change makes incomplete now that lavish-axi stream is a co-equal SSE push path. Two lines: line 41 ("Every token is spent on purpose") says "waiting is a long poll instead of repeated checks", and line 61 ("Scope") says "It expects to run inside an agent harness, and the long poll is how that harness holds a continuous session". Neither is false about poll, but line 61 is a scope statement a future contributor would read a delivery-mechanism decision against, and it now names only half of what ships. I did not edit it: AGENTS.md's Documentation ownership section states VISION.md owns the acceptance policy and "it is author-approved, so change it only through the author." The minimal correction would be to widen line 61 to "the long poll or the real-time stream is how that harness holds a continuous session" and line 41 similarly, but that is the author's call. Everything user-facing and architectural about the stream is already accurate in its own owner documents (README's Real-time streaming bullet and command/flag tables, AGENTS.md request-flow step 8), so nothing depends on this beyond VISION.md's internal consistency.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

ezeng and others added 30 commits June 27, 2026 14:12
Fork additions (code markers change #1/kunchenguid#2/kunchenguid#3):
- #1 GET /api/stream SSE endpoint + `lavish-axi stream` CLI — one frame per
  message as it arrives, no poll-batching
- kunchenguid#2 Send button + chat input no longer disabled while the agent is working
- kunchenguid#3 stable message UUIDs + reply_to threading through store/server/CLI,
  plus a reply UI (button, quoted snippet, indicator) in the chrome

Built against upstream v0.1.31; 223 tests + full `pnpm run check` gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Playwright review: .reply-indicator had no [hidden] guard, so the
'Replying to' bar was permanently visible. Add .reply-indicator[hidden]{display:none}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ated messages

The realtime stream path made an existing read-modify-write race routine:
every SessionStore method read the whole state file, mutated in memory, and
wrote it back with no serialization. A stream drain (takeFeedback) and a
concurrent always-on Send (queuePrompts) both read the same pre-write state, so
whichever wrote last erased the other's change — dropping a queued message or
resurrecting an already-delivered one.

Chain every file-touching method through a store-global operation queue
(withLock). The lock is store-global rather than per-session key because
readState/writeState operate on the whole file, so two different keys would
still clobber each other's slice on a shared write.

Adds a deterministic 30-iteration concurrent takeFeedback/queuePrompts
regression test (caught the duplicate-delivery bug pre-fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each open stream registered a "feedback"/"ended" listener on the shared
emitter and held a socket, with no ceiling: the 11th tripped Node's
MaxListenersExceededWarning and an unbounded number could exhaust sockets.

Enforce a maxStreamClients cap (default 16, injectable via serve()): past the
cap, requests get 503 + Retry-After instead of another open SSE, tracked in a
streamClients set that frees on req.close. Raise events.setMaxListeners above
the cap (plus headroom for polls and browser chromes) so legitimate fan-out
doesn't warn while a real listener leak still surfaces.

Adds regression tests for cap-then-free-a-slot and >10 concurrent streams
without a MaxListenersExceededWarning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-existing format drift introduced by the reply-indicator fix commit and
never caught; expand the one-line rule so `prettier --check` passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /api/stream drain emits a `message` SSE frame even for annotation- or
layout-warning-only batches (no user message). The stream CLI treated every
such frame as a delivered user message: it incremented `delivered` and, under
--once, terminated the stream — so a --once harness could exit on a layout
warning before any real message arrived, breaking the "one line per user
message" contract.

Extract the per-frame classification into a pure, exported streamMessageRecord()
that distinguishes user-message frames from feedback-only frames (now labelled
type:"feedback"). Feedback-only frames are still emitted so the agent acts on
them, but no longer count toward delivery or satisfy --once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Through the unauthenticated local API a caller could supply arbitrary or
duplicate message ids and reply_to values, colliding stable UUIDs or threading
a message under a target that doesn't exist.

queuePrompts now always mints the message id server-side (ignoring any supplied
id) and drops a reply_to that doesn't reference a message already in the
session transcript. addAgentReply applies the same reply_to validation. Valid
threads (reply_to to a real prior message) are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esign

Records the MEDIUM 3 decision: the /api/stream connection cap bounds resource
use, but the server is unauthenticated by design and meant for the loopback
default. Binding beyond loopback (LAVISH_AXI_HOST) exposes the whole control
surface; document that and recommend a reverse proxy on untrusted networks
rather than bolting on a stream-only token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…essages

The stream drain called takeFeedback (which clears the queue and persists the
empty state under the lock) and only then wrote the SSE frames — and writeFrame
silently no-ops on a dead socket. So a client disconnect between the take and
the write left messages cleared-but-undelivered, with no error and no re-queue:
silent loss of human feedback, contrary to the "messages are never lost"
guarantee. This was missed by the prior review round (C1).

Add non-destructive peekFeedback + ackFeedback to the store and rewire the
drain to peek -> (skip if the client is gone) -> write -> ack. A batch is only
cleared after it was written to a live socket; if the client vanished it stays
queued for the next stream/poll. ackFeedback clears only the delivered prefix,
so messages queued during delivery survive. takeFeedback stays as the atomic
one-shot take for the poll path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stream loop's inner frame parser checked `!stop`, so when a user message
set stop under --once it broke mid-buffer, dropping any further complete frames
already received — and the server had already delivered (and now acks) them, so
a multi-message batch lost its tail (I1).

Extract a pure drainSseBuffer(buffer, onFrame) that processes every complete
frame in the buffer and returns the trailing partial; the outer read loop owns
the stop. A batch that arrives in one read is now fully emitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anup

Round 2 of the Codex/Claude convergence review found that the peek + count-based
ack model from the previous commit was itself unsafe:

- Codex (HIGH): peek leaves the batch queued, so two consumers (two streams, or
  a stream + a poll) could both deliver it, and a count-based ack could slice off
  newly queued messages another consumer had already cleared.
- Claude (Critical): ackFeedback prefix-sliced layout_warnings, but those are
  replace-semantics (recordLayoutWarnings overwrites the whole array), so a fresh
  warning arriving between peek and ack was dropped — and the strand then
  suppressed the `ended` frame.

Replace peekFeedback/ackFeedback with takeFeedback (atomic clear) + a new
requeueFeedback that restores a batch the stream took but couldn't deliver
(detected disconnect). Atomic take means only one consumer gets a batch (no
double-delivery, no prefix race); requeue prepends prompts and restores layout
warnings/dom only when nothing fresher arrived (no clobber). This closes the C1
disconnect window without the layout-warning data loss.

Also: server-side `?once=1` delivers exactly one user message and requeues the
rest of the batch, so a single-shot `lavish-axi stream --once` harness can't lose
the tail of a multi-message batch (Codex HIGH). And register the stream's close
cleanup before writeHead + run it in the catch, so a synchronous throw can't leak
a stream slot/listeners/presence (Claude). Comments now say a *detected*
disconnect never loses messages — res.write acceptance is not client receipt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…snapshot

Round 2 of the convergence review (Codex) found two gaps the stream rework left:

- HIGH: /api/poll still took feedback with takeFeedback and never requeued it, so
  if the poll client vanished as the batch was taken (a poll racing a stream, or
  any poll dropped exactly as feedback arrived) the batch was cleared and lost —
  the same disconnect-during-delivery hole that was fixed for the stream. Track
  `closed` in the poll handler and requeue a taken feedback batch when the client
  is gone (both the immediate take and the long-poll respond path), so the next
  poll/stream still gets it.
- MEDIUM: --once requeued the tail of a batch without its dom_snapshot, so the
  next consumer got those messages with no DOM context. Requeue the tail with the
  batch's dom_snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test

Round 3 of the convergence review (Codex MEDIUM/LOW, Claude minors):

- MEDIUM: /api/poll registered its close listener only after the first
  takeFeedback await, so a disconnect during that await on the waiting path was
  missed and leaked presence/idle state. Register the close tracker before the
  first await; after the initial take, bail (requeuing any taken batch) if the
  client is already gone; run cleanup if a close raced the long-poll setup.
- LOW: requeueFeedback restored a batch but didn't wake other already-open
  consumers. Emit a "feedback" event after every server-side requeue. A `stopped`
  flag on the stream (generalizing the old oneDelivered) blocks the dead stream's
  own re-drain so the re-emitted event can't loop.
- Minor: wrap the immediate poll res.json in try/catch so a synchronous write
  failure requeues instead of dropping.
- Add a deterministic two-concurrent-streams test asserting a message is
  delivered exactly once (atomic take, no double-delivery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he batch

Round 4 review (Claude, Important): the stream drain runs fire-and-forget
(drain().catch(noop)). takeFeedback has already cleared the batch, so if the
restoring requeueFeedback throws (e.g. a transient store-write failure), the
rejection was swallowed and the batch vanished with no trace — while the poll
path surfaces the same failure. Wrap the disconnect and once-tail requeue calls
so a failure is logged via writeLog rather than silently eaten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d() path

Round 5 review (Claude, Minor): the immediate poll path requeues if res.json
throws synchronously, but the woken long-poll respond() path did not — a
synchronous write failure (e.g. ERR_HTTP_HEADERS_SENT) after taking the batch
would drop it. Wrap the respond() write to requeue on throw, matching the
immediate path. (Within the inherent res.write != receipt window; cosmetic
symmetry, no behavior change on the happy path.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reply indicator's text is a flex child with white-space:nowrap +
overflow:hidden + text-overflow:ellipsis, but lacked min-width:0 — so it
refused to shrink below its content width and pushed the whole composer off to
the right instead of truncating (reported as "wraps infinitely to the right"
when replying to a long message). Add min-width:0 to the indicator text (so the
ellipsis engages) and to the indicator container (so it doesn't expand the
composer grid track).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two requests from live testing:
- Agent bubbles now render a safe subset of inline markdown (**bold**, *italic*,
  `code`), escaped-then-formatted so message text can't inject HTML.
- The Reply affordance is on every message (yours included), not just agent
  bubbles, and all messages are stored for quoting. So a user can thread under
  their own messages. The server re-broadcasts chat-sync on a new "chat-changed"
  event after queuePrompts, so a just-sent message gets its server id (and thus a
  Reply button) without a reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promotes threadReplyIndicatorText to a live const, re-adds truncateQuote,
adds setThreadReplyTarget(id, text) which sets threadReplyToId + shows the
reply indicator with the quoted snippet and focuses the thread input.
Extends buildBubble with a reply:true option that appends a Reply button
wired to setThreadReplyTarget. renderThread now passes reply:true to both
the pinned root and each reply so all messages in the thread are reply-able.
openThread already called clearThreadReplyTarget so stale targets are always
cleared on open. setThreadReplyTarget is exposed on the __lavishTest seam;
chrome-harness gains threadingReplyTo(id, text). Two new TDD tests verify
sub-reply targeting and that opening a thread clears the prior target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tests (round 1)

Fix 1: buildBubble reply=false|"open"|"target" so reply-less roots get a
Reply affordance that opens their thread (renderChat), while thread-panel
bubbles use "target" to set the reply indicator (renderThread).

Fix 2: mint temp ids "local-N" for optimistic sendQueued/sendThreadReply so
orderedMessages includes the message immediately; server chat-sync clears
them on reconciliation (setMessages already clears the model).

Fix 3: formatRelativeTime now accepts ISO-string at values via Date.parse,
fixing dead relative times when server writes at: new Date().toISOString().

Fix 4: expose buildBubble + orderedMessages through the __lavishTest seam;
add threadingBuildBubble + threadingOrdered to chrome-harness; replace the
misleading id-less-optimistic test and add ISO-timestamp + reply-affordance
tests (14 → 16 threading tests, 254 → 256 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate.

This is still waiting on you, not on a captain decision.

Saw the new head adb26250 (docs + layout-gate comment clarity past b4f94a87). Incremental fork diff reviewed: AGENTS.md / README.md wording and a chrome-client comment only — no workflow/deps, not a security flag. Approved CI and Guard on this head so they can run.

Require no-mistakes is still blocking, and the PR body has no pipeline attestation for adb26250. After a matching raise on that head, we'll look again.

Comment thread src/chrome-client.js Outdated
@eloise-idealab eloise-idealab changed the title feat: real-time SSE streaming, message threading, and always-on send (supersedes #111) feat: add real-time stream delivery, threaded replies, and annotation card actions Aug 26, 2026
Comment thread src/chrome-client.js Outdated
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate.

This is held for a captain decision, not waiting on you.

Head f1ab0d2f has a matching no-mistakes pipeline attestation. Incremental review since adb26250: optimistic-bubble takedown on refused/failed sends (addresses the earlier Greptile phantom-message note), docs alignment, and dropping the brittle --version wall-clock ceiling. No workflow/deps; not a security flag. Approved CI, Guard, and Require no-mistakes — all green on this head (ubuntu/macos/windows + Guard + attestation). Greptile check is red; treating that as advisory.

VISION (full PR against VISION.md on main a7ddbba):

  1. artifact stays the author's: align — chrome/server/CLI/session store only; saved HTML / one-script injection unchanged.
  2. interaction beats prose: align — threads, unread chips, and Queue/Send keep pointing and answering on the page.
  3. design is chosen, never defaulted: align — no artifact design defaults.
  4. nothing interrupts the human: align — detection stays passive; bubble takedown stops false "delivered" phantoms; stream/poll only deliver deliberate human sends.
  5. every token is spent on purpose: align for opt-in stream as an alternative wait surface; inconclusive on whether always-on send + thread panel + agent markdown earn their chrome cost every session (product call).
  6. the instructions are the product: align — stream / --reply-to owned by CLI help; skill stub left pointing at CLI.
  7. scope: align — still one person, one agent, one local HTML file; no hosting/MCP/daemon.

Default-behavior (not auto-mergeable): annotation card Cancel/Queue/Send split, conversation thread panel + unread chips, agent inline-markdown rendering, and treating stream as a peer presence consumer. Flagging for captain leave/land — I will not squash-merge this.

ezeng added 11 commits August 28, 2026 22:21
Resolves the AGENTS.md conflict from kunchenguid#301 (server-derived presence). The new
"Presence is server-derived" paragraph moves into the renumbered §9 and widens
from "poll" to "poll or stream": an open stream calls the same setPollActive,
so it shares the presence refcount rather than keeping one of its own.

Adds the regression test that pins that cross-consumer rule, which kunchenguid#301's
poll-only tests do not reach: a stream attaching with nothing else in flight
retires the previous round's delivery exactly as a fresh poll does, and
releasing it returns presence to waiting instead of stranding "listening".
@eloise-idealab eloise-idealab changed the title feat: add real-time stream delivery, threaded replies, and annotation card actions feat: stream user messages in real time with threaded replies Aug 30, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate.

This is waiting on you, not on a captain decision.

Saw the new head a3beba3e (13 commits past held f1ab0d2f: merge of main/#301, stream presence pairing, isTranscriptMessage including image-only sends, thread seen-credit refund/credit, bounded stream stdout flush, and a tests-only Windows skip on the last commit). Incremental fork diff reviewed: no workflow/deps, /api/stream still GET behind the existing Host allowlist, ids still server-minted (after attachment resolution). Not a security flag. Approved CI and Guard on this head so they can run.

Require no-mistakes is blocking. The pipeline attestation in the PR body is for aa1a9f7f, not a3beba3e. After a matching raise on this head, we'll look again. The prior captain default-behavior hold still applies once that blocker is clear. I will not squash-merge this.

VISION (full PR against VISION.md on main, incremental inspected at a3beba3e):

  1. artifact stays the author's: align - chrome/server/CLI/session store only; saved HTML / one-script injection unchanged.
  2. interaction beats prose: align - threads, unread chips, and Queue/Send keep pointing and answering on the page.
  3. design is chosen, never defaulted: align - no artifact design defaults.
  4. nothing interrupts the human: align - detection stays passive; chrome no longer self-promotes to working on send (server-derived presence); stream/poll only deliver deliberate human sends.
  5. every token is spent on purpose: align for opt-in stream as an alternative wait surface; inconclusive on whether always-on send + thread panel + agent markdown earn their chrome cost every session (product call).
  6. the instructions are the product: align - stream / --reply-to owned by CLI help; AGENTS.md/README follow; skill stub left pointing at CLI.
  7. scope: align - still one person, one agent, one local HTML file; no hosting/MCP/daemon.

contract-class: new-default (annotation Cancel/Queue/Send, thread panel + unread chips, agent inline-markdown, stream as a peer presence consumer). Incremental does not flip that.

@eloise-idealab eloise-idealab changed the title feat: stream user messages in real time with threaded replies feat: stream feedback over SSE with threaded replies and unread thread chips Aug 30, 2026
@eloise-idealab

Copy link
Copy Markdown
Author

The matching raise you asked for is in: head is now 620afd8e64276c07c97cc723953f718d15d11b34, and the PR body's no-mistakes attestation is for that exact SHA (head_sha == headRefOid; review, test and document all completed).

Incremental fork diff since a3beba3e — the head you reviewed this morning — is one commit, docs only:

620afd8 no-mistakes(document): note SSE stream alongside long poll in AGENTS.md
 AGENTS.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

No workflow files, no dependency changes, no source change at all. The three lines widen prose that named only the long poll so it also names the stream: the Architecture sentence ("...over a long-polling API or a real-time SSE stream"), the passive-detection line ("can never make a lavish-axi poll or lavish-axi stream deliver"), and the ended-session rule ("no later poll or stream will deliver it"). Nothing behavioural.

The ask, and it is only this: all four workflow runs on 620afd8e are sitting at action_required under the fork-PR approval gate, which re-arms on every new head — so Require no-mistakes has never actually executed against a head whose attestation matches. Could you approve them so the checks can run?

Greptile has re-reviewed this head and is now green (5/5, "no blocking failure remains"), which covers the two notes you raised on 08-25: stream consuming non-message batches on artifact failures, and failed chat submissions leaving optimistic bubbles — both were fixed on the way to f1ab0d2f and are still in.

I am not asking for a merge. Your captain default-behavior hold from 08-26 stands on its own terms, and "I will not squash-merge this" is understood and respected. This is only about clearing the mechanical blocker you named, so the decision you are holding is a decision about the change rather than about a check that was never allowed to start.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

This is held for a captain decision, not waiting on you.

Saw the new head 620afd8e (one commit past a3beba3e: AGENTS.md only — Architecture, passive-detection, and ended-session lines now name stream beside poll). Incremental fork diff reviewed: no workflow/deps, no source change. Not a security flag. Approved CI, Guard, and Require no-mistakes — all green on this head (ubuntu/macos/windows + Guard + attestation). Greptile is 5/5 on this head.

The pipeline attestation in the PR body MATCHES 620afd8e (review/test/document completed). The prior captain default-behavior hold still applies. I will not squash-merge this.

VISION (full PR against VISION.md on main, incremental inspected at 620afd8e):

  1. the artifact stays the author's: align — chrome/server/CLI/session store only; saved HTML / one-script injection unchanged; incremental is docs only.
  2. interaction beats prose: align — threads, unread chips, and Queue/Send keep pointing and answering on the page.
  3. an artifact's design is chosen, never defaulted into: align — no artifact design defaults.
  4. nothing interrupts the human: align — detection stays passive; AGENTS.md now states a diagnostic pass cannot make poll or stream deliver; stream/poll only deliver deliberate human sends.
  5. every token is spent on purpose: align for opt-in stream as an alternative wait surface; inconclusive on whether always-on send + thread panel + agent markdown earn their chrome cost every session (product call).
  6. the instructions are the product: align — stream / --reply-to owned by CLI help; AGENTS.md now names stream beside poll; skill stub left pointing at CLI.
  7. scope: align — still one person, one agent, one local HTML file; no hosting/MCP/daemon. VISION.md on main still names only the long poll as the continuous-session hold; that is a VISION-owner call, not a widening of this PR.

contract-class: new-default (annotation Cancel/Queue/Send, thread panel + unread chips, agent inline-markdown, stream as a peer presence consumer). Incremental docs do not flip that.

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.

2 participants