Skip to content

feat(session): close sessions whose agent exited cleanly (#446, part 2) - #486

Merged
Ark0N merged 5 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/446-close-exited-sessions
Sep 24, 2026
Merged

Ark0N merged 5 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:fix/446-close-exited-sessions

Conversation

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor

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 user
ended with /exit is closed the way the X button closes it, and a crashed
agent 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 exited instead of idle.

The four commits

  1. fix(cleanup): keep .claude-images while a sibling uses the same
    dir.
    This is the guard the maintainer asked for as its own commit.
    cleanupSession() removes {workingDir}/.claude-images only when no other
    session still uses that directory.
  2. feat(session): close sessions whose agent exited cleanly. The sweep
    itself, plus a reboot-restore refusal.
  3. fix(web): show "exited" on the phone overview and desktop home rail.
  4. fix(cleanup): close the gaps review found. Four fixes from a
    Claude + Codex review of commits 1 to 3, described below.

When a session is closed

shouldCloseCleanlyExitedSession() in the new pure module
src/pane-exit-sweep.ts holds the rule. A session is closed only when all
three of these hold:

  • The exit status is an explicit numeric 0 with no signal. An absent
    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 only status === 0, and the
    warning comment on PaneExit now points at it.
  • Two authoritative pane reads agreed on that exit.
    TmuxManager.getPaneExitReadCount() counts them. A repeat of the same pane
    pid, status and signal adds one, and anything else starts again at 1. A
    failed, empty or skipped read never reaches applyPaneExits(), so it
    neither confirms nor resets the count. At the watcher's 2 s cadence a
    finished session goes 2 to 4 s after its agent exits.
  • No start, attach or relaunch is running for the pane.
    Session.paneLifecycleInFlight is raised for the whole of
    _setupOrAttachMuxSession() and restartCli(). The dead-pane branch
    revives an exited pane on purpose, and the pane reads as dead until its
    startup delay ends and the exit is cleared.

setPaneExit() already scopes paneExit to local mux-backed sessions, so a
remote, 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 pinned
one is demoted to status: 'stopped', and the lifecycle log records why. The
conversation stays resumable from the Resume list. Nothing here touches
status or pid, and local panes keep remain-on-exit on.

There is no setting, per answer 3 on the issue.

Reboot restore

planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit, with the new skip reason agent-exited. That covers an agent that
exited 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-images guard

A session counts as still using the directory when either of these holds:

  • It is in the server's session map and is not itself being killed.
  • It has a persisted record that is not stopped. This covers a session
    detached with DELETE ?killMux=false, which leaves the map while its pane
    keeps running.

Paths are compared by realpath, so a sibling that reaches the same directory
through 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:

  • The guard compared paths with resolve(), so a symlinked sibling lost its
    images.
  • The guard could not see a detached sibling, and exempted a detach in
    progress as if it were a kill.
  • /interactive awaits listener setup before startInteractive(). A start
    that raced a close could launch a CLI in a tmux session whose record was
    then deleted. Session.markClosing() now makes both start methods refuse
    while a close is in progress, and a failed close clears the mark.
  • A close that failed was retried, and logged, every 2 s. Each exit now gets
    one attempt, keyed by session id and the exit's at stamp.

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.json
less than about 4 s before a server restart, for example during a deploy. The
dead-pane respawn in _setupOrAttachMuxSession() predates this PR. After a
host reboot the same record is now refused, so the two paths disagree in that
narrow window.

Shells

The sweep closes shell sessions too. exit after a successful command closes
the tab, and exit 3 leaves an exited (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:

  • A real Claude session and a real Codex session ended with /exit were
    closed 2 to 4 s later. The lifecycle log reads deleted with reason
    agent exited cleanly (status 0).
  • A shell ended with exit 3 and a shell whose process was SIGKILLed both
    stayed on the board, as exited (3) and a bare exited.
  • Pasted images survived while a sibling session used the directory, and were
    removed with the last session.
  • Playwright screenshots show the exited row on the desktop home rail and the
    phone overview, with a grey pill and a dot that does not pulse.

npm test passes (8285 tests), as do typecheck, lint, format and the frontend
syntax check. New tests:

  • test/pane-exit-sweep.test.ts drives the rule and the real
    paneExitsUpdated wiring on a server.
  • test/paste-image-dir-shared.test.ts covers the guard, including a real
    delete, a detach and a symlink.
  • test/home-screen-exited-rows.test.ts covers both home screens.
  • test/tmux-manager.test.ts and test/reboot-restore.test.ts gained the
    read count and the agent-exited refusal.

🤖 Generated with Claude Code

…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>
@Ark0N

Ark0N commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for splitting the .claude-images guard into its own commit like I asked. I went through it carefully, since an automatic close of someone's session is the one thing here that can't be undone, and it holds up. It does what we agreed in #446 and nothing more.

What I checked and found right:

  • isCleanPaneExit() accepts only an explicit status === 0 with no signal, so an absent status stays unknown. That's the tmux 3.2a SIGKILL case covered.
  • applyPaneExits() restarts the count when pid, status or signal changes, and a failed or skipped read never reaches it.
  • paneLifecycleInFlight wraps all of _setupOrAttachMuxSession() and restartCli(), and the exit is cleared inside that window, so a revive can't be swept. reattachRemote() isn't wrapped, but the sweep never sees remote sessions.
  • The pane-exit watcher only starts after restoreMuxSessions(), so boot recovery can't race the sweep.
  • The close goes through cleanupSession(id, true, reason), which stops respawn and Ralph, and nothing touches status or pid. markClosing() blocks a start that races the close.
  • The .claude-images guard compares by realpath and counts a detached sibling through its persisted record.
  • The CLAUDE.md and architecture-invariants text matches the code.

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:

  1. A minimum pane lifetime. Right now a CLI that exits 0 during startup loses its tab about 4 s later, taking whatever it printed with it. The case I have in mind is Codex's startup "update available" prompt: pick update, it updates and exits, and the only output is "please restart Codex". I haven't reproduced that exit status, so treat it as a suspicion. I'm leaning toward taking your earlier offer to skip exits within roughly 10 to 15 s of the pane starting. That needs a pane-start stamp, since paneExit.at is when Codeman first saw the pane dead, not when it started.
  2. Shell sessions. A shell tab closes on a plain exit, but false; exit leaves an exited (1) row. That follows the rule exactly, but from the user's side it depends on whatever the last command returned, which will feel random. I haven't decided whether shells should be in or out yet.

Smaller notes, none blocking:

  • An agent worker told to /exit can now be closed before its parent reads it. A parent doing send-and-wait on stop,exit and then polling last-response for 15 s races a close that lands about 4 s after the exit. It's rare, and I'll probably just add a line to the skill docs.
  • A failed close is attempted once per exit (cleanExitCloseAttempts), so a session whose close failed keeps its exited badge until the next exit. Fine by me, just noting it's intentional.
  • Tiny window: applying a custom model to an exited session sets the env before restartCli() raises the in-flight mark, so the sweep could close it in between.
  • The gap you flagged (boot recovery reviving a clean exit saved in the last ~4 s before a restart) predates this PR and is fine as a follow-up.

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>
@Ark0N
Ark0N merged commit b80d47a into Ark0N:master Sep 24, 2026
2 checks passed
@Ark0N

Ark0N commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Merged, thank you @irisitymichaelgrundberg. I added one commit on top, the 10 s startup window from your question 1. Details are on #446.

@Ark0N

Ark0N commented Sep 24, 2026

Copy link
Copy Markdown
Owner

This shipped in 1.33.1 tonight. Finished sessions tidying themselves away is a nice change to live with, thanks again @irisitymichaelgrundberg.

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.

3 participants