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
+40
View File
@@ -85,6 +85,15 @@ export type ClientMessage =
// the current turn. Frees the session so its transcript can be resumed elsewhere.
type: 'disconnect';
}
| {
// Sent on (re)connect when the client knows only Claude's transcript uuid — which, after a page
// refresh, is the ONLY id it has: officer's `sessionId` lived in React state and died with the
// page, while the uuid is in the URL. Officer reverse-maps it through the agent sidecar's
// on-disk session map and re-binds this socket to the live session, so a turn that kept running
// while the browser was away resumes delivering instead of stranding the user on a dead page.
type: 'attach';
claudeSessionId: string;
}
| {
// Sent on (re)connect: re-bind this socket to the session and replay every durable event queued
// since `cursor` (the last seq the client saw). Powers transparent reconnect without losing
@@ -120,6 +129,15 @@ export type ServerMessage =
context?: string;
contextId?: string;
}
| {
// Claude's transcript uuid, forwarded the moment the harness reports it (its `system.init`) rather
// than at the end of the turn with `result`. The client writes it straight into the address bar, so
// the chat is addressable — and therefore recoverable after a refresh — from the first second of the
// first turn instead of only once the turn has finished. Live-only: anyone replaying the durable log
// reached it by this id already.
type: 'session:claude';
claudeSessionId: string;
}
| ({
type: 'assistant:text';
text: string;
@@ -158,6 +176,26 @@ export type ServerMessage =
isGenerating: boolean;
streamingText: string;
}
| {
// Answer to a client `attach`: this socket is now bound to the live session. Deliberately carries
// no messages. The client has just loaded the transcript over HTTP and the harness writes that file
// as it goes, so the past is already on screen; what it cannot have is the part of the turn still
// being written. Sending both records of the same messages is the one thing guaranteed to produce
// duplicates — there is no shared id to reconcile them by — so attach hands over the *future* of
// the turn plus the half-written paragraph, and nothing else.
type: 'sync:live';
sessionId: string;
isGenerating: boolean;
streamingText: string;
/**
* The session's newest durable cursor, so the client starts from the head rather than from zero.
* Not an optimisation: this socket now holds officer's session key, so the *next* drop goes down
* the `resume-cursor` path — and a cursor of 0 there would replay the entire session on top of the
* transcript the client already loaded over HTTP, turning one reconnect into a duplicated
* conversation.
*/
cursor: number;
}
| {
type: 'error';
message: string;
@@ -194,6 +232,7 @@ export type ServerMessage =
// cursor of the previous durable message in the same session — which lets a reconnecting client tell a
// contiguous replay from one with a hole in it. Absent when the writer cannot vouch for it.
export type TurnMessageType =
| 'session:claude'
| 'assistant:delta'
| 'assistant:text'
| 'tool:start'
@@ -209,6 +248,7 @@ export type TurnMessageType =
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
export type ChatEvent =
| { type: 'session'; claudeSessionId: string }
| ({ type: 'text'; text: string } & Parented)
| ({ type: 'delta'; text: string } & Parented)
| ({
+75 -1
View File
@@ -8,7 +8,7 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent } from 'officerdb';
import { getUserSettings, getEmailAccounts, getChatEventsSince, getLastChatEventSeq, appendChatEvent } from 'officerdb';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
@@ -130,6 +130,8 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
await handleDisconnect(ws);
} else if (clientMsg.type === 'resume-cursor') {
await handleResumeCursor(ws, clientMsg);
} else if (clientMsg.type === 'attach') {
await handleAttach(ws, clientMsg);
}
} catch (err) {
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) });
@@ -603,6 +605,78 @@ async function handleResumeCursor(
if (msg.generating) await endTurnIfAgentIsGone(ws, sessionId, model);
}
/**
* Re-bind a socket that knows only Claude's transcript uuid.
*
* This is the refresh case, and until now it was the hole in an otherwise complete reconnect path. Every
* piece of the machinery already existed — the session survives a dropped socket, the agent keeps
* generating into it, `close` only detaches and arms an hour-long idle timer — but the browser came back
* having forgotten officer's session id, so `resume-cursor` could never fire and the output simply stopped
* arriving. The uuid in the URL is the one identifier a refresh cannot destroy; the agent's on-disk map
* turns it back into the key everything else here is written in terms of.
*
* Deliberately hands over only the live turn, never the transcript — see `sync:live`.
*/
async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId: string }): Promise<void> {
const { claudeSessionId } = msg;
if (!claudeSessionId) return;
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId);
if (!sessionId) {
// No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation
// opened from history lands here every time. Stay silent and leave the socket as it was — the next
// `chat` mints a session in the usual way.
logger.info('Attach found no live session for transcript', { claudeSessionId });
return;
}
// An officer restart takes the in-memory session with it while the agent carries on, so the key can
// resolve to a session this process has never heard of. Adopting re-subscribes it to the sidecar's bus,
// which is what makes the rest of the turn arrive.
const existing = sessionManager.getSession(sessionId);
const session = existing ?? adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, '');
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
// The client learns officer's key here, so any *later* drop of this socket goes down the existing
// cursor-replay path instead of coming back through attach.
sendToClient(ws, {
type: 'session:init',
sessionId,
model: session.model,
cwd: session.cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
// `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
// same reason, as `endTurnIfAgentIsGone`.
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId);
session.isGenerating = isGenerating;
let cursor = 0;
try {
cursor = (await getLastChatEventSeq(sessionId)) ?? 0;
} catch (err) {
logger.error('Failed to read chat event head on attach', { sessionId, error: String(err) });
}
sendToClient(ws, {
type: 'sync:live',
sessionId,
isGenerating,
cursor,
// Whatever the agent had typed but not yet finished as a message. The transcript on disk cannot
// supply it — the harness writes an assistant message only once it is complete — so this is the one
// piece of the turn a refresh would otherwise genuinely lose.
streamingText: session.streamBuffer,
});
logger.info('Attached socket to live session by transcript id', { sessionId, claudeSessionId, isGenerating });
}
/**
* The client came back still believing a turn is running. Check whether it is, and if it isn't, say so.
*