feat(session): close sessions whose agent exited cleanly (#446, part 2) - #486
Conversation
…me dir
cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.
The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.
Split out ahead of the exited-agent sweep for Ark0N#446, which closes
sessions unattended and would otherwise make the loss routine.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Part 2 of Ark0N#446. Part 1 records an exited agent as SessionState.paneExit. A session whose agent the user ended with /exit is now closed through cleanupSession(), the same path the X button takes, so finished sessions stop piling up on the board. The lifecycle log records the reason as "agent exited cleanly (status 0)", and the conversation stays resumable from the Resume list. shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts holds the rule. It closes a session only when all of these hold: - The exit status is an explicit numeric 0 with no signal. An absent status is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the row stays. A non-zero status or any signal also keeps the row, with the exit code on the tab. - Two authoritative pane reads agreed on that exit. TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or skipped read neither confirms nor resets the count. - No start, attach or relaunch is running for the pane. Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose dead-pane branch revives an exited pane on purpose, and restartCli(). setPaneExit() already scopes paneExit to local mux-backed sessions, so remote, docker and direct-PTY sessions are never closed. planRebootRestore() now refuses a record whose persisted paneExit is a clean exit. That covers an agent that exited just before a reboot, before the sweep reached it. A crashed agent's record stays eligible, like its row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rk0N#446) Part 1 of Ark0N#446 taught the tab strip and the rich rail rows to say that a session's agent has exited. The phone overview and the desktop home rail still said "idle", beside a green or pulsing dot. _mobileOverviewExit() in mobile-overview.js is now the one rule for all three surfaces, and _sidebarRichRow() uses it as well. It changes what a row shows and leaves the row's state alone, because the state still picks the section and the sort order. An exited row gets an "exited" pill, a neutral dot and row accent, and a duration measured from when the server first saw the pane dead. A pending permission prompt or question still wins, as it does on the tab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…image guard Four fixes from a dual review of Ark0N#446 part 2. - The .claude-images guard compares canonical paths, so a sibling that reaches the same directory through a symlink keeps it. Its comment used to say that case only missed a deletion; it caused one. - A detached session counts as a live sibling. DELETE ?killMux=false removes it from the server's map while its pane keeps running, so the guard now reads persisted records too, and exempts only sessions being killed rather than every session in cleaningUp. - A session being closed refuses startInteractive() and startShell(). The /interactive route awaits listener setup before the start, and a start that raced the close could launch a CLI in a tmux session whose record was then deleted. A failed close clears the mark again. - The clean-exit sweep tries each exit once, keyed by session id and the exit's at stamp, so a close that fails is not retried and logged every two seconds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for this, and for splitting the What I checked and found right:
Typecheck is clean and the touched test files pass here (227/227). Two things I want to think about before merging, and I'd like your take on both:
Smaller notes, none blocking:
I'll get back to you on 1 and 2 shortly. |
Ark0N#446) A CLI that prints a startup error ("not logged in", a bad profile, a config error) and exits 0 used to lose its tab, and the error with it, about 4 s after launch. The sweep now keeps any clean exit that lands within CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The row stays as "exited (0)" for the user to read and close. Verified on an isolated instance: a shell that ran `exit 0` 2 s after start kept its row, one that exited after 13 s was closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged, thank you @irisitymichaelgrundberg. I added one commit on top, the 10 s startup window from your question 1. Details are on #446. |
|
This shipped in 1.33.1 tonight. Finished sessions tidying themselves away is a nice change to live with, thanks again @irisitymichaelgrundberg. |
Close sessions whose agent exited cleanly (#446, part 2)
Part 2 of #446, built to the maintainer's replies of 2026-09-20
and 2026-09-22. Part 1 (#466) records an exited agent as
SessionState.paneExit. This PR acts on it: a session whose agent the userended with
/exitis closed the way the X button closes it, and a crashedagent keeps its row.
It also carries the ride-along from the part 1 release note: the phone
overview and the desktop home rail now say
exitedinstead ofidle.The four commits
fix(cleanup): keep.claude-imageswhile a sibling uses the samedir. This is the guard the maintainer asked for as its own commit.
cleanupSession()removes{workingDir}/.claude-imagesonly when no othersession still uses that directory.
feat(session): close sessions whose agent exited cleanly. The sweepitself, plus a reboot-restore refusal.
fix(web): show "exited" on the phone overview and desktop home rail.fix(cleanup): close the gaps review found. Four fixes from aClaude + Codex review of commits 1 to 3, described below.
When a session is closed
shouldCloseCleanlyExitedSession()in the new pure modulesrc/pane-exit-sweep.tsholds the rule. A session is closed only when allthree of these hold:
status stays unknown. On tmux 3.2a a SIGKILLed pane reports neither a
status nor a signal, so reading absence as clean would close an agent the
OOM killer took.
isCleanPaneExit()accepts onlystatus === 0, and thewarning comment on
PaneExitnow points at it.TmuxManager.getPaneExitReadCount()counts them. A repeat of the same panepid, status and signal adds one, and anything else starts again at 1. A
failed, empty or skipped read never reaches
applyPaneExits(), so itneither confirms nor resets the count. At the watcher's 2 s cadence a
finished session goes 2 to 4 s after its agent exits.
Session.paneLifecycleInFlightis raised for the whole of_setupOrAttachMuxSession()andrestartCli(). The dead-pane branchrevives an exited pane on purpose, and the pane reads as dead until its
startup delay ends and the exit is cleared.
setPaneExit()already scopespaneExitto local mux-backed sessions, so aremote, docker, direct-PTY or discovered session is never closed.
The close goes through
cleanupSession(id, true, 'agent exited cleanly (status 0)'), the X button's path. An unpinned session is removed, a pinnedone is demoted to
status: 'stopped', and the lifecycle log records why. Theconversation stays resumable from the Resume list. Nothing here touches
statusorpid, and local panes keepremain-on-exit on.There is no setting, per answer 3 on the issue.
Reboot restore
planRebootRestore()now refuses a record whose persistedpaneExitis aclean exit, with the new skip reason
agent-exited. That covers an agent thatexited moments before the power went, before the sweep reached it, which is
the case the maintainer gave for persisting the field. A crashed agent's
record stays eligible, like its row on the board.
The
.claude-imagesguardA session counts as still using the directory when either of these holds:
stopped. This covers a sessiondetached with
DELETE ?killMux=false, which leaves the map while its panekeeps running.
Paths are compared by
realpath, so a sibling that reaches the same directorythrough a symlink counts. A session being killed does not count, so two
sessions of one case closed on the same tick still remove the directory once.
Erring toward "in use" costs only a missed deletion, which the hourly
paste-image GC ages out. One such case is known: a pinned record whose tmux
session is gone keeps its status through boot pruning, so it holds the
directory until it is unpinned.
What review changed
A Claude + Codex review of the first three commits found four gaps, fixed in
commit 4:
resolve(), so a symlinked sibling lost itsimages.
progress as if it were a kill.
/interactiveawaits listener setup beforestartInteractive(). A startthat raced a close could launch a CLI in a tmux session whose record was
then deleted.
Session.markClosing()now makes both start methods refusewhile a close is in progress, and a failed close clears the mark.
one attempt, keyed by session id and the exit's
atstamp.One question for you
The sweep has no minimum pane lifetime. Suppose a CLI prints a startup error
("not logged in", a missing profile, a config error) and exits 0. Its tab is
closed about 4 s after launch, and the only trace left is the lifecycle entry.
Before this change the dead pane stayed on screen with the error in it.
I have not confirmed that any supported CLI does this. Should the sweep skip an exit that
lands within a short window after the last pane start, say 10 s? It is about
ten lines and a test, and I am happy to add it here or separately.
Known gap, not changed here
Boot recovery still respawns a pane whose clean exit reached
state.jsonless than about 4 s before a server restart, for example during a deploy. The
dead-pane respawn in
_setupOrAttachMuxSession()predates this PR. After ahost reboot the same record is now refused, so the two paths disagree in that
narrow window.
Shells
The sweep closes shell sessions too.
exitafter a successful command closesthe tab, and
exit 3leaves anexited (3)row. That follows the same rule,but say if you would rather scope the sweep to agent modes.
Verified
On an isolated instance with its own data directory and tmux socket, on tmux
3.2a:
/exitwereclosed 2 to 4 s later. The lifecycle log reads
deletedwith reasonagent exited cleanly (status 0).exit 3and a shell whose process was SIGKILLed bothstayed on the board, as
exited (3)and a bareexited.removed with the last session.
phone overview, with a grey pill and a dot that does not pulse.
npm testpasses (8285 tests), as do typecheck, lint, format and the frontendsyntax check. New tests:
test/pane-exit-sweep.test.tsdrives the rule and the realpaneExitsUpdatedwiring on a server.test/paste-image-dir-shared.test.tscovers the guard, including a realdelete, a detach and a symlink.
test/home-screen-exited-rows.test.tscovers both home screens.test/tmux-manager.test.tsandtest/reboot-restore.test.tsgained theread count and the
agent-exitedrefusal.🤖 Generated with Claude Code