resolve live sessions by claude's id, not the agent's session key
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// `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.
|
// 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,
|
// Titles are resolved here rather than in the client, which can only name the sessions in the group it
|
||||||
// and the client can only name the sessions in the group it happens to be browsing — which is how the
|
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
|
||||||
// list ended up showing raw ids for anything running elsewhere.
|
|
||||||
chatRouter.get('/live', async (ctx) => {
|
chatRouter.get('/live', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const live = await sidecar.listLiveClaudeSessions();
|
const live = await sidecar.listLiveClaudeSessions();
|
||||||
const sessions = live.map((session) => {
|
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 { ...session, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
|
||||||
});
|
});
|
||||||
return ctx.json({ sessions });
|
return ctx.json({ sessions });
|
||||||
|
|||||||
@@ -511,6 +511,9 @@ export function clearSession(sessionKey: string): void {
|
|||||||
export function listSessions(): LiveClaudeSession[] {
|
export function listSessions(): LiveClaudeSession[] {
|
||||||
return Array.from(sessions.values()).map((session) => ({
|
return Array.from(sessions.values()).map((session) => ({
|
||||||
sessionKey: session.sessionKey,
|
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,
|
isGenerating: session.isGenerating,
|
||||||
pendingTasks: session.pendingTasks.size,
|
pendingTasks: session.pendingTasks.size,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export type SidecarMessage = SidecarCommand | SidecarEvent;
|
|||||||
*/
|
*/
|
||||||
export type LiveClaudeSession = {
|
export type LiveClaudeSession = {
|
||||||
sessionKey: string;
|
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;
|
isGenerating: boolean;
|
||||||
pendingTasks: number;
|
pendingTasks: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,16 +40,16 @@ export const LiveSessions = () => {
|
|||||||
return (
|
return (
|
||||||
<DataList>
|
<DataList>
|
||||||
{live.map((session) => {
|
{live.map((session) => {
|
||||||
// No title means no transcript on disk yet — a conversation whose first turn has not landed. It
|
// The route opens Claude's id, not the agent's session key — they are different things and only
|
||||||
// is shown but not linked: a row that navigates to a session you cannot open is worse than one
|
// the agent holds the mapping. No id yet means a conversation whose first turn has not landed:
|
||||||
// that plainly isn't a link.
|
// shown, but deliberately not a link, since there is nothing to open.
|
||||||
const isDraft = session.title === null;
|
const transcriptId = session.claudeSessionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataRow
|
<DataRow
|
||||||
key={session.sessionKey}
|
key={session.sessionKey}
|
||||||
to={isDraft ? undefined : chatSessionPath(session.sessionKey)}
|
to={transcriptId ? chatSessionPath(transcriptId) : undefined}
|
||||||
selected={session.sessionKey === sessionId}
|
selected={transcriptId === sessionId}
|
||||||
title={session.title ?? 'Starting…'}
|
title={session.title ?? 'Starting…'}
|
||||||
meta={[
|
meta={[
|
||||||
session.isGenerating ? (
|
session.isGenerating ? (
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
*/
|
*/
|
||||||
export type LiveSession = {
|
export type LiveSession = {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
|
/**
|
||||||
|
* Claude's own id, which is what `/chat/:sessionId` opens — distinct from `sessionKey`, officer's
|
||||||
|
* handle for the same conversation. Null before the first turn has reported one.
|
||||||
|
*/
|
||||||
|
claudeSessionId: string | null;
|
||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
/** Background tasks started but not yet notified — `run_in_background`, Monitor, and friends. */
|
/** Background tasks started but not yet notified — `run_in_background`, Monitor, and friends. */
|
||||||
pendingTasks: number;
|
pendingTasks: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user