Skip to content

feat(watch): keep Claude Code sessions alive past a closed terminal - #32

Merged
pfriedrix merged 32 commits into
masterfrom
feature/watch-mode
Aug 6, 2026
Merged

feat(watch): keep Claude Code sessions alive past a closed terminal#32
pfriedrix merged 32 commits into
masterfrom
feature/watch-mode

Conversation

@pfriedrix

@pfriedrix pfriedrix commented Aug 6, 2026

Copy link
Copy Markdown
Owner
  • lcc start --watch hands the session to a daemon that owns its pty instead of taking over the terminal, so it survives the terminal closing; lcc watch is a live dashboard with attach/detach on ^\. Without the flag nothing changes — the branch is one if immediately before the existing claude.launch, and both paths share every step above it including the argv.

  • While attached lcc draws nothing. A status bar held the bottom row behind a scroll region and said ^\ dashboard there; against Claude Code it never worked. It repaints many times a second with cursor moves relative to wherever the cursor already is, resets the scroll region itself, and uses the terminal's one saved-cursor slot — so every paint had to borrow the cursor and return it exactly, and three schedules (timer, before output, after output) each still disturbed its rendering. Doing it right means emulating a terminal, so the row went back to the child and attach is pure passthrough. ^\ is documented in the dashboard footer and README instead. statusBar and --status-bar/--no-status-bar are gone with it.

  • Session status comes from Claude Code's own hooks, not from reading its screen. The first design scraped the pty and needed a pattern table, fixtures pinned to a release, and a drift detector; Notification/Stop/PreToolUse say it exactly, installed via claude --settings so nothing is written to ~/.claude/settings.json or into the repo.

  • The daemon is single-threaded by construction rather than for simplicity: forkpty from a process with a thread pool can deadlock in libc's allocator before execve, so Io.Group is banned anywhere it can reach. Idle it measures 0:00.00 CPU — nextTimeout returns -1 when nothing is pending rather than ticking.

Review notes

  • lcc remove does not consult sessions.owning() yet. The seam and its tests exist; no caller uses them. Today remove will delete a worktree with a live agent in it. Worth a follow-up before this sees much use.
  • Sessions do not survive a daemon crash — closing the pty master revokes it and the child gets SIGHUP. Inherent to the daemon owning the pty, same as tmux, but it belongs in the README next to the feature rather than only in a comment.
  • Three things here were measured rather than reasoned about, and each contradicted the obvious assumption: "matcher": null is accepted as valid JSON and then silently registers no hook; Claude Code emits a bare CSI r as its second command, which reclaims a naively reserved status row — one of the reasons that row is gone; and UnixAddress.max_len is 108 while Darwin's sun_path is 104, with an unclamped memcpy between them.
  • zig build test does not analyse function bodies no test calls — zig build caught three errors in code the suite had already passed. Both are in CI.

`lcc watch` needs the same raw mode, the same line-counting erase and the
same codepoint-safe truncation as the pickers, but redraws on a schedule
rather than on a keystroke. Duplicating them would leave two redraw
disciplines to drift apart, with only one of them ever getting a fix.

A pure move, with two changes worth calling out. Screen no longer carries
a Terminal: it was written at all three construction sites and read at
none, and dropping it is what lets the erase be tested against a fixed
buffer instead of a tty. pageSize stays behind in prompt.zig, because
rows - 4 is the picker's chrome budget rather than a fact about the
terminal.

Adds the first tests truncate and eraseFrame have ever had, plus reset
and sanitize, which the passthrough client needs and the pickers do not.
The two leaf primitives behind an upcoming --watch flag, which will keep
Claude Code sessions alive across a closed terminal. Nothing calls them
yet; they land first so the riskiest assumption is proven before a daemon
depends on it.

pty spawns through forkpty rather than openpty plus std.process.spawn.
A slave fd handed to a child passes isatty but is not a controlling
terminal, and 0.16 offers neither a setsid option nor a pre-exec hook to
make it one — so the kernel would never deliver SIGWINCH, and Claude Code
repaints on resize and on nothing else. Two tests pin that: one compares
the child's controlling terminal against the pty on its stdin, the other
resizes and waits for the signal to arrive.

forkpty also means a fork from a process that must stay single-threaded,
so every allocation happens before it and the child is async-signal-safe
until execve. Darwin's std.c.T has no TIOCSWINSZ — the constant that
turns up when grepping std belongs to the FreeBSD arm — so the value is
derived here from the BSD macro it comes from.

ring gives each session one fixed buffer and each client a cursor into
it, so a client that stops reading loses scrollback rather than costing
the writer memory. A per-client queue would let one wedged terminal
exhaust the daemon on behalf of an agent that is working perfectly.
Captured Claude Code's startup through a forkpty and read the first
eighty bytes: alongside bracketed paste it enables focus reporting
(?1004), colour-scheme notifications (?2031), the kitty keyboard flags
(CSI > 1 u) and modifyOtherKeys (CSI > 4;2 m). None of those were being
undone.

Left set when a client detaches, the first two spray \x1b[I and \x1b[O at
the shell on every window focus change, and the last two hand it a
different encoding for ordinary keypresses. That is the strange-shell
symptom nobody ever traces back to the tool that caused it.

The same capture settles a design question: no ?1049 anywhere, so Claude
Code renders inline rather than on the alternate screen.
Three leaf modules, no callers yet, landing ahead of the daemon that will
use them so each carries its own tests.

watch_paths guards the socket path at 103 bytes. std will not:
UnixAddress.max_len is 108 — Linux's number applied to every non-Windows
target — while Darwin's sockaddr_un.path is [104]u8, and
addressUnixToPosix memcpys into it with an unclamped length. A path
between the two passes every check std makes and writes off the end of
the struct. One override, LCC_WATCH_DIR, moves the socket, lock, log and
hook settings together, because a test needs all of them isolated and
moving HOME would move the login Keychain with it.

sessions.json is a projection of what the daemon holds in memory, not an
authority: the pty fds and child pids only exist in the process. It
carries a daemon block with a pid, without which a dead daemon's file
would report its sessions active forever and every reader would believe
it. Alone among this repo's state files it writes through a temp and a
rename — lcc remove reads it to decide whether a worktree has a live
agent in it, and a torn read there costs work rather than a cached answer.

wire is a five-byte header and then bytes. Control payloads are JSON;
pty output stays raw, because it is not text and base64 would be
mandatory rather than optional. Frame lengths are validated at the header
before anything is sized from them, since Io.Reader.take panics rather
than erroring past its buffer. Clients open two connections instead of
multiplexing one, so a screen repaint cannot delay a status update and
switching sessions stops being a race.
The first design scraped the pty: strip ANSI, match a spinner glyph and
"esc to interrupt", infer a state. That needed a pattern table, fixtures
pinned to a release, and a drift detector to notice when a new Claude
Code silently invalidated all of it.

None of it is needed. Claude Code reports its own state through hooks, by
contract: Notification with a permission_prompt, elicitation_dialog or
agent_needs_input matcher means blocked on a person; UserPromptSubmit and
PreToolUse mean a turn is in flight; Stop means it finished; SessionEnd
means it is over. Exact rather than heuristic, and a release cannot break
it silently.

The matcher does the discrimination, not lcc — each entry bakes the state
it means into its own command line, so nothing here parses a
notification's payload to work out which kind it was. The only field read
off a hook is cwd, which is the worktree, which is already the key the
daemon files sessions under.

Installed via `claude --settings <file>`, which merges additional
settings. Nothing is written to ~/.claude/settings.json, nothing lands in
the repo, and the hooks exist only for sessions lcc launched. Hook
entries are async with a short timeout: a status that arrives late is
cosmetic, a turn that waits on lcc's bookkeeping is not.

idle_prompt is deliberately not treated as blocking. It fires after a
quiet spell rather than on a question, and mapping it to `waiting` would
light up every finished session as needing attention — which is the
signal this feature exists to make trustworthy.
bind orders three steps that must not be reordered: take the lock, remove
a stale socket, then listen. Unlinking before locking would let a
starting daemon delete a live daemon's socket and strand every session it
owns. Null means another daemon holds the lock, which is the whole
two-daemon guard — the loser exits in milliseconds and its client
connects to the winner instead.

std does neither of the two things bind has to do here. UnixAddress.listen
goes straight to bind(2), so the socket a SIGKILLed daemon left behind
fails with AddressInUse forever; and the directory keeps the default
umask, which on macOS — where the socket's permissions are enforced at
connect — would let another local account attach to a session and get a
shell in the worktree.

nextTimeout returns -1 when nothing is pending. That is the point rather
than an optimisation: an idle daemon then costs zero wakeups instead of
four a second forever to discover nothing happened. It is pure, so both
that and the "never negative when overdue" edge are pinned by tests —
poll reads a negative timeout as block-forever, so an off-by-one there
turns a due registry flush into a hang.
lcc start --watch now hands the session to a daemon that owns its pty,
so it survives the terminal closing and `lcc watch` can show it. Without
the flag nothing changes: the branch is one `if` immediately before the
existing claude.launch, and both paths share every step above it —
including the argv — so they cannot come to different conclusions about
what to run.

The daemon is single-threaded on purpose, not for simplicity. pty.spawn
forks, and a fork from a process with a thread pool can deadlock in
libc's allocator lock before reaching execve, so Io.Group and Io.async
are banned anywhere it can reach. The loop is one poll over the listener,
every client socket, every pty master and a self-pipe written by the
SIGCHLD handler — which is not hardening but the only mechanism that
works, since std.posix.poll swallows EINTR and restarts with the full
timeout.

Backpressure is a cursor per client into the session's ring, so a client
that stops reading loses scrollback rather than costing the daemon
memory, and POLLOUT is armed only when something is actually waiting. An
idle daemon measured 0:00.00 CPU: it blocks indefinitely rather than
ticking.

The end-to-end test registers /bin/cat on a real pty, types into it,
closes both connections, reconnects and finds the session still there
with its scrollback intact. It runs the loop on a thread, so every spawn
in it forks from a multithreaded process — which makes it a check on the
between-fork-and-exec code as well.

Two bugs it caught on the way: the poll set was indexed by the live
client count after an accept had already grown it, and the first tick ran
after the first poll, so a daemon with nothing pending correctly blocked
forever before ever writing the registry that says it exists. A third was
found by hand — a bad socket path failed after daemonizing, where there
is no terminal left to report it — so the path is now validated before
the fork.
Ran the generated settings through real Claude Code with --debug and
none of lcc's hooks registered. No error, no warning, no mention of the
file — the entries were simply dropped.

The cause was `"matcher": null`, which lcc emitted for every entry that
matches all notifications of an event. It is valid JSON and is accepted
without complaint; an empty string registers. Probing with a settings
file that only touches a marker file confirmed both halves: --settings
does load hooks, and the matcher is what decided whether they appeared.

The unit test could not have caught this. It parsed lcc's JSON against a
schema lcc wrote, and both agreed null was fine. It now asserts on the
wire bytes that no matcher is ever null, which is the property that
actually matters to the program on the other end.

Also extends the end-to-end test over the last untested link: a hook
frame keyed by worktree reaching the daemon and changing that session's
status, and a hook for an unknown cwd being ignored rather than failing.
`lcc watch` on a terminal is now a live table; piped or with --json it
stays the one-shot snapshot it was. That split is not cosmetic: a
full-screen TUI cannot write its frames through app.ui, and --json is
what a tool call can drive.

The table has two things `lcc list`'s does not, both consequences of
redrawing rather than printing once. It has a width budget — a row that
wraps costs the frame its line count and every later frame inherits the
error — so columns drop in a fixed order, the branch shrinks rather than
wrapping, and below that it degrades to one line per session. And render
returns its own line count instead of leaving the caller to total it up,
which is what lets the invariant be tested against a buffer with no
terminal attached: the count equals the newlines, and no line exceeds the
width, checked from 200 columns down to 10.

The cursor is a session id rather than a row index, because snapshots
re-sort as statuses change and an index moves the selection under the
user's finger. Frames allocate from an arena reset each iteration, since
app.gpa is a process arena and nothing else in this repo is long-lived
enough to have cared.

Detach is Ctrl-\: ISIG is off in raw mode so it arrives as a plain byte,
and Ctrl-C stays free because reaching the agent with it is the first
thing anyone tries. Bytes typed ahead of it are still forwarded rather
than dropped. The terminal is handed back through term.sanitize on every
exit path — the program that would normally undo those modes is still
running, deliberately.

Verified under a real pty: two sessions, hook-driven statuses rendering
as ● waiting and ◐ active, cursor movement, attach, echo through the
session, Ctrl-\ back to the dashboard, both sessions still alive.
Attaching handed the terminal to Claude Code with no visible way to
leave it. Ctrl-\ is undiscoverable, and a hint printed before passthrough
scrolls away within seconds — after which someone is inside a session
with no reason to believe there is an exit at all.

So the bottom row is taken away from the child rather than drawn over
it. The child is told the terminal is one row shorter and a scroll
region keeps its output inside those rows, which is how tmux does it and
why there is nothing to flicker. Drawing over the child's own rows was
tried on paper and rejected: Claude Code repaints its bottom UI many
times a second while streaming and would win every time.

The row carries the other sessions and their statuses, so it earns its
line rather than only holding a keybinding, plus `^\ dashboard` and
`^C→agent` — the second is there because the question right after "how
do I get out" is whether interrupting still reaches the agent.

The reason ansi.zig exists is a measurement rather than a guess: a
capture of Claude Code's startup has a bare `CSI r` as its *second*
command, before it draws anything. A bar that set the scroll region once
at attach would lose the row immediately and never know. So the output
stream is scanned for anything that reclaims it — which has to survive a
sequence split across a read boundary, hence a scanner with state rather
than a function.

Verified under a pty at 20x90: the region is set on attach, the row
renders with both sessions and the keys, and after the child sends
`CSI r` the region is re-issued and the row redrawn.

Also fixes a width bug the tests caught: the bar budgeted in bytes while
laying out in columns, and its separators are multi-byte.
A session that outlives the terminal that started it is the behaviour
worth having without asking for it, so `lcc start` now hands sessions to
the daemon and lands on the dashboard. `--no-watch` covers the one-off
and `lcc config watchByDefault false` covers the machine.

Because this is the path every start takes now, an unreachable daemon
must not be able to take `lcc start` down with it: it warns and falls
back to the foreground launch. Warns rather than falls back silently — a
quiet fallback would hide a broken daemon until someone noticed their
sessions had stopped surviving.

`lcc config` exists because `lcc setup` cannot be used from a script, a
slash command or a tool call: it enters raw mode and fails outright
without a tty. Same settings, named one at a time, with --json. It is
also where the settings are discoverable at all — a bare `lcc config`
prints each one with its value and what it does.

The --json path is unaffected: it returns before the launch branch, so a
caller parsing `lcc start PE-N --json` sees no change.
`lcc config` covered one key; every other switchable behaviour was
reachable only as a flag, which meant retyping it on every invocation.
Now the booleans that are genuinely preferences — plan mode, session
resume, the TOKENS column, the status bar, what `lcc remove` leaves
behind, whether the picker ignores activeStates — are settings, plus one
three-way for `lcc list`'s network columns, where two booleans would
have let someone ask for both "skip the network" and "ignore the cache".

Every boolean gained a flag on the other side too. A stored default you
cannot override for a single run is a trap, so `--tokens` joins
`--no-tokens`, `--resume` joins `--no-resume`, `--no-keep-branch` joins
`--keep-branch`, and `lcc list` gets `--cached` as the way back from a
stored `local` or `refresh`.

`--yes` and `--force` are deliberately excluded, and there is a test
asserting so rather than a comment hoping so: a stored value that
pre-approves a destructive operation removes the one confirmation
between a mistyped command and a deleted worktree, invisibly, long after
anyone typed it.

Resolution happens in the argv layer, which is the only place that can
tell "not passed" from "passed false". Commands keep plain booleans, so
none of list.zig's dozen helpers had to learn about tri-state.
`lcc config` needed a key before it would do anything, which is fine once
you know the names and useless before that. Bare, it now opens the whole
table: arrows to move, Enter to toggle a switch, cycle a choice or edit a
value, written as you go. Naming a key still skips it and never touches
raw mode, because that is the only form a script or a tool call can use.

Each row is a short name and its value, and nothing else. A name needing
a line of explanation under it is a name that is wrong, so the labels
carry the meaning — "Sessions outlive the terminal", not
"watchByDefault: hand new sessions to the daemon…". Switches read on and
off; everything else shows what it is currently set to.

`lcc setup` becomes a second door into the same editor. It was a
hand-written walk through five settings in a fixed order, which meant
every setting added since was simply missing from it and there were two
places to remember. Its one piece of real knowledge is preserved and now
tested: mcpCarry's "all" and "none" are words, and taken literally a
typed "none" would have become a server called none, silently carrying
nothing.
Ctrl-\ was matched as the byte 0x1c, which is only how a terminal sends
it when nothing has asked otherwise. Claude Code asks otherwise twice in
its first eighty bytes: `CSI > 1 u` turns on the kitty keyboard protocol
and `CSI > 4 ; 2 m` turns on modifyOtherKeys, and both of those bytes
pass straight through lcc to the real terminal. Under either, a modified
key arrives as a CSI sequence and the scan never fires — so detaching
silently did nothing while the status bar said it would work.

It held up against /bin/cat because cat enables neither. The program this
feature exists for enables both, and Ghostty implements both.

All three encodings are now accepted: the control code, `CSI 92 ; mods u`
and `CSI 27 ; mods ; 92 ~`, with the keysym or the control code in either.
Only the ctrl bit is required, so a terminal folding shift or meta into
the same report still detaches.

Also makes the status bar repaint on a slow tick rather than only when
something known disturbed it. A scroll region confines scrolling; it does
not stop the child addressing the last row, erasing outside it, or any
cause lcc never sees. A hundred bytes a second is nothing, and for the
line that tells you how to get out, "cannot be permanently lost" is worth
more than "is not redrawn unnecessarily". The peers on it are refreshed
every two seconds for the same reason — they were frozen at attach, and a
bar claiming a session is waiting ten minutes after it stopped is worse
than one that says nothing.
Taking on a second task meant quitting the dashboard, running lcc start,
and landing back in it — from the one screen whose whole purpose is
managing several sessions at once.

`n` runs the ordinary start flow in place: same picker, same worktree
bootstrap, same argv. It forces the daemon path on and suppresses the
dashboard, which would otherwise open a second one on top of this one.
One way a session comes into being, not two that can drift apart.

Cancelling a picker now returns instead of exiting 130, but only when
something else is hosting the flow. Changing your mind about which issue
to take should put you back where you were; quitting lcc out from under
the sessions you were watching is a strange answer to pressing Esc.
Standalone, 130 is the conventional status and is unchanged.

`watch.run` gets an explicit error set because it can no longer have an
inferred one: start opens the dashboard and the dashboard starts issues,
and inference chases that in a circle. Only `zig build` catches it —
the test build never analyses either body, since no test calls them.
The empty dashboard advised `lcc start PE-256 --watch`. That flag became
the default two commits ago, so the advice was redundant — and it pointed
at quitting and running a command from a screen that has a key for
exactly this. It now names `n`.
`lcc open` listed worktrees and knew nothing about sessions; the
dashboard listed sessions and knew nothing about worktrees. Both answer
"where do I get back into Claude Code", and which half you got depended
on which command you happened to type. Worse, since watch became the
default, `lcc open` was the one path still starting a session that dies
with its terminal.

They are one screen now, under the name already in muscle memory. A row
is a worktree; a session is a property of it. Enter attaches when one is
running and starts one when it is not, with the same --resume and the
same carried MCP servers `open` used to arrange. Sessions in other
repositories are kept rather than filtered out — an agent working
somewhere you are not looking is the one you most need to see. `lcc
watch` is an alias; `lcc open xcode` is untouched, since opening an
editor has nothing to do with any of this.

Two defects the merge introduced and the real repo exposed at once: the
cursor buffer was sized for a session id and silently truncated the first
worktree path, after which it never matched the row it came from and the
selection lived nowhere; and a worktree that has never run anything was
aged from timestamp zero and reported "56y", which reads as a fact rather
than the absence of one.
Enter on an `unknown` row did nothing at all. The row still carried a
session id — a dead daemon leaves its ids in the projection — so the code
took that as "something is running here", tried to attach, found nothing
listening, and returned in silence. It read as the key being broken.

Having an id and having a live pty are different questions. `unknown`
means the daemon that recorded the id is gone; `exited` means the child
is. In both the id names something that no longer exists, and the honest
answer is to start again, which also brings the daemon back with it.

`orphan` stays attachable: its worktree is missing but its agent is not,
which is the entire reason that state is shown rather than dropped. `x`
follows the same rule — a leftover id is not something you can kill.

Verified against a registry left by a daemon that cannot exist: the row
reads unknown, Enter starts a real daemon and a real session, and the
resumed agent comes up with the status bar on it.
Keys are encoded by the terminal, and Claude Code turns on two protocols
that change that encoding — the kitty keyboard protocol and
modifyOtherKeys. A pty in a test harness implements neither, so a key
that misbehaves under a real emulator cannot be reproduced by reasoning
about it here; the bytes have to be read.

Set LCC_WATCH_DUMP to a path and an attached session tees both
directions to it as hex. That is the difference between diagnosing this
class of problem and guessing at it, which is what the last two attempts
were.
Every single-letter shortcut read the character the terminal printed. On
a Ukrainian or Russian layout the key labelled `n` prints `т`, so none of
them worked — and nobody switches layout back to press one key.

The worst of it was not the dashboard. `prompt.confirm` is the y/n gate
in front of `lcc remove` deleting a worktree and its branch, and on a
Cyrillic layout there was no way to answer it at all: not yes, not no.
That has been true since long before any of this.

ЙЦУКЕН and QWERTY agree on where the keys are and differ only in what
they print, so one table maps a printed letter back to its position.
Ukrainian and Russian disagree in two places and both are listed. Digits
already agree everywhere.

Verified by driving the dashboard with Cyrillic: `о`, the key labelled j,
moves the cursor.
Attaching replayed the scrollback verbatim, and the first thing in any
session's scrollback is the child's terminal setup. Claude Code opens
with `CSI > 1 u` to push kitty keyboard flags and `CSI > 4 ; 2 m` to turn
on modifyOtherKeys; replaying those pushes a *second* level onto the
terminal's keyboard stack.

From then on the child and the terminal disagree about how keys are
encoded. The child waits for a `\r` its picker never receives, while the
mouse — a separate protocol, unaffected — keeps working. Which is exactly
the reported symptom: in Claude Code's session picker, clicking selects
and Enter does nothing.

A replay exists to repaint a screen, not to re-run a setup the terminal
has already been through. Replayed bytes now travel as their own frame
type, so the client can tell them from live output, and the sequences
that configure rather than draw are dropped from them: private-mode
set/reset, keyboard-protocol push and pop, modifyOtherKeys, and DECSTBM.
SGR is untouched — `CSI 31 m` is colour, which is content.

Verified against a real Claude Code: none of the four mode sequences
reach the terminal on attach, and the screen still repaints in full.
Whether it settles the reported symptom needs a real emulator to say —
the pty in a test harness implements none of these protocols, which is
why this went unnoticed.
Pressing Esc in Claude Code's resume picker quits it, and the screen went
black and stayed there. The client was still attached to a pty that would
never speak again, repainting its own status bar over output that had
stopped — indistinguishable from a hang.

The `exited` frame existed and the client handled it. Nothing ever sent
it. The daemon reaped the child, wrote the new status to the registry,
and told the one party that most needed to know precisely nothing.

Also reworks the bar. The attached session's ring is filled rather than
carrying a `*` beside it: a marker outside the circle read as
punctuation, and filling the shape says "you are here" in the place the
eye is already looking. Colour still carries status, so nothing is lost
by spending shape on selection. Sessions are numbered, and the row is
drawn in reverse video so it reads as a bar rather than as a line of
output that happens to be last.
The row read `^\ dashboard · ^C→agent`. The second half meant "Ctrl-C
still reaches Claude Code", but on a row of keybindings everything reads
as a key you press, so it looked like a third shortcut with an
unguessable effect. A reassurance that has to be decoded is worse than
the doubt it was answering.
Attached sessions took every key except Enter. Ctrl+A, Ctrl+B, Ctrl+W,
Space, Esc and the arrows all reached Claude Code; Enter did nothing, and
the same picker in the same terminal answered Enter fine without lcc.

enterRaw only cleared the local flags — ICANON, ECHO, ISIG — and left the
input translation alone. ICRNL was still on, so the line discipline
turned the CR the terminal sends for Enter into an NL before lcc ever
read it. lcc then forwarded an NL, and Claude Code's resume picker acts
on CR.

It hid because the pickers this code was written for never cared:
readKey maps 0x0d and 0x0a both to .enter. Only forwarding bytes verbatim
to another program exposes it, and only for the one byte ICRNL touches —
which is exactly why the symptom was "everything works except Enter".

IXON goes too, so Ctrl-S reaches the agent instead of freezing the
terminal in front of it. OPOST deliberately stays: every frame lcc draws
ends in a bare \n and needs the terminal to add the carriage return.

readPending had the same hole from the other side — it rebuilt its
termios from the *saved* one, turning the translation back on for the
length of an escape sequence. It now derives from the raw mode that was
actually set.
The numbers read as keys you could press, and there is no such key —
switching between sessions from inside one is not implemented, so the bar
was advertising something that does not exist.

Finished sessions are gone from it too. They linger in the registry for a
few minutes so the dashboard can say what became of them, which is right
there and wrong here: you cannot switch to a session that has exited, and
a restarted worktree showed up twice under the same name, once dead and
once alive.

Colour now applies to the glyph alone. Painting the label with it made a
whole entry one colour, which reads as an alert rather than as a status;
with several sessions the row became competing signals instead of a list.

The width arithmetic counted one column per entry that was never printed,
so the row padded for more than it drew and the right-aligned keys lost
their last characters. Caught by the test that asserts the bar is exactly
as wide as it was told, which is why that test exists.
A terminal has exactly one saved-cursor slot. The bar borrowed it every
second — ESC 7, jump to the last row, draw, ESC 8 — and Claude Code uses
the same pair. Landing between its save and its restore destroyed the
position it was going to return to, so its cursor came back somewhere it
had never been. Two cursors on screen and neither where it belonged.

The scanner already parses the child's output for other reasons, so it
now tracks whether a DECSC is outstanding, and the bar waits rather than
borrowing a slot that is in use. Bounded at a few seconds: a child that
saves and forgets would otherwise cost the bar entirely, and the line
that says how to get out is worth being briefly wrong for.

Also stops writing into the last column. Filling it leaves the terminal
poised to wrap, and the erase that followed was then applied from a cell
whose position is a matter of interpretation — which is where the last
characters of `^\ dashboard` were going. The reverse-video erase already
paints the row to the edge, so the text does not have to reach it.
The bar repainted on a one-second tick, including while the child was
silent. That is the only moment the cursor is a problem: after the
repaint it sits on the bar row, and nothing is coming to move it, because
the child has nothing to say. Everything built to solve that — DECSC and
DECRC around the draw, then deferring while the child held the slot — was
solving a problem the schedule created.

The child owns rows 1..n-1, renders correctly in them, and positions its
own cursor every time it draws. So the rule is not "put the cursor back"
but "only take it when the child is about to want it anyway": the bar is
now painted immediately before forwarding a burst of the child's output,
and those bytes carry its cursor positioning microseconds later. No save,
no restore, no slot to collide over, and nothing here has to know where
the cursor was.

While the child is silent the bar is not repainted at all. Nothing has
changed and nothing has disturbed the row, so there is nothing to fix —
and the cursor stays wherever the child last put it.

Measured: one repaint per output burst, and zero across five seconds of
silence.
It existed to answer "did the child just reclaim the scroll region", so
the bar could reassert it. The bar now reasserts on every paint, and it
paints only alongside the child's own output — six bytes per burst is
cheaper than a state machine deciding whether to send them. The
cursor-slot tracking added to it went the same way when the scheduled
repaint did.

`bar_dirty` went too: three places set it and nothing read it, left over
from when a repaint had to be decided rather than simply performed.

ansi.zig is now one thing — the filter that keeps a replay from
reconfiguring the terminal. 215 lines out, 16 in.
Painting first left the cursor on the bar row, and Claude Code renders
with relative moves — `CSI 4A` and the like — so everything it drew next
was measured from the wrong place. That is what the misdrawn UI was.

Save and restore come back with it, because they were never the problem.
The problem was when they ran: on a one-second timer, landing mid-render
and repeating while the child was silent. Called once after a burst of
output has been written, the position being saved is the one the child
just chose, and handing it straight back is exactly right — and while the
child says nothing, nothing moves its cursor at all.

This was the failure mode I named when the reserved row was designed and
then talked myself out of. It cost two rounds; the design note in
watch_bar.zig now says so, so the next person weighing "surely the child
will just reposition it" has the answer.
The status bar reserved the bottom row with a scroll region and painted
the sessions and `^\` there. Against Claude Code it never held: it
repaints many times a second with cursor moves relative to wherever the
cursor already is, resets the scroll region itself, and uses the
terminal's single saved-cursor slot. Every paint had to borrow the
cursor and hand it back exactly, and three different schedules — on a
timer, before the child's output, after it — each still disturbed its
rendering.

Doing it correctly needs a model of where the cursor is, which is a
terminal emulator. That is more than one line of text is worth, so the
row goes back to the child and attach becomes pure passthrough. The way
out is documented instead of displayed: the dashboard footer names `^\`
and so does README.

Takes `statusBar` and `--status-bar`/`--no-status-bar` with it, along
with the per-burst registry snapshot the bar needed. `watch_attach.zig`
carries the rationale so this is not attempted a fourth time.
The daemon's exit announcement had no coverage, and its absence is the
worst failure this feature has: an attached client polls a session that
will never speak again, and the only way out is the detach key. Nothing
on screen says why.

Two shapes, because they die by different signals. A plain child exits
and the pty reports EOF. Claude Code leaves MCP servers and shells
behind it that inherit the pty slave, so the master stays open and
SIGCHLD is the only notice the daemon gets — the second test pins a
grandchild open across the exit to hold that path down.

Both type into the session first: the frame that reports the exit must
survive not being the client's first exchange.
A daemon outlives rebuilds. `zig build` replaces the file; the running
process keeps the image it exec'd, so it can be hours of commits behind
the client talking to it — same path, same protocol, older behaviour.

That is not hypothetical. A daemon started before `announceExit` reaps
its children and records `exited` in the registry, so every reader looks
healthy, while never telling an attached client its session is over. The
client then polls a pty that will never speak again and the detach key
is the only way out, with nothing on screen to say why. It reads as a
bug in code that was already fixed, and it cost an afternoon.

Derived from `started_at` against the binary's mtime rather than a build
stamp the daemon writes, because the daemons this has to catch are the
ones already running, which cannot be taught to report a new field. The
cost is precision: a rebuild that changed nothing still counts. So it is
a line on the dashboard and a key in `--json`, never a refusal, and it
stays silent whenever either timestamp is missing or no daemon is up.
@pfriedrix
pfriedrix merged commit 117b482 into master Aug 6, 2026
1 check passed
@pfriedrix
pfriedrix deleted the feature/watch-mode branch August 6, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant