feat(watch): keep Claude Code sessions alive past a closed terminal - #32
Merged
Conversation
`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.
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.
lcc start --watchhands the session to a daemon that owns its pty instead of taking over the terminal, so it survives the terminal closing;lcc watchis a live dashboard with attach/detach on^\. Without the flag nothing changes — the branch is oneifimmediately before the existingclaude.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
^\ dashboardthere; 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.statusBarand--status-bar/--no-status-barare 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/PreToolUsesay it exactly, installed viaclaude --settingsso nothing is written to~/.claude/settings.jsonor into the repo.The daemon is single-threaded by construction rather than for simplicity:
forkptyfrom a process with a thread pool can deadlock in libc's allocator beforeexecve, soIo.Groupis banned anywhere it can reach. Idle it measures 0:00.00 CPU —nextTimeoutreturns -1 when nothing is pending rather than ticking.Review notes
lcc removedoes not consultsessions.owning()yet. The seam and its tests exist; no caller uses them. Todayremovewill delete a worktree with a live agent in it. Worth a follow-up before this sees much use."matcher": nullis accepted as valid JSON and then silently registers no hook; Claude Code emits a bareCSI ras its second command, which reclaims a naively reserved status row — one of the reasons that row is gone; andUnixAddress.max_lenis 108 while Darwin'ssun_pathis 104, with an unclampedmemcpybetween them.zig build testdoes not analyse function bodies no test calls —zig buildcaught three errors in code the suite had already passed. Both are in CI.