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
@@ -95,6 +95,17 @@ export type ServerMessage =
// which addresses nothing after the socket closes.
| { type: 'result'; sessionId: string; cost: MessageCost; claudeSessionId?: string }
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
/**
* Claude's transcript uuid, sent at the *start* of a turn. Makes the address bar a permalink before the
* turn produces anything, which is what makes a mid-turn refresh recoverable at all.
*/
| { type: 'session:claude'; claudeSessionId: string }
/**
* Answer to an `attach`: this socket is bound to a live turn again. Carries no messages by design — the
* transcript came over HTTP a moment ago and there is no shared id to reconcile the two records by, so
* this hands over the rest of the turn and the half-written paragraph, and nothing that would double up.
*/
| { type: 'sync:live'; sessionId: string; isGenerating: boolean; streamingText: string; cursor: number }
| { type: 'error'; message: string; errorCode?: string }
| { type: 'stopped' }
/** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */
+88 -15
View File
@@ -28,6 +28,22 @@ type UsePiChatOptions = {
onTurnComplete?: (hadToolCalls: boolean) => void;
};
/**
* Point the address bar at Claude's transcript uuid — the only id `/chat/sessions/:id` can resolve, and
* the only one that survives a refresh.
*
* The permalink is bare: no group, and `?cwd=` is stripped rather than carried. The transcript records
* its own cwd and the server resolves it from the id, so naming the group again could only ever
* contradict it — which is what a hand-edited or stale `?cwd=` used to do. Anything else in the query
* string is left alone.
*/
function writePermalink(claudeSessionId: string): void {
const params = new URLSearchParams(window.location.search);
params.delete('cwd');
const search = params.toString();
window.history.replaceState(null, '', `/chat/${claudeSessionId}${search ? `?${search}` : ''}`);
}
export function useChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
const {
replaceUrl = true,
@@ -96,6 +112,13 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// Mirror for the reconnect handshake, which runs from a stable callback and cannot read state.
const isGeneratingRef = useRef(false);
isGeneratingRef.current = isGenerating;
// Claude's transcript uuid — the reconnect handshake's fallback when officer's key is gone.
//
// Seeded from `resumeSessionId` because on a refresh that IS the URL: the page remounts with no memory
// of officer's session, and this is the only identifier left to reconnect by. Kept current from
// `session:claude` (start of turn) and `result` (end), so a chat started in this tab becomes
// reattachable the moment the harness names its transcript rather than when the turn finishes.
const claudeSessionIdRef = useRef<string | null>(resumeSessionId ?? null);
const client = useClient();
@@ -230,6 +253,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setCwd(msg.cwd);
break;
case 'session:claude':
// The turn has only just begun and the conversation is already addressable. Everything that makes
// a refresh survivable hangs off this: the URL is what the reattach handshake sends, so writing it
// here rather than at `result` is the difference between "refresh mid-turn and lose the turn" and
// "refresh mid-turn and watch it carry on".
claudeSessionIdRef.current = msg.claudeSessionId;
if (replaceUrl) writePermalink(msg.claudeSessionId);
break;
case 'assistant:delta':
// A subagent's deltas are deliberately not streamed. Two speakers cannot share one cursor, and the
// complete `assistant:text` that follows lands in the Task row a moment later regardless.
@@ -284,19 +316,13 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
}
case 'result': {
// Make the address bar a permalink. This runs on `result`, not `session:init`, because the id
// there is officer's per-connection key (`msg.sessionId || randomUUID()`), which
// `/chat/sessions/:id` cannot resolve — only the turn reports the real transcript uuid.
//
// The permalink is bare: no group, and `?cwd=` is stripped rather than carried. The transcript
// records its own cwd and the server resolves it from the id, so naming the group again could
// only ever contradict it — which is what a hand-edited or stale `?cwd=` used to do. Anything
// else in the query string is left alone.
if (replaceUrl && msg.claudeSessionId) {
const params = new URLSearchParams(window.location.search);
params.delete('cwd');
const search = params.toString();
window.history.replaceState(null, '', `/chat/${msg.claudeSessionId}${search ? `?${search}` : ''}`);
// Belt and braces for the permalink: `session:claude` normally got here first, but a harness that
// never emitted an init (or a turn relayed by another sidecar) still reports the uuid at the end.
// Deliberately not keyed off officer's `sessionId`, which is a per-connection key
// (`msg.sessionId || randomUUID()`) that `/chat/sessions/:id` cannot resolve.
if (msg.claudeSessionId) {
claudeSessionIdRef.current = msg.claudeSessionId;
if (replaceUrl) writePermalink(msg.claudeSessionId);
}
commitStreaming();
setMessages((prev) => [
@@ -313,6 +339,25 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
}
case 'sync:live': {
// Re-bound to a turn that kept running while this page was away. Messages are deliberately absent
// — the transcript already loaded over HTTP — so this only picks up the live state and lets the
// rest of the turn arrive through the normal cases below.
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setIsGenerating(msg.isGenerating);
if (msg.isGenerating) setHasStarted(true);
// Start from the session's head, not from zero: this socket now knows officer's key, so the next
// drop replays via `resume-cursor` — and from zero that would re-deliver the whole conversation
// on top of the transcript already on screen.
if (msg.cursor > cursorRef.current) cursorRef.current = msg.cursor;
if (msg.streamingText) {
streamingRef.current = msg.streamingText;
flushStreaming();
}
break;
}
case 'sync:messages': {
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
@@ -426,9 +471,29 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// `model`/`cwd` come back from the session:init this client already holds. After an officer restart the
// server has no memory of either, and it needs both to re-adopt the session rather than leave it
// orphaned — so the client, which is now the only party that remembers, hands them back.
// Ask the server to re-bind this socket by Claude's transcript uuid — the refresh case, where officer's
// key died with the page but the uuid is right there in the URL. Sending it for a chat that turns out
// not to be running costs one message and a silent no from the server, which is the right trade: the
// case worth catching is opening a URL whose turn is still going, and that is indistinguishable from
// the ordinary one until the server has looked.
const attachedRef = useRef<string | null>(null);
const sendAttach = useCallback(() => {
const claudeId = claudeSessionIdRef.current;
// Officer's own key, once we have one, is strictly better: it addresses the session directly and
// replays from a cursor. Attach is only ever the fallback for not having it.
if (!claudeId || sessionIdRef.current || attachedRef.current === claudeId) return;
attachedRef.current = claudeId;
sendRef.current({ type: 'attach', claudeSessionId: claudeId });
}, []);
const onOpen = useCallback(() => {
const sid = sessionIdRef.current;
if (!sid) return;
if (!sid) {
// Without this the callback returned early and the socket sat idle while the turn ran on unwatched
// — which is why output "stopped" on refresh and the only way forward was to re-send the prompt.
sendAttach();
return;
}
sendRef.current({
type: 'resume-cursor',
sessionId: sid,
@@ -440,11 +505,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// transcript just stops. The server checks the claim with the agent and answers if it's false.
generating: isGeneratingRef.current,
});
}, []);
}, [sendAttach]);
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });
sendRef.current = send;
// `resumeSessionId` is resolved asynchronously by the panel that owns this hook, so it routinely lands
// *after* the socket has already opened and `onOpen` has come and gone with nothing to send. Attaching
// is idempotent, so covering both orders here is simpler than sequencing them.
useEffect(() => {
if (resumeSessionId) claudeSessionIdRef.current = resumeSessionId;
if (isConnected) sendAttach();
}, [resumeSessionId, isConnected, sendAttach]);
// Clean up RAF on unmount
useEffect(() => {
return () => {