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:
@@ -85,6 +85,15 @@ export type ClientMessage =
|
|||||||
// the current turn. Frees the session so its transcript can be resumed elsewhere.
|
// the current turn. Frees the session so its transcript can be resumed elsewhere.
|
||||||
type: 'disconnect';
|
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
|
// 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
|
// since `cursor` (the last seq the client saw). Powers transparent reconnect without losing
|
||||||
@@ -120,6 +129,15 @@ export type ServerMessage =
|
|||||||
context?: string;
|
context?: string;
|
||||||
contextId?: 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';
|
type: 'assistant:text';
|
||||||
text: string;
|
text: string;
|
||||||
@@ -158,6 +176,26 @@ export type ServerMessage =
|
|||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
streamingText: string;
|
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';
|
type: 'error';
|
||||||
message: string;
|
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
|
// 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.
|
// contiguous replay from one with a hole in it. Absent when the writer cannot vouch for it.
|
||||||
export type TurnMessageType =
|
export type TurnMessageType =
|
||||||
|
| 'session:claude'
|
||||||
| 'assistant:delta'
|
| 'assistant:delta'
|
||||||
| 'assistant:text'
|
| 'assistant:text'
|
||||||
| 'tool:start'
|
| 'tool:start'
|
||||||
@@ -209,6 +248,7 @@ export type TurnMessageType =
|
|||||||
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
|
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
|
||||||
|
|
||||||
export type ChatEvent =
|
export type ChatEvent =
|
||||||
|
| { type: 'session'; claudeSessionId: string }
|
||||||
| ({ type: 'text'; text: string } & Parented)
|
| ({ type: 'text'; text: string } & Parented)
|
||||||
| ({ type: 'delta'; text: string } & Parented)
|
| ({ type: 'delta'; text: string } & Parented)
|
||||||
| ({
|
| ({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-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 { mkdirSync } from 'node:fs';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
@@ -130,6 +130,8 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
|
|||||||
await handleDisconnect(ws);
|
await handleDisconnect(ws);
|
||||||
} else if (clientMsg.type === 'resume-cursor') {
|
} else if (clientMsg.type === 'resume-cursor') {
|
||||||
await handleResumeCursor(ws, clientMsg);
|
await handleResumeCursor(ws, clientMsg);
|
||||||
|
} else if (clientMsg.type === 'attach') {
|
||||||
|
await handleAttach(ws, clientMsg);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(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);
|
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.
|
* The client came back still believing a turn is running. Check whether it is, and if it isn't, say so.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 {
|
export function clearClaudeSession(sessionKey: string): void {
|
||||||
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ export function getClaudeSession(sessionKey: string): string | undefined {
|
|||||||
return currentState.claudeSessions[sessionKey];
|
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() {
|
function scheduleSave() {
|
||||||
if (saveTimer) return;
|
if (saveTimer) return;
|
||||||
saveTimer = setTimeout(async () => {
|
saveTimer = setTimeout(async () => {
|
||||||
|
|||||||
@@ -338,7 +338,10 @@ describe('parseStream', () => {
|
|||||||
|
|
||||||
expect(state.gotResult).toBe(true);
|
expect(state.gotResult).toBe(true);
|
||||||
expect(sessionIds).toEqual(['sess_1', 'sess_1']);
|
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 () => {
|
test('handles chunked delivery (split mid-line)', async () => {
|
||||||
|
|||||||
@@ -148,7 +148,13 @@ function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallb
|
|||||||
const subtype = msg.subtype as string | undefined;
|
const subtype = msg.subtype as string | undefined;
|
||||||
if (subtype === 'init') {
|
if (subtype === 'init') {
|
||||||
const sessionId = msg.session_id as string | undefined;
|
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') {
|
} else if (subtype === 'task_started') {
|
||||||
callbacks.onEvent({
|
callbacks.onEvent({
|
||||||
type: 'task:started',
|
type: 'task:started',
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ describe('createTurnStream', () => {
|
|||||||
expect(durable).toHaveLength(0);
|
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', () => {
|
test('an explicit text event wins over the deltas that produced it', () => {
|
||||||
const { durable } = run([
|
const { durable } = run([
|
||||||
{ type: 'delta', text: 'par' },
|
{ type: 'delta', text: 'par' },
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export function createTurnStream(sessionId: string): TurnStream {
|
|||||||
const parent = ('parentToolUseId' in event ? event.parentToolUseId : undefined) ?? '';
|
const parent = ('parentToolUseId' in event ? event.parentToolUseId : undefined) ?? '';
|
||||||
|
|
||||||
switch (event.type) {
|
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':
|
case 'delta':
|
||||||
buffers.set(parent, (buffers.get(parent) ?? '') + event.text);
|
buffers.set(parent, (buffers.get(parent) ?? '') + event.text);
|
||||||
return [{ msg: { type: 'assistant:delta', text: event.text, ...parented(parent) }, durable: false }];
|
return [{ msg: { type: 'assistant:delta', text: event.text, ...parented(parent) }, durable: false }];
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
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 { createSessionLogStore } from './session-log';
|
||||||
import { setMcpConfigPath } from './claude-manager';
|
import { setMcpConfigPath } from './claude-manager';
|
||||||
import * as claudeManager 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) });
|
reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'claude:find-session':
|
||||||
|
reply({
|
||||||
|
type: 'claude:session-key',
|
||||||
|
id: cmd.id,
|
||||||
|
sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
case 'claude:clear-session':
|
case 'claude:clear-session':
|
||||||
claudeManager.clearSession(cmd.sessionKey);
|
claudeManager.clearSession(cmd.sessionKey);
|
||||||
sessionLog.drop(cmd.sessionKey);
|
sessionLog.drop(cmd.sessionKey);
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// exactly why it is asked over the wire — see `isClaudeGenerating` in sidecar-registry.
|
||||||
| { type: 'claude:is-generating'; id: string; sessionKey: string }
|
| { 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)
|
// 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:run-streaming'; id: string; params: OpenCodeRunParams }
|
||||||
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
||||||
@@ -47,6 +50,7 @@ export type SidecarEvent =
|
|||||||
| { type: 'claude:interrupted'; id: string }
|
| { type: 'claude:interrupted'; id: string }
|
||||||
| { type: 'claude:session-cleared'; id: string }
|
| { type: 'claude:session-cleared'; id: string }
|
||||||
| { type: 'claude:generating'; id: string; generating: boolean }
|
| { type: 'claude:generating'; id: string; generating: boolean }
|
||||||
|
| { type: 'claude:session-key'; id: string; sessionKey: string | null }
|
||||||
// VNC
|
// VNC
|
||||||
| { type: 'vnc:started'; id: string; port: number; display: number }
|
| { type: 'vnc:started'; id: string; port: number; display: number }
|
||||||
| { type: 'vnc:password'; id: string; password: string }
|
| { type: 'vnc:password'; id: string; password: string }
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ export type ServerMessage =
|
|||||||
// which addresses nothing after the socket closes.
|
// which addresses nothing after the socket closes.
|
||||||
| { type: 'result'; sessionId: string; cost: MessageCost; claudeSessionId?: string }
|
| { type: 'result'; sessionId: string; cost: MessageCost; claudeSessionId?: string }
|
||||||
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: 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: 'error'; message: string; errorCode?: string }
|
||||||
| { type: 'stopped' }
|
| { type: 'stopped' }
|
||||||
/** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */
|
/** The agent process went away mid-turn. Recoverable — see the `cutoff` message role. */
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ type UsePiChatOptions = {
|
|||||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
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) {
|
export function useChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) {
|
||||||
const {
|
const {
|
||||||
replaceUrl = true,
|
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.
|
// Mirror for the reconnect handshake, which runs from a stable callback and cannot read state.
|
||||||
const isGeneratingRef = useRef(false);
|
const isGeneratingRef = useRef(false);
|
||||||
isGeneratingRef.current = isGenerating;
|
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();
|
const client = useClient();
|
||||||
|
|
||||||
@@ -230,6 +253,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
setCwd(msg.cwd);
|
setCwd(msg.cwd);
|
||||||
break;
|
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':
|
case 'assistant:delta':
|
||||||
// A subagent's deltas are deliberately not streamed. Two speakers cannot share one cursor, and the
|
// 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.
|
// 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': {
|
case 'result': {
|
||||||
// Make the address bar a permalink. This runs on `result`, not `session:init`, because the id
|
// Belt and braces for the permalink: `session:claude` normally got here first, but a harness that
|
||||||
// there is officer's per-connection key (`msg.sessionId || randomUUID()`), which
|
// never emitted an init (or a turn relayed by another sidecar) still reports the uuid at the end.
|
||||||
// `/chat/sessions/:id` cannot resolve — only the turn reports the real transcript uuid.
|
// Deliberately not keyed off officer's `sessionId`, which is a per-connection key
|
||||||
//
|
// (`msg.sessionId || randomUUID()`) that `/chat/sessions/:id` cannot resolve.
|
||||||
// The permalink is bare: no group, and `?cwd=` is stripped rather than carried. The transcript
|
if (msg.claudeSessionId) {
|
||||||
// records its own cwd and the server resolves it from the id, so naming the group again could
|
claudeSessionIdRef.current = msg.claudeSessionId;
|
||||||
// only ever contradict it — which is what a hand-edited or stale `?cwd=` used to do. Anything
|
if (replaceUrl) writePermalink(msg.claudeSessionId);
|
||||||
// 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}` : ''}`);
|
|
||||||
}
|
}
|
||||||
commitStreaming();
|
commitStreaming();
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
@@ -313,6 +339,25 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
break;
|
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': {
|
case 'sync:messages': {
|
||||||
sessionIdRef.current = msg.sessionId;
|
sessionIdRef.current = msg.sessionId;
|
||||||
setSessionId(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
|
// `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
|
// 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.
|
// 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 onOpen = useCallback(() => {
|
||||||
const sid = sessionIdRef.current;
|
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({
|
sendRef.current({
|
||||||
type: 'resume-cursor',
|
type: 'resume-cursor',
|
||||||
sessionId: sid,
|
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.
|
// transcript just stops. The server checks the claim with the agent and answers if it's false.
|
||||||
generating: isGeneratingRef.current,
|
generating: isGeneratingRef.current,
|
||||||
});
|
});
|
||||||
}, []);
|
}, [sendAttach]);
|
||||||
|
|
||||||
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });
|
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen });
|
||||||
sendRef.current = send;
|
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
|
// Clean up RAF on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user