reattach a refreshed browser to a running turn

Refreshing mid-turn appeared to kill the agent's output. It never did: the
session survives a dropped socket, the agent keeps generating into it and keeps
committing durable events, and `close` only detaches the socket and arms an
hour-long idle timer. What broke was purely delivery — and the reconnect path
that would have fixed it could not fire, because the browser came back having
forgotten officer's session key. It lived in page state. The only id left was
Claude's transcript uuid in the URL, and nothing accepted that.

So accept it. `attach` carries the uuid, and the agent's on-disk session map —
the single record relating the two — turns it back into the key everything else
is written in terms of. The uuid now also goes out at `system.init` rather than
only at `result`, which is what makes the first turn recoverable at all: until
now a chat had no address until it had finished, and a long first turn is
exactly the one worth reconnecting to.

`sync:live` deliberately carries no messages. The harness writes its transcript
as it goes, so the HTTP load on landing already supplies the past; sending the
server's record of the same messages on top of it would duplicate them, and
there is no shared id to reconcile the two by. Attach hands over the rest of the
turn, the half-written paragraph the transcript cannot hold, and the session's
cursor head — that last one so a *later* drop replays from the head instead of
re-delivering the whole conversation from zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:59:26 +00:00
co-authored by Claude Opus 5
parent dc5ad28aa2
commit 6b4339052a
12 changed files with 316 additions and 19 deletions
+21
View File
@@ -318,6 +318,27 @@ export async function isClaudeGenerating(sessionKey: string): Promise<boolean> {
}
}
/**
* Officer's session key for a Claude transcript uuid, or null if the agent has never seen it.
*
* The browser only ever has the uuid after a refresh — it is what the URL carries — and officer's own
* key is not derivable from it. The agent's on-disk map is the single record that relates them, so this
* is the hinge the whole reattach path turns on.
*
* Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls
* back to today's behaviour of leaving the socket unattached rather than binding it to a guess.
*/
export async function findClaudeSessionKey(claudeSessionId: string): Promise<string | null> {
const sc = findSidecarByCapability('claude');
if (!sc) return null;
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId });
return res.type === 'claude:session-key' ? res.sessionKey : null;
} catch {
return null;
}
}
export function clearClaudeSession(sessionKey: string): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
}