From 5a7591124a855d38b6f5272104c7a37753293a57 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 01:23:14 +0100 Subject: [PATCH] resolve live sessions by claude's id, not the agent's session key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every live row read 'Starting…' and linked nowhere, because the title lookup searched for a transcript named after the session KEY. It isn't. The key is officer's handle for a conversation; the transcript is named after Claude's own session id, and the mapping between them exists only inside the agent (setClaudeSession/getClaudeSession). I assumed the two were the same and never checked — confirmed wrong by looking for the ids from the officer log under ~/.claude/projects and finding nothing. claude:list now reports claudeSessionId beside the key, the route resolves titles by that, and rows link to it. Null means the first turn has not reported one yet, which is a genuinely unwritten conversation and stays unlinked. Needs the agent sidecar restarted to take effect — the new field comes from there. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 11 +++++++---- src/servers/sidecar/claude/claude-manager.ts | 3 +++ src/servers/sidecar/protocol.ts | 8 ++++++++ .../officerdev/src/apps/ChatHistory/LiveSessions.tsx | 12 ++++++------ src/workspaces/state/src/useLiveSessions.ts | 5 +++++ 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 77b81878..7a86f154 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -101,14 +101,17 @@ chatRouter.get('/sessions/:id', async (ctx) => { // // `pendingTasks` is background work started but not yet notified — with `isGenerating` it is what the // agent's own idle GC consults, so a caller can tell "busy" from "merely open" the same way it does. -// Titles are resolved here rather than in the client: the agent reports session keys and nothing else, -// and the client can only name the sessions in the group it happens to be browsing — which is how the -// list ended up showing raw ids for anything running elsewhere. +// Titles are resolved here rather than in the client, which can only name the sessions in the group it +// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere. chatRouter.get('/live', async (ctx) => { const email = ctx.get('user').email; const live = await sidecar.listLiveClaudeSessions(); const sessions = live.map((session) => { - const resolved = isOpenCodeSessionId(session.sessionKey) ? null : liveSessionTitle(email, session.sessionKey); + // Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript + // is named after Claude's. Null until the first turn reports one, which is a conversation that has + // genuinely not been written yet. + const transcriptId = session.claudeSessionId; + const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(email, transcriptId) : null; return { ...session, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null }; }); return ctx.json({ sessions }); diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 15b067a3..3930ca96 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -511,6 +511,9 @@ export function clearSession(sessionKey: string): void { export function listSessions(): LiveClaudeSession[] { return Array.from(sessions.values()).map((session) => ({ sessionKey: session.sessionKey, + // The only place this mapping exists. Without it a caller cannot find the transcript, because the + // key is officer's handle and the filename is Claude's id. + claudeSessionId: getClaudeSession(session.sessionKey) ?? null, isGenerating: session.isGenerating, pendingTasks: session.pendingTasks.size, })); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index bc9d7d07..09bf2237 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -13,6 +13,14 @@ export type SidecarMessage = SidecarCommand | SidecarEvent; */ export type LiveClaudeSession = { sessionKey: string; + /** + * Claude's own session id — the transcript's filename — or null before the first turn reports one. + * + * NOT the same as `sessionKey`, which is officer's handle for the conversation. Only the agent holds + * the mapping (`setClaudeSession`/`getClaudeSession`), so anything that wants to find the transcript + * has to be told it here. Assuming the two were equal made every live row unnameable and unlinkable. + */ + claudeSessionId: string | null; isGenerating: boolean; pendingTasks: number; }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx index d01b5ec7..c74f7f52 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/LiveSessions.tsx @@ -40,16 +40,16 @@ export const LiveSessions = () => { return ( {live.map((session) => { - // No title means no transcript on disk yet — a conversation whose first turn has not landed. It - // is shown but not linked: a row that navigates to a session you cannot open is worse than one - // that plainly isn't a link. - const isDraft = session.title === null; + // The route opens Claude's id, not the agent's session key — they are different things and only + // the agent holds the mapping. No id yet means a conversation whose first turn has not landed: + // shown, but deliberately not a link, since there is nothing to open. + const transcriptId = session.claudeSessionId; return (