IDLE_TIMEOUT_MS dropped to 30s, a background ticker started, the tab closed. Officer logged the release thirty seconds later and the job ticked straight through it — the exact point where deleteSession used to call _claudeKill. Constant reverted. Also worth writing down: restarting officer does not test this. The process dies outright and releaseSession never runs, so that only exercises adoptOrphanedSession, which already worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.0 KiB
Chat session lifetime — findings and plan
Written 2026-08-09, before implementation. The investigation is done; the code is not.
The trigger. A laptop ran out of battery overnight while an agent on the home lab was running a 15-minute background loop (review new commits, restart the server). The session should have survived until morning. Officer would have killed it about an hour after the browser socket dropped.
What is actually there — verified, not remembered
Two independent idle policies exist, in two processes, answering different questions.
The sidecar's — the real one
src/servers/sidecar/claude/claude-manager.ts
IDLE_TIMEOUT_MS = 30 * 60 * 1000(:39) — thirty minutes with no new turn.armIdle(:284-295) is a heartbeat, not a one-shot: on expiry it re-checks and re-arms ifisGenerating || pendingTasks.size > 0, so it can never collect a session that is still working.pendingTasks: Set<string>(:174) holds background tasks started but not yet notified.task:startedadds the id and clears the idle timer outright (:406-411);task:notificationremoves it and re-arms only if nothing else is outstanding (:413-414).- Its own comment states the intent: "never GC a session that's mid-turn or still has background tasks
running — a long silent
run_in_backgroundjob would otherwise be killed along with its pendingtask_notification."
So the sidecar already detects long-running background work with no declaration from the user. The "mark a session permanent" feature is not needed for this case.
There is also stallTimer for a turn that claims to be generating but has emitted nothing, and
SEND_TIMEOUT_MS = 30 * 60 * 1000 (:10).
Officer's — the blunt one
src/servers/api/chat/websocket.ts, src/servers/api/chat/session-manager.ts
IDLE_TIMEOUT_MS = 60 * 60 * 1000(websocket.ts:48).- Armed by the socket closing, not by it being unresponsive:
close(ws)→detachWs+setIdleTimeout(websocket.ts:151-163). Unresponsiveness matters only upstream — Bun closes a WS idle for 60s and there is a per-connection heartbeat, so a dead laptop loses the socket about a minute in, and that starts the hour. - Expiry runs
deleteSession(session-manager.ts:74-102), which calls_sidecarUnsub()then_claudeKill()→sidecar.killClaude(sessionId). attachWsclears the timer (session-manager.ts:104-116), so reconnecting cancels it cleanly.
Three defects follow:
- It kills sessions the sidecar has deliberately protected. Officer has no view of
pendingTasks. - It does not survive
pm2 restart officer— asetTimeouton an in-memory record, in the process designed to bounce. If the browser never returns, nothing re-arms it. - On a flat battery the sidecar's 30 minutes normally fires first anyway, so officer's hour is mostly redundant except in the one case where it does damage — a session with background work, which the sidecar keeps and officer kills.
What is already fine
pm2 restart officer does not kill running sessions. Sidecars are PM2 peers, not children; the
agent keeps generating and keeps committing to chat_session_events. Only officer's in-memory binding
dies, and adoptOrphanedSession (websocket.ts:559) rebuilds it — including the session-scoped
subscription, without which a reconnected client replays and then goes silent for the rest of the turn.
Step 1 — officer's timer releases instead of kills — DONE
Officer's idle timer is doing two unrelated jobs: garbage-collecting its own binding (its business) and terminating the agent (the sidecar's). Split them and leave the agent's lifetime to the process that already reasons about it correctly.
Rejected alternative: teaching officer about pendingTasks over the protocol. That re-implements
the sidecar's heartbeat in a second place, which is how these two drifted apart to begin with.
The blocker. src/servers/channels/send-claude-code.ts:65-69 welds them together:
return {
kill: () => {
sidecar.killClaude(params.sessionKey);
unsub();
},
};
unsub is a closure reachable only through kill, so officer cannot let go without killing.
The seam already exists. _sidecarUnsub is declared (types.ts:310) and called separately in
deleteSession (session-manager.ts:83-84) — and nothing ever assigns it. Populating it is the fix.
Work:
send-claude-code.ts— return{ kill, detach };killstays as-is,detachisunsubalone.- Same for the OpenCode path (
websocket.ts:452assignshandle.killthere too). websocket.ts:361/:452— also assignsession._sidecarUnsub = handle.detach.session-manager.ts— addreleaseSession(sessionId): clear the idle timer, call_sidecarUnsub, drop fromsessions/userSessions. Do not call_claudeKill.setIdleTimeout's callback →releaseSession, notdeleteSession.- Leave
deleteSessionalone — explicit disconnect (handleDisconnect) must still kill.
Check while doing it: adoptOrphanedSession notes that handleChat treats an absent _claudeKill as
"first turn of this session" and would open a second subscription, delivering every message twice.
Make sure a released-then-readopted session cannot land in that state.
Consequence to accept: after an hour a returning browser goes through the adopt path rather than finding a live record. That path already runs on every officer restart.
As built. detach added beside kill on both streaming handles; _sidecarUnsub populated at all
three sites (Claude first turn, OpenCode, and adoptOrphanedSession — that last one was not in the
original list and would have leaked a listener per adopt-then-idle); forget factored out of
deleteSession; releaseSession added; the idle timer points at it. deleteSession still kills, so an
explicit disconnect is unchanged.
The double-subscription trap does not arise: releaseSession unsubscribes and drops the record, so a
returning browser either adopts (fresh single subscription) or starts a first turn with no stale
listener behind it. unsub is a Set.delete, so deleteSession calling it twice is harmless.
Verified end to end, 2026-08-10 01:03. IDLE_TIMEOUT_MS was temporarily dropped to 30s (reverted
immediately after), a ticking run_in_background job started, and the tab closed. Officer logged
Session idle timeout reached, releasing binding (agent left running) thirty seconds later, and the job
ticked straight through it and kept going. That is the exact point at which deleteSession used to call
_claudeKill. Reopening the tab adopted the session and resumed its output.
Note the shape of the test: restarting officer does not exercise this — the process dies outright and
releaseSession never runs, so that only tests adoptOrphanedSession, which already worked. The socket
has to close while officer stays up.
Step 2 — enumerate live sessions
After an officer restart a live session is invisible until a browser reconnects to it by id; adoption is on-demand only. There is no list verb in the sidecar protocol (checked).
Add claude:list returning each live sessionKey with isGenerating and pendingTasks.size, so
officer can answer "what is running right now" and surface it.
Do not try to persist a running session. A live session is a running query() with an open stream;
it cannot be serialised. The durable part — the output — is already in chat_session_events, which is
what makes replay work.
Hard limit: if the agent sidecar restarts, a mid-turn is lost regardless. Officer restarts are
survivable; officer-agent restarts are not.
Deliberately not decided
Keeping an idle session alive — nothing generating, no background work — is a separate decision and the only part that would need the user to mark anything. Steps 1 and 2 cover sessions with work in flight, which is the case that actually bit. A 15-minute loop re-arms the sidecar's 30-minute timer on every turn, so it never idles out on its own.