let an idle browser go without taking the agent with it

Officer's hour-long idle timer was doing two unrelated jobs: collecting its own in-memory binding, which
is its business, and terminating the agent, which is the sidecar's. It could not do the first without
the second, because `unsub` was a closure reachable only through `kill`.

So a browser that went away killed a live agent an hour later — including one the sidecar had
deliberately protected. The sidecar already refuses to collect a session that is mid-turn or holding
background tasks: `task:started` disarms its idle GC, and `armIdle` re-checks and re-arms rather than
firing once. Officer had no view of any of that. A laptop running out of battery overnight took a
`run_in_background` job with it for no reason.

`detach` now sits beside `kill` on both streaming handles, and `_sidecarUnsub` — declared and called for
a long time, never once assigned — is populated at all three sites. `releaseSession` unsubscribes and
forgets the record without killing; the idle timer points at it. `deleteSession` is unchanged, so an
explicit disconnect still ends the session.

The third assignment site was not in the plan: `adoptOrphanedSession` sets `_claudeKill` but nothing
else, so an adopted session that later idled out would have dropped its record while the listener stayed
subscribed — a leak of one per adopt-then-leave.

No double subscription: releasing unsubscribes first, so a returning browser either adopts with a fresh
listener or starts a first turn with none behind it.

Step 1 of docs/chat-session-lifetime.md. Step 2 (a list verb, so running sessions can be found after a
restart) is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 00:32:10 +01:00
co-authored by Claude Opus 5
parent 5286e9f9ec
commit be266da9e2
5 changed files with 109 additions and 34 deletions
+12
View File
@@ -45,7 +45,18 @@ type ClaudeCodeStreamingParams = {
};
type ClaudeCodeStreamingHandle = {
/** End the agent's session upstream and stop listening. For an explicit disconnect. */
kill: () => void;
/**
* Stop listening and leave the agent running.
*
* These are separate because officer's idle GC and a user's "disconnect" want different things, and
* for a long time they could not have them: `unsub` was a closure reachable only through `kill`, so
* letting go of a session necessarily killed it. That is why an idle browser took a live agent down
* with it — including one the sidecar had deliberately protected because background work was still
* in flight.
*/
detach: () => void;
};
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
@@ -67,5 +78,6 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
sidecar.killClaude(params.sessionKey);
unsub();
},
detach: unsub,
};
}