Conversation
An alias arrived as a bare value. The note the user wrote about it — "read
replica only", "revoke after use" — was dropped at the send boundary, because
the wire map could only hold name→value. So the composer could find an alias by
its note while the agent it was sent to never saw one, and the constraint that
made the value safe to act on was exactly the part thrown away.
The note travels in a sibling map rather than widening the value map into
`string | {value, description}`. The daemon is a globally-installed CLI that
users upgrade independently of the server-served web UI, so new-web +
old-daemon is routine, and an old daemon calls nfc() on each entry: an object
there throws TypeError and the send never reaches the agent at all. An unknown
sibling field is ignored instead. Sends with no note are byte-identical.
Notes go only to the legend, never to inline expansion — inline lands in a
shell command for shell/script agents, where prose would corrupt it.
Long notes ride every send whether or not they get used, so past 200 code
points the line carries the leading slice plus a pointer to list_aliases, which
returns descriptions and never values. This is a real gate, not a formality:
the map is client-supplied and never re-checked against the save-time ceiling.
The sanitizer's cap is deliberately above the budget, or an oversized note
would arrive pre-trimmed to exactly 200 and nothing would tell the agent that
anything had been withheld.
Notes are covered by the send audit anchor, since a silently edited note
changes what the agent saw; note-free sends hash exactly as before. They are
scrubbed from server logs on the same terms as values — a note routinely
describes what the secret is for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These were the same number, so the only way to stop a long note burning tokens on every send was to forbid writing one. Wrong lever: the note is written once and read by a person, while the injection cost recurs per message. Storage now allows 2000 code points and injection spends at most 300, past which the line carries the leading slice plus the pointer to list_aliases. The column is already TEXT, so the old ceiling bought nothing at the database. This also makes the truncation path real for ordinary aliases. While the save cap equalled the budget, nothing saved through the UI or MCP could ever exceed it, and the only notes that could trip the cut were oversized ones from a client bypassing validation. The three ceilings now carry an explicit ordering test. Inline budget below the save cap, save cap below the out-of-band hard ceiling — collapse either gap and the behaviour degrades silently rather than failing: equal save cap and budget makes the hint unreachable, and an equal hard ceiling pre-trims a legal note to exactly the budget so nothing signals that anything was withheld. The limit is no longer restated in seven translations or in the MCP tool schema; both take it from the constant, so they cannot drift from what the server actually enforces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Simple-view chip showed counts and a progress rail, which answer "how many" but not "what is it stuck on" — the only question worth asking during a long wait. It now carries the newest tool's elapsed time, live while running and final once finished, and on desktop the command or search pattern behind it. The descriptor is desktop-only via CSS rather than a JS media query, so it costs nothing on resize, and the chip's width ceiling rises with it — added without that, the text would just squeeze the rail. Merging a call with its result kept only the call's timestamp, so a finished tool had no computable duration; the result's timestamp now rides along. The elapsed value is read from the original call input rather than the merged one, which has a ✓/✗ status appended that the counters already show. Also fixes a latent bug this made visible: useNowTicker seeded from the shared store's last known time, which a store with no listeners keeps forever, so a re-activated ticker rendered one frame of an arbitrarily stale clock. Harmless for a wall clock that immediately corrects, wrong for anything counting up from a timestamp — it showed 495989h24m in test. It now starts the store first, so the seed is at most one interval old. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shipped wrong: the chip showed a raw argument dump with no tool name and no elapsed time, and dropped the descriptor entirely on mobile. The name and time were missing for the same reason. The last event in a group is frequently a standalone tool.result — the merge window pairs a call with the FIRST result after it, so a second unpaired result lands last — and a result carries neither a tool name nor a start time. It now walks back to the newest tool.call. The argument dump came from deriving the input by hand instead of the way the full row does it. Both now share one derivation, so a streamed call whose args arrived only on the result still renders, and the chip takes the same first-line preview rather than a whole JSON body. Full width on every client, with the descriptor ellipsing instead of being dropped. Sizing to content made the chip jump around as tools changed, and hiding the descriptor on mobile removed the only answer to "what is it doing" — which does not stop being the question on a small screen. Removed a "look for a trailing result" timing branch: the merge scans forward by array position, so a result adjacent to its call always merges, and no test could reach it. Same for the ✓/✗ stripping in the previous commit — the merged payload keeps the pre-status input under its own key, so nothing needed stripping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three gaps a peer audit found in the alias send path, all on the queue. Retrying a failed queued message resent bare text. Values never live on the timeline, so the entry still holds `;;(name)` — a legend agent got the literal marker and shell/script failed closed. Retry now re-resolves from the current alias list rather than replaying a stored map, which also keeps values out of queue component state and picks up any edit since the original send. Editing a queued message dropped the old expansion and computed no new one, so an edit that still referenced an alias lost its value. The edit command now carries the resolution for the NEW text and the daemon re-expands from it. The old expansion is still cleared first, so a restart before re-delivery cannot rehydrate a secret belonging to text that no longer exists. The audit anchor only rode immediately-sent messages. Anything dispatched after a drain, a reconnect, or a daemon restart reached the provider with its value while the timeline carried no names+hash record of what was delivered. The anchor now travels with the queued entry, through SQLite and the resend queue. It deliberately survives the public queue projection that strips providerText — it holds only names and a hash, and the drain callback is what needs it. Covers the wiring, not just the helpers: the previous round's tests passed with the entire daemon-side note wiring deleted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The collapsed chip went full width but the expanded detail rows stayed capped at min(100%, 760px), so they were laid out into a container narrower than the space available — and with the 6px left margin the remainder ran past the right edge instead of fitting. Width is now driven by the stretching column, with min-width: 0 so the horizontally-scrolling rows inside shrink to the container rather than forcing it wider. Both widths are pinned as style contracts: jsdom does not load stylesheets, so reading the rule is the only way to keep this from silently regressing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The history strip showed a bare ✓ next to an empty chat, which reads as "your history is cached but we refuse to show it". The tick only ever meant the step finished — it said nothing about whether anything came back, so a device with no stored copy and a device whose stored copy fails to reach the view looked identical. Each step now carries the number of events it produced, and a local read that returned nothing reports `empty` rather than success. Between them, "nothing is stored here" and "something is stored but is not being shown" are finally distinguishable from the UI instead of needing a debugger. The count is read defensively: a status built by an older caller has no such field, and indexing it unguarded took down the whole chat view to lose one number. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ProviderError is a plain object { code, message, recoverable, details },
not an Error, so `error instanceof Error ? error.message : String(error)`
collapsed every provider failure to the literal `[object Object]` — in the
user-facing warning and in the daemon log alike:
Automation could not obtain a decision from supervisor model
codex-sdk/gpt-5.3-codex-spark after 3 attempts: [object Object].
The code was read correctly from the same object one line below, so the
failure looked classified while its cause was destroyed, leaving nothing to
diagnose with. Read the object's own message first, and fall back to the
error code rather than ever emitting the placeholder. Applied to the two
sibling call sites so a non-Error thrown elsewhere in the chain cannot
degrade the same way.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Its background stacked a 12x12 graph-paper texture, and the first of those layers is a horizontal rule every 12px. A collapsed fold whose content is shorter than its box leaves that texture bare, so the leftover stripes read as stray separator lines between the messages on either side. Dropped both grid layers and kept the card's own gradient, so the fill is unchanged and only the texture goes. Nothing else uses it — that background-size was the single occurrence in the stylesheet. This is a second, independent cause of the same symptom: an earlier fix removed per-event margins that exposed the chat backdrop between adjacent assistant cards, which looked identical and left this one still visible. Pinned as a style contract. jsdom loads no stylesheets, so reading the rule is the only way to keep it from coming back; the assertion targets the repeating line stop rather than the gradient, so changing the fill stays free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two ways the supervision loop wasted rounds. A session that had dispatched a peer audit and was barred from touching the repository until the verdict returned could only be classified `continue` — the enum had no way to say "correctly parked" — so automation re-prompted it and it answered "still blocked" every time. Adding `waiting` parks the run instead: no continue contract, no finish, and the awaited reply's own next turn resumes it, so nothing polls. A 30-minute bound still hands back to the human, because a reply that never arrives must surface rather than strand the run. The park timer is identified by its handle, not by generation+phase: generation restarts at 1 when a run is cancelled rather than replaced, so a stale timer could match a later run and terminate it. It is also disarmed before the broker await — left armed across it, the timer could fire mid-decision and discard the very verdict the park existed to wait for — and cleared at every site that drops a run, not just finishRun. Repeat audits had no memory. Each round re-derived what the previous one had already cleared and surfaced a fresh crop of incidental findings, so rounds diverged instead of converging, and an unresolved finding tended to be replaced by a newly-noticed unrelated one rather than answered. The brief now carries the previous REWORK findings and asks the auditor to close them item by item. Only PASS clears that carry-over: a timeout or cancellation decided nothing, so the open items are still open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
requiresAudit is a yes/no, so a two-line stylesheet tweak and a cross-layer state-machine change bought the same full independent round. That is the main reason a supervised session feels audited constantly: the cost never varies with the risk. Decisions can now carry auditDepth. `narrow` keeps the independent check but scopes it to the diff and what it directly touches, for a small self-contained change whose blast radius is visible in the diff. `standard` stays the default and is what anything touching protocol, persistence, auth, concurrency, or multiple layers gets — including whenever the model is unsure. The prompt also says plainly that requiresAudit false is correct for a change with no behavioural surface at all, rather than spending a full round confirming that a comment edit changed nothing. A rework round always re-opens the full surface: the previous verdict already established that a narrow read was not enough, so repeating it would re-run the same insufficient check. Narrow scopes the work, not the standard of evidence — both the delegated task and the peer-audit brief still refuse a PASS that rests on static reading when a relevant executable check exists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile session-controls row right-aligned its meta controls only as a side effect of layout: `.shortcuts-meta-scroll` was `display: contents`, so its children were direct flex children of the row and `.shortcuts` (flex: 1) absorbed the free space and pushed them to the edge. Making that scroller a real horizontal scroller on mobile inverted this — the scroller itself became the growing item (flex: 1 1 0), and its children packed to the start of the leftover space, sitting flush against the Stop button. Push them back with an auto start margin instead of `justify-content: flex-end`: on an overflowing scroll container flex-end can leave the leading items unreachable, whereas an auto margin only absorbs positive free space and collapses to 0 once the content overflows, preserving the existing scroll behaviour. The Stop group is `flex: 0 0 auto`, so it is never overlapped or compressed. The styles contract case that already claimed "meta header controls stay right" only pinned the scroller's scroll properties, which is why the regression slipped through; it now asserts the alignment rule too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Simple-view tool chip could only ever show a one-line preview of the newest call, and its native `title` tooltip repeated that same truncated string. During a long tool run the one thing you want — what exactly was run and what came back — was reachable only by expanding the whole group. Hovering the chip now opens a peek with the full command and the final output. It is portalled to the body and anchored to the chip's viewport rect, because the chat scroller clips overflow; it prefers to open upward so the reply text stays readable and flips down only when there is no room. The panel renders from the live event stream, so a call that is still running shows a pending state and swaps in the real output in place, without re-hovering. Merging a call with its result recorded `_toolFailed` but kept the failure text only inside the composed `input` string, so a failed call had nothing to show in the output section. The reason is now preserved verbatim. Touch clients are excluded via `(hover: hover)`: they have no hover, and opening the peek on a tap would fight the existing expand toggle. The chip's `title` is dropped — the peek supersedes it, and keeping both stacked a native tooltip over the panel. `aria-label` is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
STOP visibly did not stop the automatic continue prompts. Two separate reasons, both fixed here. The stand-down was buried inside `cancelTransportTurnNow`, after two early returns, in an async block that is only reached when the session is a transport session AND its runtime is still live. `cancelSession` appeared exactly once in the whole command handler, at that one spot. So a process/tmux agent bailed at the `isTransportStop` check and a transport session whose runtime had already gone bailed at the `!stopRuntime` check — in both cases the supervision state machine stayed fully armed and kept waking on its deadline. Hoist it above both returns; it is idempotent, so running it for a session the function then declines to handle is a no-op. Cancelling only the stopped session was also not enough. A supervised-audit run lives on the *supervisor* session and drives its `auditTargetSessionName`, so stopping the audit target left the driver running and it re-sent continue prompts at the session the user had just stopped. `cancelForUserStop` now sweeps the drivers too and cancels their in-flight audit, whose awaited reply can no longer arrive, instead of letting it time out much later. Raw ESC via `session.input` is deliberately not treated as a supervision stop: it is an ordinary keystroke for vim and other TUIs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a low-spec machine, restoring several sub-session windows blocked the
main thread long enough that the tab stopped responding to input — and to
a reload. Because the open-window list is persisted in localStorage, the
reload re-mounted the same windows and froze again, so the usual escape
made it worse.
Measured in a 20x-CPU-throttled browser (web/perf-harness), 4 windows x
300 history events, worst single uninterruptible task:
16170 ms -> 3778 ms
Four independent causes, each verified by profile rather than assumption
(the first guess, "markdown parsing", did not appear in the profile at
all):
* Merged tool call+result events were rebuilt on every buildViewItems
run, so every settled event in view lost its identity and ChatEvent's
memo missed for the entire list on every streamed token. Cached by
(call, result) event id. Streaming blocked time 56s -> 11s.
* scrollToBottom ran 5x per window mount, each call reading
scrollHeight and forcing a full synchronous layout of the message
list. All five target the same place, so the DOM measure+write is now
coalesced to one per frame; the follow/suppress policy stays
synchronous. It was 24.6% of profile self-time and is now absent from
the top ten.
* formatChatDateTime built a fresh Intl.DateTimeFormat per rendered
timestamp. Cached per (locale, shape): 7.9% -> 2.3%. Output is
unchanged — toLocale* differs from Intl.DateTimeFormat only in the
defaults injected when no field is requested, and both shapes request
theirs explicitly.
* All open windows mounted in one render pass, so their cost formed a
single task that grew with window count. They now mount one animation
frame apart (useProgressiveMount), focused window first. Total blocked
time is unchanged (15594 -> 15583 ms) — the work is the same, it is
just interruptible now — while the worst task stops scaling: 4 windows
3778 ms, 8 windows 3840 ms, versus 6518 / 10167 ms unstaggered.
CHAT_INITIAL_RENDER_ITEM_LIMIT drops 250 -> 60. Mount cost measures as
~900 ms fixed plus ~10 ms per rendered item per window, and a window shows
10-30 items; scrolling up still auto-reveals CHAT_RENDER_ITEM_INCREMENT
more, anchored.
Also fixes a pre-existing bug found while testing the merge: the merged
event dropped `_toolError`, so a failed tool call lost its error text.
Adds a byte-level guard for NUL bytes in source. Two landed in
ChatView.tsx during this work as cache-key separators; they compile, bundle
and pass every behavioural test, but make grep/rg treat the whole 200KB
file as binary and report "binary file matches" instead of the matching
lines. Only a byte scan can catch that.
Verified: web 204 files / 2639 tests; daemon 543 files / 6267 tests;
server 84 files / 1139 tests plus 23 integration files / 367 tests against
real PostgreSQL; root, server and web tsc clean; npm run build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ions Both defects were introduced by the previous commit and share a shape: a cache or a clamp that answers "nothing changed" when it should have said "I don't know", so incremental output stopped reaching the screen and content only appeared when something forced a full repaint. Merged tool events were cached by eventId pair. A tool result keeps its eventId while its payload changes — streaming output grows under the same id, and detail hydration replaces a truncated payload with the full one under that same id — so the first, shortest merge was pinned forever and the real output never rendered. Now keyed on the two source objects via a WeakMap pair: useTimeline updates events immutably, so a new object is exactly the signal that content changed. Unchanged inputs still return the same merged object, so the memo this cache exists for still holds, and the size cap and eviction are gone with it — entries die with their events. The existing test only covered a result arriving under a DIFFERENT eventId, which is the case that already worked; the same-id payload change — the one that actually happens — was untested. That gap is why this shipped. TerminalView clamped a missing or non-finite `diff.rows` to 0. Every line then failed the bounds check and the buffer was sliced to empty, so incremental frames painted nothing and output only appeared when the next full frame redrew the screen at once. The original code sized the buffer with `lines.slice(0, diff.rows)`, and `slice(0, undefined)` keeps the whole array — an absent `rows` was tolerated. resolveDiffRows now separates "declares nothing" (null → bound by the protocol ceiling, leave the buffer alone) from "declares zero" (0 → really truncate), keeping the allocation bound that the clamp was added for. Both fixes are covered by tests verified against re-injected regressions: each new assertion fails on the old code and passes on the new. Verified: web 204 files / 2645 tests; root, server and web tsc clean; npm run build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Streaming output is reported broken — assistant text arrives as one block instead of incrementally — and two attempted fixes on top of 1e07c45 did not resolve it. This reverts both commits so the question can be answered instead of guessed at. What the evidence says so far. A harness that reproduces the real streaming shape (one event whose payload.text grows under a stable eventId, updated immutably as useTimeline does it) renders 40 distinct intermediate states per 40 ticks at d11f94f, at 7de5edb and at a337fc3 alike, for both assistant text and tool output. The render layer therefore behaves identically before and after this work, and terminal incremental output travels the raw PTY byte path, which none of it touched. That is evidence, not proof: the harness feeds ChatView directly and so does not exercise WS delivery, useTimeline, or subscription lifecycle, which is where the symptom most plausibly lives. Reverting settles it either way — if streaming recovers, the cause is in here and the search space is 19 files; if it does not, this work is exonerated and can land again unchanged while the delivery path is investigated. Reverting costs the measured freeze fix (worst uninterruptible task with 4 restored windows: 3778 ms, versus 16170 ms before it). That is the right trade against a core interaction being unusable, and it is meant to be temporary. Two genuine defects found while chasing this are reverted with it and must come back when it re-lands: the merged tool event cache keyed by eventId rather than by object identity, which pinned the first short output of a result whose payload later grew or was hydrated; and TerminalView reading an absent diff.rows as zero rows, which blanked the buffer instead of leaving it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sub-sessions stopped streaming while main sessions were unaffected: a turn's
text arrived as one block at the end instead of incrementally.
The server drops its whole `activeSubSessions` map when the daemon socket
closes. That map is what authorizes a browser's `chat.subscribe` for a
`deck_sub_*` name — a sub-session has no row in the `sessions` table, so the
only other route is a `sub_sessions` DB lookup. The map is repopulated only
by `subsession.sync`, which the restore broadcast sends, and that broadcast
was invoked exactly once, from daemon startup. Its own comment says
"connected late, reconnected", so reconnect was always the intent; the wiring
just never existed.
One socket blip was therefore permanent. The map stayed empty for the rest of
the daemon's life, every sub-session subscribe was rejected, and the server
then discarded their live timeline events for having no subscribed viewer
("Timeline event discarded: no subscribed viewer"). HTTP backfill still filled
the chat in, which is why the text appeared complete but never streamed. Main
sessions authorize straight from the `sessions` table and never depended on
the map.
The reconnect handler now also schedules the restore broadcast, and the
broadcast keeps at most one timer armed so a flapping link cannot stack a
replay of the session list per reconnect.
Honest limits. This is derived from reading the subscribe/authorize path, not
from observing the rejection: the server is behind a tunnel and its logs and
metrics were not reachable from here, and every other candidate was eliminated
first (render layer A/B across three commits, terminal raw path, JSONL and
terminal-parse, SDK partial messages, delta route drops, browser rate limit,
preview mode, the transport sub-session subscribe effect). It also carries no
unit test — `startDaemon` is a single large function with no seam to assert
the wiring through; extracting the sub-session sync loop into its own exported
function is the change that would make it testable.
It predicts something checkable: restarting the daemon should restore
sub-session streaming immediately even without this commit, because startup
runs the same broadcast. If it does not, this diagnosis is wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sage
Sub-session replies stopped rendering progressively — the text arrived in
one block at the end — while main sessions were unaffected.
Same-eventId conflicts were resolved by completeness before anything else.
That is right for a settled event: a later truncated copy must not downgrade
text already fetched in full. Mid-stream it is backwards. Detail hydration
rebuilds the event as `{ ...existing, payload }`, so the hydrated copy
inherits the seq of the snapshot it hydrated — an older generation than the
deltas still arriving. Ranked first, it outranked all of them: each later
delta lost the merge, `mergeTimelineEvents` reported no change, `setEvents`
returned the same array, and the view froze until the terminal event replaced
it.
Two orderings were wrong, not one. Completeness also outranked terminality,
so a hydrated streaming snapshot beat the very event that settled it. My
first attempt fixed only the first and its own test caught the second.
The order is now lifecycle → in-flight freshness → completeness → freshness:
a terminal version always wins; between two streaming generations of one
message the higher seq is simply the longer text; same-generation pairs still
fall through to completeness, so the existing full-over-preview and
hydrate-an-existing-preview contracts are untouched.
This bites sub-sessions hardest because `SubSessionWindow` arms history
backfill unconditionally (`isActiveSession: true, isVisible: true`) where
`SessionPane` gates on `isActive`, so a sub-session is far more likely to
fetch and hydrate a truncated copy of a message that is still streaming.
Honest limit: derived from reading the merge path, not from a captured frame
— the server sits behind a tunnel and its logs were unreachable. It is a real
defect on its own terms and produces exactly this symptom, but whether it is
the whole of what was reported is unconfirmed. Three earlier attempts on this
report were wrong; treat it as confirmed only once sub-sessions visibly
stream again.
Verified: 15/15 in timeline-merge including three new cases, each failing on
the old ordering and passing on the new; daemon shared+timeline 82 files /
787 tests; web 199 files / 2619 tests; root and web tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comparing the whole streaming path against master — SDK delta, transport relay, timeline emitter, daemon forwarding, server fan-out, ws-client, ChatView — leaves exactly one behavioural change in that range: timeline events now also go to a subscribed user's other browser connections. Nothing else on the path differs. transport-relay's changes are cancel/error text, lifecycle's are file transfer and delegation resume, useTimeline's added ids feed history-status counters only, and merge.ts was byte-identical to master until the fix in the previous commit. That fan-out reads as strictly additive: the two subscriber loops are the old `sendJsonToSessionSubscribers` verbatim, and the extra sends go to sockets it never touched. It should not be able to remove delivery. But "should not" has been wrong three times on this report — the memo/merge cache, the diff.rows clamp, and the reconnect re-sync were all reasoned to be the cause and all were not — so this stops arguing and measures. The flag narrows delivery to what master did, and nothing else. If sub-sessions stream again, the fan-out owns the regression and can be reintroduced in a form that does not; if they do not, it flips straight back on and the multi-device split it fixed stays fixed. It is one boolean either way. Cost while it is off: a second device that has not settled its own subscription goes back to seeing only the final event via backfill — the split-brain the fan-out was written to fix. The two tests covering it are skipped rather than rewritten, so re-enabling restores the contract intact. Verified: server 84 files / 1137 tests / 2 skipped, server tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sub-session streaming did not come back with the fan-out disabled, and the disabled build did reach production: the server image carries the web bundle (server/Dockerfile builds web/ into it), so app-build.json dates the running container at 14:15, after the bisect commit at 14:08. The experiment ran. So the fan-out is not the cause, and since it was the only behavioural change on the streaming path between master and dev — transport-relay's diff is cancel/error text, lifecycle's is file transfer and delegation resume, useTimeline's added ids feed history-status counters, merge.ts was identical to master until two commits ago — that path carries no regression at all. Whatever breaks sub-session streaming is outside it, or was never a regression to begin with. Turning it back on costs nothing and restores the multi-device fix: a second device that has not settled its own subscription sees the live stream instead of only the final event. Both tests come back unmodified rather than rewritten around the flag. Verified: server 84 files / 1139 tests, server tsc clean; bridge.ts is functionally identical to the pre-bisect commit (diff is comment-only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…riptions Sub-session replies arrived in one block instead of typing out; main sessions were fine. The cause is not on the streaming path at all — which is why four attempts at it were wrong, and why comparing dev against master found no regression there. `subSessionRebuildAll` sent every sub-session in one message. At 178 of them that crosses the 60 KB outbound cap and `send` throws "Message too large". The throw escaped the effect in useSubSessions that calls it, and an effect that throws takes the rest of its flush with it. `useSubSessions` is invoked early in App, so its effects run before App's later ones — including the two that open transport chat subscriptions. The sub-session subscribe effect is scheduled in the same flush as the rebuild (both keyed on the sub-session list, which had just loaded), so it never ran. With no subscriber the server discards those sessions' live timeline events by design, and their text only reappeared via history backfill: no typewriter. Main sessions were untouched because their subscribe effect keys on the main session list, which settles in an earlier flush — nothing rescheduled it, so the aborted flush cost it nothing. That asymmetry is the whole main-vs-sub split, and it only appears once the sub-session count pushes the message past the cap. Two changes. The message is now split into size-measured batches under the cap — `rebuildSubSessions` upserts per item and prunes nothing, so splitting is equivalent to sending it whole. And the call site no longer lets a send failure escape: a rebuild that fails to send is a degraded daemon-side view, while cancelling the subscriptions behind it is a dead chat stream. The chunker tests alone were not enough — reverting the call site to a single send left all of them green. The added client-level tests drive the real WsClient over a mock socket, and on the single-send call site they fail with the exact "Message too large" from the report. Verified: web 200 files / 2627 tests, web tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts the temporary revert. The work was pulled to bisect a sub-session streaming report; that report has since been traced to `subsession.rebuild_all` overflowing the outbound WS cap and throwing, which aborted the effect flush that opened the transport chat subscriptions — nothing to do with any of this. Leaving it out only costs the freeze fix. Restored unchanged: merged tool events cached by object identity so a settled call keeps its identity across rebuilds; scrollToBottom coalesced to one DOM measure+write per frame instead of five forced layouts per window mount; Intl.DateTimeFormat cached per (locale, shape); the initial chat render limit at 60; sub-session windows mounted one animation frame apart, focused first; the terminal row clamp and its shared limits; and the byte-level NUL guard. Measured before the revert, 20x-CPU-throttled, worst uninterruptible task with 4 restored windows: 16170 ms -> 3778 ms, and no longer growing with window count (8 windows: 3840 ms staggered vs 10167 ms not). One tidy-up beyond a pure revert: the doc comment for isRenderableLineIndex had ended up above resolveDiffRows and is moved back onto its own function. Comment-only — the diff against the reverted commit shows no code change. Verified: web 205 files / 2653 tests; terminal-streamer + timeline-merge 34 tests; root, server and web tsc clean; npm run build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary\n- add direct controlled-node file transfers, authenticated TURN relay support, transfer diagnostics, progress/ETA, cancellation, and remote attachment deletion\n- improve supervision lifecycle, peer-audit parking, failure reporting, and audit proportionality\n- restore reliable multi-device and sub-session streaming while batching rebuild traffic and preventing hydrated snapshots from freezing live text\n- ship universal macOS controlled-node packaging plus controlled-node upgrade and direct-transfer hardening\n- improve mobile/session UI, tool activity visibility, alias delivery, and timeline restore/correlation behavior\n- recover Codex app-server malformed resume responses immediately and bound malformed-frame diagnostics\n\n## Validation\n- focused Codex malformed-response suites: 153/153 passed\n- root typecheck and production daemon build passed for the latest targeted fix\n- dev-to-master merge-tree conflict check found no conflicts\n- PR-specific CI must pass at the PR head SHA before merge