diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index c125186d..cf445539 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -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 & { prevSeq?: number }; export type ChatEvent = + | { type: 'session'; claudeSessionId: string } | ({ type: 'text'; text: string } & Parented) | ({ type: 'delta'; text: string } & Parented) | ({ diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index d73b28b6..28edf8bf 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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, 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, msg: { claudeSessionId: string }): Promise { + 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. * diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index 48c6a66a..1f04343f 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -318,6 +318,27 @@ export async function isClaudeGenerating(sessionKey: string): Promise { } } +/** + * 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 { + 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 }); } diff --git a/src/servers/sidecar/claude/state.ts b/src/servers/sidecar/claude/state.ts index 59dfd5fc..f2fb317e 100644 --- a/src/servers/sidecar/claude/state.ts +++ b/src/servers/sidecar/claude/state.ts @@ -105,6 +105,28 @@ export function getClaudeSession(sessionKey: string): string | undefined { return currentState.claudeSessions[sessionKey]; } +/** + * The same map read backwards: Claude's transcript uuid → the key officer made up for the session. + * + * A browser that has refreshed holds only the uuid, because that is what is in the URL; officer's own + * key lived in page state and is gone. This is the only record anywhere that relates the two, which is + * why re-binding a socket to a running turn has to come through the sidecar rather than being answerable + * on the platform side. + * + * A linear scan over a handful of live sessions. If that ever stops being true, add the inverse map — + * but a second copy of a mapping is a second thing to keep honest, and this one is written on every turn. + * Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants + * the session generating now, not the one that produced the same file yesterday. + */ +export function findSessionKeyByClaudeSession(claudeSessionId: string): string | undefined { + const keys = Object.keys(currentState.claudeSessions); + for (let i = keys.length - 1; i >= 0; i--) { + const key = keys[i]!; + if (currentState.claudeSessions[key] === claudeSessionId) return key; + } + return undefined; +} + function scheduleSave() { if (saveTimer) return; saveTimer = setTimeout(async () => { diff --git a/src/servers/sidecar/claude/stream-parser.test.ts b/src/servers/sidecar/claude/stream-parser.test.ts index 24a7266d..59e510ff 100644 --- a/src/servers/sidecar/claude/stream-parser.test.ts +++ b/src/servers/sidecar/claude/stream-parser.test.ts @@ -338,7 +338,10 @@ describe('parseStream', () => { expect(state.gotResult).toBe(true); expect(sessionIds).toEqual(['sess_1', 'sess_1']); - expect(events.map((e) => e.type)).toEqual(['delta', 'text', 'result']); + // `session` leads: the transcript id goes out at `system.init` so the URL is a permalink from the + // start of the turn, which is what makes a mid-turn refresh reattachable. + expect(events.map((e) => e.type)).toEqual(['session', 'delta', 'text', 'result']); + expect(events[0]).toEqual({ type: 'session', claudeSessionId: 'sess_1' }); }); test('handles chunked delivery (split mid-line)', async () => { diff --git a/src/servers/sidecar/claude/stream-parser.ts b/src/servers/sidecar/claude/stream-parser.ts index 6588aa1f..624808a0 100644 --- a/src/servers/sidecar/claude/stream-parser.ts +++ b/src/servers/sidecar/claude/stream-parser.ts @@ -148,7 +148,13 @@ function handleSystem(msg: Record, callbacks: StreamParserCallb const subtype = msg.subtype as string | undefined; if (subtype === 'init') { const sessionId = msg.session_id as string | undefined; - if (sessionId) callbacks.onSessionId(sessionId); + if (sessionId) { + callbacks.onSessionId(sessionId); + // Also out to the browser, and at the *start* of the turn. The same id used to travel only on + // `result`, so a chat had no address until its first turn had finished — refresh before that and + // there was nothing to reconnect by, which is exactly when a long turn is worth reconnecting to. + callbacks.onEvent({ type: 'session', claudeSessionId: sessionId }); + } } else if (subtype === 'task_started') { callbacks.onEvent({ type: 'task:started', diff --git a/src/servers/sidecar/claude/turn-stream.test.ts b/src/servers/sidecar/claude/turn-stream.test.ts index 536b4c8d..a255d36c 100644 --- a/src/servers/sidecar/claude/turn-stream.test.ts +++ b/src/servers/sidecar/claude/turn-stream.test.ts @@ -28,6 +28,27 @@ describe('createTurnStream', () => { expect(durable).toHaveLength(0); }); + test('the transcript id goes out live but is never persisted', () => { + // Durable would put a second copy of the answer inside the question: the only way to replay this log + // is to ask for it by the very id the event carries. + const { all, durable, types } = run([{ type: 'session', claudeSessionId: 'claude-uuid-1' }]); + expect(types).toEqual(['session:claude']); + expect(all[0]!.msg).toEqual({ type: 'session:claude', claudeSessionId: 'claude-uuid-1' }); + expect(durable).toHaveLength(0); + }); + + test('the transcript id does not disturb an open delta buffer', () => { + // It arrives at `system.init`, but a resumed turn can re-announce it mid-flight, and flushing there + // would split one paragraph into two messages. + const { durable } = run([ + { type: 'delta', text: 'half ' }, + { type: 'session', claudeSessionId: 'claude-uuid-1' }, + { type: 'delta', text: 'a sentence' }, + { type: 'result', cost: COST }, + ]); + expect(durable[0]).toEqual({ type: 'assistant:text', text: 'half a sentence' }); + }); + test('an explicit text event wins over the deltas that produced it', () => { const { durable } = run([ { type: 'delta', text: 'par' }, diff --git a/src/servers/sidecar/claude/turn-stream.ts b/src/servers/sidecar/claude/turn-stream.ts index 820b117f..583f53e0 100644 --- a/src/servers/sidecar/claude/turn-stream.ts +++ b/src/servers/sidecar/claude/turn-stream.ts @@ -46,6 +46,12 @@ export function createTurnStream(sessionId: string): TurnStream { const parent = ('parentToolUseId' in event ? event.parentToolUseId : undefined) ?? ''; switch (event.type) { + case 'session': + // Not durable: the id names the log rather than belonging in it, and a client replaying the log + // had to know the id to ask for it. Persisting it would put a second copy of the answer inside + // the question. + return [{ msg: { type: 'session:claude', claudeSessionId: event.claudeSessionId }, durable: false }]; + case 'delta': buffers.set(parent, (buffers.get(parent) ?? '') + event.text); return [{ msg: { type: 'assistant:delta', text: event.text, ...parented(parent) }, durable: false }]; diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index bf01495a..38ed54ca 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -2,7 +2,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { initPaths, loadState, flushAndSave, acquireLock, releaseLock, readProxySecretFromDisk } from './state'; +import { + initPaths, + loadState, + flushAndSave, + acquireLock, + releaseLock, + readProxySecretFromDisk, + findSessionKeyByClaudeSession, +} from './state'; import { createSessionLogStore } from './session-log'; import { setMcpConfigPath } from './claude-manager'; import * as claudeManager from './claude-manager'; @@ -194,6 +202,14 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) }); break; + case 'claude:find-session': + reply({ + type: 'claude:session-key', + id: cmd.id, + sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null, + }); + break; + case 'claude:clear-session': claudeManager.clearSession(cmd.sessionKey); sessionLog.drop(cmd.sessionKey); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 129bda53..e2b865a4 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -20,6 +20,9 @@ export type SidecarCommand = // Is a turn still running for this session? Only the process that owns the session can say, which is // exactly why it is asked over the wire — see `isClaudeGenerating` in sidecar-registry. | { type: 'claude:is-generating'; id: string; sessionKey: string } + // Which session key owns this transcript? The map lives on the agent's disk, so only it can answer — + // see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked. + | { type: 'claude:find-session'; id: string; claudeSessionId: string } // OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir) | { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams } | { type: 'opencode:kill'; id: string; sessionKey: string } @@ -47,6 +50,7 @@ export type SidecarEvent = | { type: 'claude:interrupted'; id: string } | { type: 'claude:session-cleared'; id: string } | { type: 'claude:generating'; id: string; generating: boolean } + | { type: 'claude:session-key'; id: string; sessionKey: string | null } // VNC | { type: 'vnc:started'; id: string; port: number; display: number } | { type: 'vnc:password'; id: string; password: string } diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index 0d379b75..ac79edbf 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -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. */ diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 8b8cd022..3d5eb1a4 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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(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(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 () => {