feat: hook-driven task state, session drag between panes, and a terminal that survives a flood - #341
Merged
Merged
Conversation
The heuristic that infers waiting_input from output silence had asymmetric thresholds. Holding `running` required 256 bytes inside a one-second burst window, but restoring `running` from heuristic waiting only required 256 bytes total, with no window at all. Any session emitting a thin, steady drip therefore fell between the two rules: too quiet to hold running, so it flipped to waiting_input after 5s, yet its bytes accumulated without decay until they crossed the same threshold and forced it back. Even a bare cursor blink (~10 B/s) oscillated on a ~30s period, for the whole life of an idle session, and the sidebar and desk widget blinked between "live" and "now" along with it. Give the restore side the same burst window the running side uses, so both ask one question: did enough bytes arrive inside one window? Route every reset through a helper so the counter and its window cannot drift apart again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`sessionActivityAt` folded `last_output_at` into the sort key, so two AI sessions streaming at once traded the lead several times a second and the sidebar reordered under the pointer. Split the two ideas apart. `sessionInteractionAt` keys off timestamps that stay frozen for a whole run — command start/end, attention, session start — so rows hold still while sessions work. `sessionOutputBucket` keeps output freshness as a last-resort tiebreak, quantised to the minute so a session with no shell integration can still surface without reintroducing the churn. Display is untouched: LastOutputIndicator reads `last_output_at` directly, so "3m ago" still ticks by the second. Also drops useSessions' hand-copied comparator in favour of the shared one, so the next change to the ordering cannot land in only one of them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olding for non-events
The highlight marking a freshly-escalated session used the generic accent blue
at the same 0.14 alpha as :hover, so it read as "the pointer is resting here"
rather than "this one is asking for you" — and the blue collided with the live
indicator right beside it. Take the colour from the reason instead (waiting =
amber, failed = red) and pulse briefly on entry: the row only has to earn one
glance, so the blink ends while the static tint stays for the rest of the peek.
The auto-peek itself unfolded the widget for three things that were not events:
- The first snapshot after connecting diffed against an empty state, so every
session that was already waiting counted as newly escalated.
- The diff ran over the truncated visible rows, so a session scrolling past
maxRows and back looked like a fresh escalation.
- A session whose state flapped re-escalated on every cycle.
Seed the baseline from the first snapshot without peeking, diff over a new
`attentionIds` covering every session rather than the rendered slice, and hold a
per-session cooldown so a flapping session raises its hand at most once a
minute.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…read controls The last-output stamp trailed the cwd while a separate 32px gutter sat reserved on the right, so the timestamp neither aligned nor filled the space, and rows without one left a ragged edge. Move it into a tail column shaped like the desk widget's — kind badge on the title line, timestamp on the cwd line, right aligned — with a min-width so an empty tail still holds the column open. It is a min rather than a fixed width because a fixed one takes its slack out of a title that is already ellipsising. The close x moves into that column's badge slot and the badge yields while it shows, so the affordance costs no layout width and overlaps nothing. It is absolute again, but not the way that got reverted before: it no longer floats over the title line, and the permanent gutter it needed is gone. Also removes the per-row unread dot and mark-read control. Unread already surfaces three other ways — the state icon becomes a star for the two attention states, unread rows sort to the top of their group, and the group header carries a count plus mark-all — and unread clears itself the moment a client attaches, so the manual per-row dismissal was only ever a shortcut. It cost ~22px of a ~224px row to restate a fact shown elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picker existed to answer one question — which existing session should fill this new pane — and it answered it with a full-screen modal listing every session, reached only by a two-modifier shortcut. Dragging a row out of the sidebar covers the same ground without leaving the layout, so the modal goes. Takes the whole chain with it: the two split-and-pick shortcuts (freeing Cmd+Alt+N), the SplitMode parameter that only existed to branch between "spawn" and "pick", lib/sessions.ts (whose sole consumer was the dialog), and the six strings none of the survivors reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…b strip Replaces the picker with direct manipulation. A sidebar row dropped on a pane splits it and lands beside it; a pane's grip dropped on a sibling swaps the two; dropped on the tab strip it becomes its own tab. The same action is reachable from both context menus for anyone who would rather not drag. Three things were load-bearing and are worth recording: The payload rides a private MIME type, never text/plain — a pane is an xterm whose hidden textarea accepts native text drops, so a text/plain payload that slipped past the handler would be typed into the terminal. The id itself is stashed in a module variable at dragstart rather than read back from dataTransfer: WebKit keeps `types` readable, so the drop target lights up, while returning nothing from getData for a custom type, which made every drop a silent no-op in the desktop app. Panes move whole rather than being rebuilt from an id. The `remote` flag decides which endpoint a pane attaches to and which list resolves its title, so a locally-spawned shell re-minted as remote resolves in neither and renders empty. dragover must preventDefault on every event, not just the first: one un-prevented dragover and the browser stops delivering drop to that element entirely. Both drop targets carry a regression test that fires three in a row. The grip cannot be the terminal itself — xterm needs the mouse for text selection — so it lives in the pane's control cluster, which is pointer-events:none, and opts back in explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every one of the renderer's ~84 log call sites sits on a failure branch, so a log file with no `ui-*` records is ambiguous: it reads exactly the same whether the run went cleanly or the bridge to the Go logger stopped working. Chasing a drag-and-drop bug through an empty log section cost a detour into an HTTP-based side channel before that ambiguity was noticed. Emit one INFO record per boot, after mount, on the happy path. An empty section now means something: the bridge is down. The bridge itself turned out to be intact — binding generated, Go side covered by tests, level parsing case-insensitive, flush handlers installed first thing in bootstrapApp. Tests pin the two properties that would silently break it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI sessions have no authoritative task_state today: claude and codex render inline and run as one long shell command, so OSC 133 never reports a boundary and the silence heuristic has to guess. Silence cannot tell "answered you, now waiting" apart from "thinking, or running a quiet tool", so the state flips while the model works and every consumer flickers with it. The authoritative signal already arrives — hookinstall wires five Claude Code hooks into atterm-hook and the desktop terminates them — but the events only feed Feishu cards. This design routes them into the session model instead, with the silence heuristic latched off per session once hooks prove they work, and extends the same path to codex (whose hook system landed with near-identical event names; verified against codex-cli 0.146.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An AI session's task_state has no authoritative source today: claude and codex render inline and run as one long shell command, so OSC 133 never reports a boundary between turns, and the silence heuristic that fills the gap cannot tell "answered you, now waiting" apart from "thinking, or running a quiet tool". The state therefore flips while the model works. Accept the state the client already reports through its hooks, and latch the session onto that source the first time one arrives: the silence timer stops arming for good, so the two can never disagree. OSC 133 D clears the latch — the CLI has exited and its hooks left with it, so the shell underneath goes back to the heuristic. Events are ignored unless the session is classified ai, since a hook can land a moment after the CLI exits and must not move the shell that replaced it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hook ingress already terminates POSTs from atterm-hook and fans them out to two Feishu consumers. Add a third, narrow one for task state: HookServer gains a TaskStateSink, relayHost implements it by resolving the session id against the local registry, and startFeishu wires the two together — the listener starts unconditionally, so this works whether or not a Feishu binding exists. The state path deliberately runs before the adapter lookup. The adapter registry only holds agents that can render a card (claude-code today), while state routing knows both agents on its own; behind the lookup, every codex event was dropped. A test covers exactly that. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… explicitly Codex's hook system covers the same turn boundaries claude's does, so the same binary can report task state for both. Write ~/.codex/hooks.json alongside the claude settings, merging rather than replacing (the file is the user's, and codex loads every entry in it) and replacing our own entry from a previous run so a version bump does not stack a second line pointing at a deleted binary. User-level only: hooks configured in a repo-local .codex/config.toml do not fire in interactive sessions (openai/codex#17532). A codex failure only warns — plenty of users have one CLI and not the other. Both installers now pass --agent explicitly, so the receiving end never infers which CLI called it. That changed the command string, which in turn needed the health check to compare against the same builder (or it would report a permanently unhealthy install that repairing could not fix) and to stat only the binary rather than the whole command line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve finished Grouping by host ignored task_state entirely: rows were ordered purely by their interaction stamp, so a session that finished five minutes ago sat above one still running, and the two grouping modes disagreed about what the top of the list meant. Use the same comparator the state groups use — urgency, then unread, then the stamp. Within a state the ordering is unchanged, so two AI sessions running at once still hold their places instead of trading them as they stream. Also fixes the byState doc comment, which claimed the opposite of what the code did (it said unread was not promoted there, while byHost promoted it; both were backwards). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rsing The replay guard exists to swallow the replies xterm generates on its own while parsing escape sequences inside replayed scrollback — cursor position reports, device attributes — which would otherwise land in the shell as typed garbage. It was implemented as "drop all input during replay", which cannot tell those replies apart from the user's own keys. The two cases coincide at the worst possible moment: a command flooding the scrollback is both why the replay is 4 MiB long and why the user is reaching for Ctrl-C. With every keystroke dropped for the duration, closing the tab was the only way to stop a runaway `yes`. Mark keyboard-originated data via attachCustomKeyEventHandler, which xterm runs for every key event before emitting the data, and let those bypass the guard. Terminal-generated replies still get suppressed exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A subscriber whose queue fills before its snapshot finishes is closed without a word: it never joins s.subs, never becomes driver, and the client sees only a socket close. From the outside that reads as "the replay progress bar hangs and my keystrokes do nothing", with nothing in the log to attribute it to. Log both drop points at INFO, with the phase and how far the replay got, so the next occurrence can be told apart from the other reasons a replay can appear stuck. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fan-out drops a client whose queue is full and expects it to reconnect with ATTACH(since_seq). That is cheap while the scrollback still covers the gap — but a flood wraps the 4 MiB ring in seconds, so the reconnect replays the whole buffer, falls behind again, and is dropped again. From the outside that reads as a replay progress bar reappearing on its own, with no tab switch and no re-attach, which is exactly what it was mistaken for. The earlier drop logging covered the two replay-phase paths; this is the one that actually fires under a flood, and it was silent. A test pins the drop itself so the loop's first half stays proven rather than argued. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ring holds 4 MiB and every attach replayed all of it, but the client keeps 20000 lines and discards the rest as it parses. For line-dense output — yes(1) writes two bytes per line — that is 99% of the replay parsed and thrown away. The waste is not just idle: parsing megabytes is what pushes a slow client past its queue depth, at which point the live fan-out drops it, it reconnects, the ring has wrapped, and it replays the whole buffer again. That loop is what put a replay progress bar on screen with no tab switch and no re-attach. Cap the replay at its newest 512 KiB — roughly the 20000 lines the client could have kept, so nothing visible is lost — and mark the view torn when the cap bites, exactly as an evicted-bytes gap already does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pane openRemoteAsTab defaults remote=true, which is safe only because of an invariant nobody enforces: every local session is placed into a pane in the same step that spawns it, and every detachOnly caller re-places it immediately, so a session that is nowhere on screen is necessarily remote. Clicking a local session in the sidebar therefore always hits the focus-existing-pane branch and never this one. Document that, and stop relying on it. The local session list is authoritative about locality, so resolvePaneRemote trusts it over the caller's request and reports the override. Without this, the day the invariant breaks a local session would attach through the relay endpoint — which does not have it — and render as an empty pane with nothing in the log to explain why. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A client whose socket stops draining is closed after clientWriteWait, which is what the user sees as a "reconnecting" badge. The renderer stalls exactly that way when a flood of output has its main thread stuck parsing, so this fires on a purely local session with no relay configured — a confusing thing to see, and the only record of it was a debugf invisible at the default INFO level. Both writer paths now say so at INFO, with the session and the frame type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every terminal in the desktop app is a websocket client of the app's own in-process relay on 127.0.0.1 — that is the only path the frontend has to a PTY, with or without a remote relay configured. So the write timeout meant for detecting a vanished network peer also governs the local renderer. It misjudges it. On loopback there is no partition to detect: a socket that stops draining means the renderer is busy, typically parsing a burst of output, and it will come back. Tearing the connection down makes that strictly worse — the client reconnects, replays, and falls behind again, which reads as a purely local terminal that keeps announcing "reconnecting". Give loopback clients a far longer bound and leave remote ones at ten seconds. Liveness is still covered by the ping loop, which fails long before either. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A disconnect currently produces a "reconnecting" badge and nothing else. The relay discarded the close status when its read loop ended, and the client ignored the CloseEvent entirely, so a local terminal dropping its connection left no way to tell the relay hanging up apart from the renderer's own websocket giving up — which are opposite problems with opposite fixes. Log the close code and reason from both sides. The relay's line lands in the desktop log file; the renderer's is there for the console when the Wails binding is unavailable, as it is under wails dev. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live fan-out dropped a subscriber whose queue was full and relied on it to reconnect. Under a flood that contract inverts: the client reconnects, replays, falls behind again, and comes straight back — so a purely local terminal announced "reconnecting" for as long as the output lasted, and each round re-parsed a replay it was already too slow to keep up with. A terminal cannot show every byte of a firehose and nobody can read one. Throw away the backlog the client has not caught up on and queue a coherent restart instead: the same reset marker a truncated replay sends, then the tail of the scrollback. The client stays attached, keeps the driver, and sees a jump rather than a disconnect. The log line is throttled, since a flood can overflow many times a second. Also renames the websocket close reason from "session ended" to "subscription ended". The session was alive throughout; the old wording sent this investigation looking for a session that had ended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list now answers "what is moving right now" before "what is asking for me". Both readings are defensible — a session waiting on you is arguably the one that needs attention — and this is the one the user asked for, with the consequence that a session waiting for input sits below a shell that is merely producing output. TaskGroupedList kept its own copy of the order for the state-group headers, so the group order and the row order could disagree. It reads SESSION_URGENCY now. The desk widget shares the same comparator and follows automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The widget is a one-glance summary of the same list, so the sprite claiming "someone is waiting for you" while the sidebar's top row is a running session would be worse than either order on its own. Only the face changes. Which row gets highlighted, and whether a collapsed widget unfolds itself, still key off waiting/failed: those are events that call for the user, which is a different question from what the list is mostly doing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It was written on a wrong diagnosis. The reconnect loop on a local terminal looked like a write timeout, but the log showed zero of those: the client was being shed by the live fan-out, which the resync change fixed at the source. Keeping it would not be free. All three timeouts in the writer share one bound, including the keepalive ping, so the split also stretched liveness detection for a local client from ten seconds to five minutes. That is a real cost for a case we no longer expect — a renderer stalling ten seconds on a replay that is now capped at 512 KiB. The INFO log on the write-timeout close stays. If it ever does fire, we will have evidence instead of a theory, and can put this back with numbers. Also renames fanout_drop_test.go, which no longer tests a drop. Co-Authored-By: Claude Opus 4.8 (1M context) <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
task_state, and a session latches onto that source the first time one arrives (the silence timer stops arming, so the two can never disagree). Covers both CLIs, installing into~/.claude/settings.jsonand~/.codex/hooks.json.yesused to disconnect the local terminal every few seconds: the client fell behind, the fan-out dropped it, it reconnected, replayed 4 MiB, and fell behind again. Replay is capped at the tail the client can actually keep, and a client that falls behind is resynced in place instead of dropped. Keystrokes now reach the PTY during a replay, so Ctrl-C stops a runaway command.last_output_at, so two streaming AI sessions traded places several times a second. It now keys off interaction timestamps that stay frozen for a whole run, with output freshness only as a minute-quantised tiebreak.runningleads the urgency order in the sidebar and the widget.ui-*section means something.Test plan
runningthrough a long turn instead of flipping to waiting_input on every pause~/.codex/hooks.jsonis written on launch)yes; confirm no "reconnecting" badge, no reappearing replay progress bar, and that Ctrl-C stops it~/Library/Logs/AT-Term/desktop.logcontains[ui-boot] renderer readyafter launch