name the live opencode rows

They were permanently unnamed, and the two halves needed to name them already existed on
officer: the sidecar reports its own sessionKey because that is all it has, while the ses_ id
arrives separately over opencode:session and is recorded in opencode/state.ts. Nothing joined
them. /chat/live joins them now, so no protocol or sidecar change — widening
LiveOpenCodeSession would have meant sending the sidecar a fact it told officer in the first
place.

One list call names every row rather than one transcript load each, and it is skipped when
nothing is running or no id has been reported, so an idle Live panel never touches the serve.

Verified against a real turn, which also showed the design working as intended: the first
poll has no id yet and shows nothing, the next shows title and cwd. That window is real and
short, and showing nothing beats showing a key the user has never seen.

Worth knowing: opencode titles its own sessions "New session - <ISO timestamp>", so the row
is located but not meaningfully named. That is genuinely its title, not a bug here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:42:15 +01:00
co-authored by Claude Opus 5
parent 1892c23aef
commit 9118a9f76c
2 changed files with 37 additions and 17 deletions
+32 -14
View File
@@ -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] });
});
+5 -3
View File
@@ -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;