diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index d29113f5..efffece4 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -21,6 +21,7 @@ import { renameOpenCodeSession, isOpenCodeSessionId, } from './opencode-sessions'; +import { getOpenCodeSession } from './opencode/state'; import { listChatModels } from './list-models'; import { logger } from './logger'; import { readSttConfig } from '../server-settings/stt'; @@ -121,24 +122,41 @@ chatRouter.get('/live', async (ctx) => { }); // OpenCode rows carry less, and the shape says so rather than faking parity. `isGenerating` is always - // true because a subprocess exists only while it generates; `pendingTasks` is 0 because `opencode run` - // has no background-task concept. + // true because a subprocess exists only while it generates, and `pendingTasks` is 0 because + // `opencode run` has no background-task concept — a number there would imply one. // - // Title and cwd are ALWAYS null here, not merely null-until-known: naming a session means reading - // OpenCode's store, which is keyed on the `ses_…` id, and `LiveOpenCodeSession` does not carry one — - // the runner reports it separately over `opencode:session` and nothing correlates the two. So a - // running OpenCode turn shows up unnamed, every time. Giving it a title means widening that type - // first; guessing from `sessionKey` would be inventing one. - const openCodeSessions = liveOpenCode.map((session) => ({ + // Naming them needs a second hop. The sidecar reports only its own `sessionKey`, because that is all it + // has; OpenCode's store is keyed on `ses_…`, which the runner reports separately over `opencode:session` + // and officer records in `opencode/state.ts`. So the correlation lives HERE, and it is the reason these + // rows used to be permanently unnamed — the two halves existed and nothing joined them. + const withIds = liveOpenCode.map((session) => ({ sessionKey: session.sessionKey, - claudeSessionId: null, - isGenerating: true, - pendingTasks: 0, - harness: 'opencode' as const, - title: null, - cwd: null, + openCodeId: getOpenCodeSession(session.sessionKey) ?? null, })); + // One list call names every row, rather than one transcript load each — `loadOpenCodeSession` rebuilds + // a whole conversation to read its title. Skipped entirely when nothing is running or nothing has + // reported an id yet, so an idle Live panel never touches the serve. Failure degrades to unnamed: + // `listOpenCodeSessions` already swallows and returns [], which matches how the rest of this route + // fails. + const named = withIds.some((r) => r.openCodeId) ? await listOpenCodeSessions() : []; + const byId = new Map(named.map((s) => [s.id, s])); + + const openCodeSessions = withIds.map(({ sessionKey, openCodeId }) => { + const meta = openCodeId ? byId.get(openCodeId) : undefined; + return { + sessionKey, + claudeSessionId: openCodeId, + isGenerating: true, + pendingTasks: 0, + harness: 'opencode' as const, + // Still null for the first seconds of a turn: the id is only known once the subprocess prints it. + // That window is real and short, and showing nothing beats showing a key the user has never seen. + title: meta?.title ?? null, + cwd: meta?.cwd || null, + }; + }); + return ctx.json({ sessions: [...sessions, ...openCodeSessions] }); }); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 198c9cac..dea40a5b 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -29,9 +29,11 @@ export type LiveClaudeSession = { * An OpenCode turn in flight. Only ever the generating ones — see `listRunningOpenCodeTurns` for why * this carries neither `isGenerating` (it is always true) nor `pendingTasks` (no such concept). * - * It also carries no id for OpenCode's own `ses_…` session, which is why a live OpenCode row cannot be - * named: the runner reports that id separately, over `opencode:session`, and nothing correlates the two - * back here. Adding it is what a titled OpenCode row in the Live panel would need. + * It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar + * only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session` + * and recorded by officer in `api/chat/opencode/state.ts`. `/chat/live` joins the two on officer's side, + * which is where both halves already are — widening this type would mean sending the sidecar a fact it + * had told officer in the first place. */ export type LiveOpenCodeSession = { sessionKey: string;