Summary
Codewhale's TUI performs a foreground-ownership check exactly once, at startup
(require_foreground_terminal_owner(), called from event_loop.rs:628). There is no
runtime equivalent and no SIGTSTP / SIGTTIN / SIGCONT handler anywhere in the TUI.
So if the TUI's process group becomes non-foreground after startup — by any job-control
route — the outcome is:
- The background group's next read of the TTY raises
SIGTTIN. The whole process group is
stopped by the kernel, in the middle of an agent turn, with no chance to run any
cleanup. The shell reports:
[1] + <pid> suspended (tty input) codewhale resume <session>
- Because no cleanup runs, every terminal mode the TUI enabled stays enabled: raw mode,
?1000h/?1002h/?1003h/?1006h/?1015h mouse reporting, ?2004h bracketed paste, the
alternate screen. The terminal then "poisons the shell with raw escape reports" — the
exact failure the code comment at tui/ui/terminal.rs:119-120 warns about — so mouse
movement / scrolling feeds escape bytes into whatever foreground shell is present.
- Any child the TUI had just spawned is orphaned or left as a zombie, because the
stopped parent can never wait() on it (zsh <defunct>, parent = the stopped TUI).
- The in-flight turn is not in the session file. The session JSON was last written at
the previous turn boundary (249 messages); the work performed before the stop lives only
in sessions/checkpoints/<id>.json (265 messages). Whether resume sees those 16
messages depends on which of the two it reads.
The TUI is not crashed and not deadlocked — it is stopped, and fg revives it. But the
user sees a frozen/garbled terminal, a shell prompt, and no indication that the session is
still alive in the background. From the user's side this reads as "the agent died mid-run".
Environment
- Codewhale
0.9.13 (npm wrapper + native binary built from the codewhale-cli 0.9.13 crate)
- Linux, xfce4-terminal, PTY
271x49, TERM=xterm-256color
- Long-lived session resumed in-session:
codewhale resume <session-id>
- Process state read directly from
ps / /proc and the shell's own job notification
Observed sequence
The consequence is reproducible and fully evidenced below. The exact job-control action
that first moved the process group out of the foreground is not yet pinned down — the
kernel does not record why a process was stopped, and the shell's earlier suspended
notification (if any) had scrolled away. Candidates that all produce this end state:
Ctrl+Z followed by bg (resume in background) — the resumed group immediately reads the
TTY and takes SIGTTIN
- any
tcsetpgrp that hands the foreground to another job while Codewhale keeps its input
pump running
- a second shell or launcher sharing the same controlling TTY
What is established: at the moment of the stop, the process group was not the TTY's
foreground group, and the TUI was reading the TTY (that is the only thing that raises
SIGTTIN).
Expected behavior
Either the TUI never ends up reading a TTY it does not own, or — when it does — it behaves
like every other full-screen terminal program (vim, nano, htop, less): on SIGTSTP/SIGTTIN
it restores the terminal (leave alt screen, disable mouse/paste, disable raw mode), stops
itself, and on SIGCONT re-enters its modes and repaints. The user sees a clean shell while
suspended and an intact TUI after fg.
Actual behavior
- Whole process group
T (stopped), wchan: do_signal_stop; shell shows
suspended (tty input).
- Terminal left in the TUI's modes; SGR/CUP escape fragments (
35;37;18N...,
51;59;26M...) appear appended to shell prompt lines — i.e. the terminal's report stream
is being echoed by the foreground shell.
- A child process spawned immediately before the stop is left as an unreaped zombie.
- The session file is stale relative to the checkpoint by one full turn.
Evidence
Process state (captured after the incident, while still stopped):
$ ps -o pid,ppid,pgid,sid,tpgid,stat,tty,args -p <tui-pid>,<shell-pid>
PID PPID PGID SID TPGID STAT TT COMMAND
<tui> <shell> <tui> <shell> <shell> Tl pts/30 node …/codewhale resume <session>
<bin> <tui> <tui> <shell> <shell> Tl pts/30 …/downloads/codewhale resume <session>
<zomb> <bin> <zomb> <shell> <shell> Z pts/30 [zsh] <defunct>
<shell> <login> <shell> <shell> <shell> Ss+ pts/30 zsh
TPGID is the shell, not the TUI: the TUI group is in the background. STAT is Tl.
/proc/<tui>/status reports State: T (stopped) with wchan: do_signal_stop.
Shell job notification (visible on screen):
[1] + <tui-pid> suspended (tty input) codewhale resume <session-id>
(tty input) is the shell's rendering of a SIGTTIN stop — distinct from a plain
Ctrl+Z stop, which zsh reports without the (tty input) suffix.
Zombie child: <zsh> Z, PPid = the TUI binary, started in the same second as the
stop and never reaped — consistent with the parent being stopped before it could wait().
Session data split across two files:
sessions/<session>.json 249 messages mtime 18:21:05 (previous turn boundary)
sessions/checkpoints/<session>.json 265 messages mtime 18:24:13 (last snapshot before stop)
The last message in the checkpoint is an assistant message carrying two bash tool calls
with no matching tool results — the exact point of interruption. The 16 messages missing
from the session file are one complete round of work.
Terminal modes after the suspend (affected TTY vs. a healthy Codewhale TTY on the same
host, same terminal program):
affected: isig -icanon iexten -echo … opost
healthy : -isig -icanon -iexten -echo … -opost
The affected TTY still carries TUI-left signal/output processing bits. (Both are
-icanon -echo because zsh's ZLE also runs that way, so this comparison is suggestive, not
conclusive on its own.)
Screen text: the shell's own prompt lines are followed by long runs of 256-colour SGR /
cursor-position fragments, which is the "raw escape reports" pattern named in the source
comment quoted below.
Analysis
The codebase already documents this failure mode and defensively guards one moment —
startup:
// codewhale-tui/src/tui/ui/terminal.rs:115-126
/// Refuse to enter terminal modes from a background Unix process group.
///
/// A TTY can still report `isatty(3) == true` after a shell has suspended the
/// process. Reading from that background group triggers `SIGTTIN`; enabling
/// mouse or keyboard protocols before that stop poisons the shell with raw
/// escape reports. Check foreground ownership before the first mode change.
#[cfg(unix)]
pub(crate) fn require_foreground_terminal_owner() -> Result<()> { … }
and the interactive-shell path is outright refused on Unix because it cannot take over the
terminal safely:
// codewhale-tui/src/tools/shell.rs:1700-1708 (Unix)
pub(crate) fn inherited_interactive_terminal_refusal() -> Option<&'static str> {
Some("Inherited interactive terminal takeover is unavailable on Unix because foreground \
TTY ownership cannot be transferred safely. …")
}
// codewhale-tui/src/tools/shell.rs:2127-2133
// A new Unix process group that inherits the terminal is not its foreground owner.
// Letting it read stdin triggers SIGTTIN; sharing Codewhale's group instead would
// make cooked-mode Ctrl+C terminate both parent and child. Until this path owns a
// complete POSIX job-control lease, fail closed before spawning.
Both guards are pre-spawn guards. Nothing covers the case where Codewhale's own group
loses the foreground while it is running:
require_foreground_terminal_owner() has exactly one caller — event_loop.rs:628, in the
startup path. It is never re-evaluated, and there is no periodic or post-spawn
tcgetpgrp(STDIN_FILENO) != getpgrp() check.
emergency_restore_terminal() (tui/ui/terminal.rs:618) exists and does the right thing
(DisableMouseCapture, disable_raw_mode, …), but is only wired to the panic hook
(lib.rs:1757-1763) and one early-startup path (lib.rs:767).
- A
grep for SIGTSTP|SIGTTIN|SIGCONT|signal_hook across codewhale-tui finds no job
control handling at all — the only SIGWINCH mention is a comment in
tui/external_editor.rs. So when the kernel stops the process, no handler runs, and every
mode the TUI turned on stays on.
The same "terminal handoff is the caller's responsibility and one caller forgot" shape as
the external-editor input-pump issue (/hooks edit / Ctrl+Shift+O), but in the opposite
direction: there the TUI kept competing for input; here the TUI loses its claim on the
terminal entirely and the kernel does the stopping.
The split session state is the second half of the problem. persist_pending_work_checkpoint
(tui/ui/session_state.rs:80) writes PersistRequest::SaveCheckpoint → checkpoints/<id>.json
for in-progress work, while the main sessions/<id>.json is written at turn boundaries. A
stop that lands mid-turn therefore leaves the only copy of the turn in the checkpoint.
Impact
- Silent loss of a turn's visibility. A stop mid-turn leaves the session file one turn
behind. If the user gives up and kills the process, or resumes through a path that reads
the session file rather than the checkpoint, that work disappears.
- Terminal left poisoned. Mouse reports and bracketed-paste bytes are delivered into an
ordinary shell. At minimum this spews escape garbage; at worst the shell accumulates
content the user never typed.
- Misleading symptom. The session is alive and
fg-recoverable, but it is indistinguishable
from a crash or a hang. The user's rational next step is to kill it — which is precisely the
action that discards the unflushed turn.
- Unreaped children. Any child alive at stop time becomes a zombie (or an orphan if the
parent is later killed), so process-group signalling designed to clean up children has
nothing to run against.
- Reproducible on demand with any terminal editor or long-running shell child present at the
moment of the stop; no special configuration is required.
Suggested fix
- Install a job-control handshake (the durable fix). Handle
SIGTSTP, SIGTTIN, and
SIGCONT: on stop, run the same teardown as emergency_restore_terminal(), then
raise(SIGSTOP) (or re-raise the original signal with the default disposition restored);
on SIGCONT, re-enable modes and force a full repaint. This is what every other
full-screen TUI does, and it makes the failure impossible rather than merely detected.
- Re-check foreground ownership at runtime, not just at startup. After every child spawn
and on a low-frequency timer, compare tcgetpgrp(STDIN_FILENO) with getpgrp(). On
mismatch, pause the terminal input pump and surface an explicit message ("this session is
in the background — run fg") instead of being stopped by the kernel with no diagnostics.
Reuse validate_foreground_process_group so the message stays consistent with the startup
one.
- Make the checkpoint the recovery source of truth. Have
resume prefer the checkpoint
when it is newer than the session file, and tell the user how many messages came from it.
Alternatively, flush the in-flight turn into the session file when a stop or a
child-spawn is detected.
- Extend cleanup coverage beyond the panic hook:
SIGTERM/SIGHUP should also run
emergency_restore_terminal() and reap children, so a killed session does not leave the
terminal in the same state.
How to confirm you hit this (diagnostic recipe)
# 1. Is the TUI stopped rather than dead? STAT `T` and a foreground pgid that is not yours.
ps -o pid,ppid,pgid,tpgid,stat,tty,args -p "$(pgrep -f 'codewhale resume' | head -1)"
cat /proc/<pid>/status | grep -E '^(State|SigPnd|ShdPnd)'
# 2. Unreaped children of the stopped TUI:
ps -eo pid,ppid,stat,args | awk '$3 ~ /^Z/'
# 3. Was the turn flushed? Compare message counts and mtimes.
ls -l --time-style=full-iso ~/.codewhale/sessions/<id>.json \
~/.codewhale/sessions/checkpoints/<id>.json
# 4. Recover without losing the turn (do NOT kill):
# in the terminal that owns the session -> fg
Test gap
There is a unit test for the startup guard
(tui/ui.rs:360, tui_launch_preflight_rejects_background_process_group) but nothing
asserts runtime behaviour:
- no test that the TUI survives a
SIGTSTP/SIGTTIN/SIGCONT cycle with the terminal
restored (termios compared before/after, and mouse/paste/alt-screen re-enabled on resume);
- no test that
emergency_restore_terminal() runs on anything other than the panic path;
- no test that a checkpoint newer than the session file is what
resume loads.
A PTY-level integration test in the style of the external-editor reproduction script would
cover all three: start a TUI on its own PTY, move its foreground group away, send SIGTTIN,
assert the process stops and that the PTY's modes were restored, then SIGCONT and assert
repaint.
Workaround
- Recover the running session in place with
fg in the owning terminal — the process is
stopped, not dead, and the in-flight turn is still in memory.
- If the session is abandoned, run
reset (or stty sane) in that terminal to clear the
leftover modes; otherwise mouse movement keeps feeding escape bytes into the shell.
- Avoid backgrounding the Codewhale job (
Ctrl+Z + bg) on a TTY you intend to keep using.
Reporter notes
- No data was lost in the reported incident: the session file parses cleanly, all 149
tool calls in the persisted portion are paired, and the 16 unflushed messages are intact in
the checkpoint. Only the two tool results that were in flight are missing (both were
read-only inspection commands, trivially re-runnable).
- The stopped session was recoverable after the fact with
fg; nothing in the TUI itself was
deadlocked. The damage was to the terminal state and to session-file freshness.
- Companion report:
codewhale-issue-hooks-editor-input-contention.md — /hooks edit hands
the terminal to $EDITOR without pausing the TUI input pump, so keystrokes are split
between two readers. That bug and this one are mirror images: there the TUI keeps a claim
on the terminal it should have released, here it loses a claim it should have kept. Both
follow from terminal ownership being implemented per call-site rather than as one enforced
invariant, and each report now cross-references the other.
- A screen capture of the affected terminal is available on request: it shows the frozen TUI
frame, the shell's suspended (tty input) job notification, and the escape fragments
appended to the shell prompt.
Summary
Codewhale's TUI performs a foreground-ownership check exactly once, at startup
(
require_foreground_terminal_owner(), called fromevent_loop.rs:628). There is noruntime equivalent and no
SIGTSTP/SIGTTIN/SIGCONThandler anywhere in the TUI.So if the TUI's process group becomes non-foreground after startup — by any job-control
route — the outcome is:
SIGTTIN. The whole process group isstopped by the kernel, in the middle of an agent turn, with no chance to run any
cleanup. The shell reports:
[1] + <pid> suspended (tty input) codewhale resume <session>?1000h/?1002h/?1003h/?1006h/?1015hmouse reporting,?2004hbracketed paste, thealternate screen. The terminal then "poisons the shell with raw escape reports" — the
exact failure the code comment at
tui/ui/terminal.rs:119-120warns about — so mousemovement / scrolling feeds escape bytes into whatever foreground shell is present.
stopped parent can never
wait()on it (zsh <defunct>, parent = the stopped TUI).the previous turn boundary (249 messages); the work performed before the stop lives only
in
sessions/checkpoints/<id>.json(265 messages). Whetherresumesees those 16messages depends on which of the two it reads.
The TUI is not crashed and not deadlocked — it is stopped, and
fgrevives it. But theuser sees a frozen/garbled terminal, a shell prompt, and no indication that the session is
still alive in the background. From the user's side this reads as "the agent died mid-run".
Environment
0.9.13(npm wrapper + native binary built from thecodewhale-cli0.9.13crate)271x49,TERM=xterm-256colorcodewhale resume <session-id>ps//procand the shell's own job notificationObserved sequence
The consequence is reproducible and fully evidenced below. The exact job-control action
that first moved the process group out of the foreground is not yet pinned down — the
kernel does not record why a process was stopped, and the shell's earlier
suspendednotification (if any) had scrolled away. Candidates that all produce this end state:
Ctrl+Zfollowed bybg(resume in background) — the resumed group immediately reads theTTY and takes
SIGTTINtcsetpgrpthat hands the foreground to another job while Codewhale keeps its inputpump running
What is established: at the moment of the stop, the process group was not the TTY's
foreground group, and the TUI was reading the TTY (that is the only thing that raises
SIGTTIN).Expected behavior
Either the TUI never ends up reading a TTY it does not own, or — when it does — it behaves
like every other full-screen terminal program (vim, nano, htop, less): on
SIGTSTP/SIGTTINit restores the terminal (leave alt screen, disable mouse/paste, disable raw mode), stops
itself, and on
SIGCONTre-enters its modes and repaints. The user sees a clean shell whilesuspended and an intact TUI after
fg.Actual behavior
T (stopped),wchan: do_signal_stop; shell showssuspended (tty input).35;37;18N...,51;59;26M...) appear appended to shell prompt lines — i.e. the terminal's report streamis being echoed by the foreground shell.
Evidence
Process state (captured after the incident, while still stopped):
TPGIDis the shell, not the TUI: the TUI group is in the background.STATisTl./proc/<tui>/statusreportsState: T (stopped)withwchan: do_signal_stop.Shell job notification (visible on screen):
(tty input)is the shell's rendering of aSIGTTINstop — distinct from a plainCtrl+Zstop, which zsh reports without the(tty input)suffix.Zombie child:
<zsh> Z,PPid= the TUI binary, started in the same second as thestop and never reaped — consistent with the parent being stopped before it could
wait().Session data split across two files:
The last message in the checkpoint is an assistant message carrying two
bashtool callswith no matching tool results — the exact point of interruption. The 16 messages missing
from the session file are one complete round of work.
Terminal modes after the suspend (affected TTY vs. a healthy Codewhale TTY on the same
host, same terminal program):
The affected TTY still carries TUI-left signal/output processing bits. (Both are
-icanon -echobecause zsh's ZLE also runs that way, so this comparison is suggestive, notconclusive on its own.)
Screen text: the shell's own prompt lines are followed by long runs of 256-colour SGR /
cursor-position fragments, which is the "raw escape reports" pattern named in the source
comment quoted below.
Analysis
The codebase already documents this failure mode and defensively guards one moment —
startup:
and the interactive-shell path is outright refused on Unix because it cannot take over the
terminal safely:
Both guards are pre-spawn guards. Nothing covers the case where Codewhale's own group
loses the foreground while it is running:
require_foreground_terminal_owner()has exactly one caller —event_loop.rs:628, in thestartup path. It is never re-evaluated, and there is no periodic or post-spawn
tcgetpgrp(STDIN_FILENO) != getpgrp()check.emergency_restore_terminal()(tui/ui/terminal.rs:618) exists and does the right thing(
DisableMouseCapture,disable_raw_mode, …), but is only wired to the panic hook(
lib.rs:1757-1763) and one early-startup path (lib.rs:767).grepforSIGTSTP|SIGTTIN|SIGCONT|signal_hookacrosscodewhale-tuifinds no jobcontrol handling at all — the only
SIGWINCHmention is a comment intui/external_editor.rs. So when the kernel stops the process, no handler runs, and everymode the TUI turned on stays on.
The same "terminal handoff is the caller's responsibility and one caller forgot" shape as
the external-editor input-pump issue (
/hooks edit/ Ctrl+Shift+O), but in the oppositedirection: there the TUI kept competing for input; here the TUI loses its claim on the
terminal entirely and the kernel does the stopping.
The split session state is the second half of the problem.
persist_pending_work_checkpoint(
tui/ui/session_state.rs:80) writesPersistRequest::SaveCheckpoint → checkpoints/<id>.jsonfor in-progress work, while the main
sessions/<id>.jsonis written at turn boundaries. Astop that lands mid-turn therefore leaves the only copy of the turn in the checkpoint.
Impact
behind. If the user gives up and kills the process, or resumes through a path that reads
the session file rather than the checkpoint, that work disappears.
ordinary shell. At minimum this spews escape garbage; at worst the shell accumulates
content the user never typed.
fg-recoverable, but it is indistinguishablefrom a crash or a hang. The user's rational next step is to kill it — which is precisely the
action that discards the unflushed turn.
parent is later killed), so process-group signalling designed to clean up children has
nothing to run against.
moment of the stop; no special configuration is required.
Suggested fix
SIGTSTP,SIGTTIN, andSIGCONT: on stop, run the same teardown asemergency_restore_terminal(), thenraise(SIGSTOP)(or re-raise the original signal with the default disposition restored);on
SIGCONT, re-enable modes and force a full repaint. This is what every otherfull-screen TUI does, and it makes the failure impossible rather than merely detected.
and on a low-frequency timer, compare
tcgetpgrp(STDIN_FILENO)withgetpgrp(). Onmismatch, pause the terminal input pump and surface an explicit message ("this session is
in the background — run
fg") instead of being stopped by the kernel with no diagnostics.Reuse
validate_foreground_process_groupso the message stays consistent with the startupone.
resumeprefer the checkpointwhen it is newer than the session file, and tell the user how many messages came from it.
Alternatively, flush the in-flight turn into the session file when a stop or a
child-spawn is detected.
SIGTERM/SIGHUPshould also runemergency_restore_terminal()and reap children, so a killed session does not leave theterminal in the same state.
How to confirm you hit this (diagnostic recipe)
Test gap
There is a unit test for the startup guard
(
tui/ui.rs:360,tui_launch_preflight_rejects_background_process_group) but nothingasserts runtime behaviour:
SIGTSTP/SIGTTIN/SIGCONTcycle with the terminalrestored (
termioscompared before/after, and mouse/paste/alt-screen re-enabled on resume);emergency_restore_terminal()runs on anything other than the panic path;resumeloads.A PTY-level integration test in the style of the external-editor reproduction script would
cover all three: start a TUI on its own PTY, move its foreground group away, send
SIGTTIN,assert the process stops and that the PTY's modes were restored, then
SIGCONTand assertrepaint.
Workaround
fgin the owning terminal — the process isstopped, not dead, and the in-flight turn is still in memory.
reset(orstty sane) in that terminal to clear theleftover modes; otherwise mouse movement keeps feeding escape bytes into the shell.
Ctrl+Z+bg) on a TTY you intend to keep using.Reporter notes
tool calls in the persisted portion are paired, and the 16 unflushed messages are intact in
the checkpoint. Only the two tool results that were in flight are missing (both were
read-only inspection commands, trivially re-runnable).
fg; nothing in the TUI itself wasdeadlocked. The damage was to the terminal state and to session-file freshness.
codewhale-issue-hooks-editor-input-contention.md—/hooks edithandsthe terminal to
$EDITORwithout pausing the TUI input pump, so keystrokes are splitbetween two readers. That bug and this one are mirror images: there the TUI keeps a claim
on the terminal it should have released, here it loses a claim it should have kept. Both
follow from terminal ownership being implemented per call-site rather than as one enforced
invariant, and each report now cross-references the other.
frame, the shell's
suspended (tty input)job notification, and the escape fragmentsappended to the shell prompt.