saikai closed itself mid-session and left the terminal unusable, and nothing on disk
said why. This release is the answer to that: the cause, the two reasons it was
invisible, and — from a first-principles audit of every path by which saikai can
suddenly die — the rest of the family it belonged to.
Fixed
- A watchdog false positive can no longer kill a healthy session. The
terminal-death watchdog reaps saikai's own tree when it can no longer find a shell
ancestor, and "the walk found nothing" and "the walk did not run" were the same
value: the snapshot helper returns{}for every failure, which the ancestor walk
reports as 0, which the watchdog reads as "the terminal is gone". A transient
CreateToolhelp32Snapshotfailure — documented under heavy process churn, which is
what a saikai with eleven panes produces — therefore counted as a confirmed terminal
death, and two in a row killed every live pane. Failures now raise instead, so the
watchdog's own "inconclusive → reset the streak" branch finally works; a truncated
snapshot, or one that does not contain our own pid, counts as a failure too. - An abrupt exit no longer leaves the terminal broken. Two separate reasons the
cleanup never landed: the mode reset wrote tosys.stderr, which Textual replaces
for the app's whole life with a capture whoseisatty()returns True on purpose — so
the mouse/paste/focus disables went to the app instead of the terminal, and a wheel
scroll at the recovered prompt sprayed escape sequences. And nothing ever sent
?1049l, so the shell prompt was drawn over the alternate screen with the scrollback
out of reach. The reset writes tosys.__stderr__now, and the paths that bypass
Textual's teardown leave the alternate buffer themselves. - An abrupt exit explains itself in the log. Unhandled exceptions on the main
thread AND in background threads (a pty reader, the mirror server, a reap — their
deaths were completely silent) are written tosaikai.logwith their traceback,
interpreter faults go tocrash.logvia faulthandler, and every clean exit logs
stop: exiting, so a log that simply stops is now itself a signal. The watchdog
logs when it arms, every miss, every NEAR miss — a session one poll from being
reaped — and the kill decision. - Blocking work no longer freezes the UI. The Windows clipboard fallback ran on the
UI thread with no timeout, and it is reached exactly when another process holds the
clipboard, i.e. when it is certain to block: the event loop stopped dead, with an
external kill as the only way out. It and the macOS one are bounded now. The
agent-kill batch (onetaskkillper target, up to 10s each) moved to a worker that is
joined at exit, and the new-session candidate walk (git worktree listplus up to 40
directory stats) no longer runs inline. - A recycled pid can no longer get an unrelated process tree force-killed. Nothing
recordsprocStarton Windows, so the agent-kill guard fell through to the image
name — andnode.exeis on the accepted list. On the machine this was found on, the
only node.exe running belonged to Adobe, and the old check accepted it as our agent. A
node now has to have a claude (or saikai) ancestor, and the pane's own reap records
the child's start time at spawn and refuses a pid whose start time no longer matches. - A single bad frame no longer freezes the mirror for the rest of the session. The
drain thread is the only consumer of the ingest queue and its body was unguarded, so
one raise ended it: attached browsers sat on their last frame forever while the local
UI looked perfectly fine. Mirror problems now also reachsaikai.log. - A single bad chunk no longer kills a pane and orphans its claude. An exception
escaping_consumeended the reader loop, which marked the pane dead — and a dead
pane is forgotten, not killed, so its child outlived saikai untracked. - A pane no longer opens at the wrong size. At mount a widget has no layout yet
(measured:sizeis 0x0, with the real geometry arriving on the first Resize), so the
child was spawned into an 80x24 PTY from the fallback: it drew its whole startup
screen wrapped at 80 columns into a grid that was then resized, and pyte does not
reflow. The start waits for the geometry now; an inactive tab, which never gets one,
still starts on the fallback so its child renders and can be classified. - A pane closed before its first layout no longer spawns a child nobody reaps.
Changed
- A session parked on claude's agents view reports "Needs input". The view states
its own aggregate in the OSC-0 title (1 awaiting input · claude agents), but with no
title spinner and no permission prompt in the body every existing signal said idle —
so a session where an agent was waiting for an answer sat in the Idle section. The
counts in the title now decide:N awaiting inputreads as needs-input and outranks
the spinner,N workingas running. Read from the title only, never the body:
"Agents" occurs thousands of times in ordinary conversation text. - The mirror's read token is half as long — 8 url-safe characters instead of 16, so
the URL is 42 characters to type on a phone and its QR is sparser. The strength comes
from the hub's own guessing budget rather than the token: a bad read token arms a
per-source 30s lockout after 50 attempts, with the source normalised to an IPv6 /64 so
rotation cannot dodge it. The write-key is unchanged and still never appears in a URL,
file, QR or log.
Known limitations
- The pid-identity guard is strong on Windows and Linux only. macOS/BSD have no cheap
process-start-time source (apssubprocess on the UI thread is exactly what this
release removed elsewhere), so there it degrades to "cannot verify → proceed", which
is what every platform did before. Windows is where pids are recycled aggressively
AND where the reap is a blanket tree kill.
[0.6.1] — 2026-07-30
A cross-terminal audit of the pane's own contract: what it tells a child it is, and
what it actually does. Every item is a place the two disagreed, so the symptom only
appeared on some hosts or under some children.
Fixed
- A multi-line paste could be submitted line by line. A PTY read that ended on a
CSI intermediate byte (the$of\x1b[?2004$p) was released instead of held, so
saikai's DECRQM scanners matched neither half and the query went unanswered — a child
that sets bracketed paste and then verifies it concluded the mode was unsupported.
pyte's own parser spans the split, which is why nothing looked broken. - DECRQM no longer claims modes saikai does not implement. The position-accurate
overlay recorded every private mode a chunk carried, so a set-then-verify in one
write was told "1 = set" for a mode nothing here honours, while the same query one
read later correctly got "0 = not recognised". - The alt screen is detected in a combined DECSET. A child that writes its whole
entry at once (\x1b[?1049;1002;1006h) produced no boundary, so pyte's single buffer
kept the pre-alt frame under the child's new UI.AltScreenTrackerhad a second copy
of the same pattern; it now shares one. - Arrow keys follow the child's DECCKM state. The pane always sent CSI while the
mirror replays?1hinto xterm.js, so the same arrow reached the child as\x1bOA
from a browser and\x1b[Afrom the pane. - A dropped image is reported. The pane answers DA1 byte-identically to Windows
Terminal — which advertises sixel — and then drops every DCS, so a graphics payload
vanished silently. It now says so once per pane. - Terminal remote-control sockets no longer reach a pane child. kitty and alacritty
were scrubbed by their identity variables whileKITTY_LISTEN_ONand
ALACRITTY_SOCKETwent through, which let a child drive the real outer window
(kitten @,alacritty msg);KONSOLE_DBUS_*was the same over D-Bus. Whole
families are stripped now, as WezTerm's already was. - The list's viewport no longer flicks to the cursor row under load. The
cursor-scroll suppression closes on a queued callback, so a rebuild that starts
before the previous one's callbacks drain opens a second window — and a boolean let
the first window's end re-arm the second's pending scroll. It counts now.
Install or upgrade: uv tool install saikai / pipx upgrade saikai.
Full changelog: https://github.com/m-morino/saikai/blob/master/CHANGELOG.md