show running opencode turns in the live panel

The Live panel asked `claude:list` and nothing else, so a running OpenCode turn was invisible — the panel
claimed to show what the agent is doing and silently omitted half of it.

Adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in parallel,
each failing toward empty so one sidecar being down contributes nothing rather than breaking the panel.

The OpenCode row is deliberately thinner than the Claude one rather than faked into parity:

  isGenerating  always true — a subprocess exists only while it generates, so there is no "merely open"
  pendingTasks  always 0    — `opencode run` has no background-task concept; reporting a number would
                              suggest a capability that does not exist
  title / cwd   null        — the session store is keyed on the `ses_…` id the runner reports, not on
                              our sessionKey, so an unreported turn shows unnamed rather than guessed

This is the incremental option from docs/opencode-serve-path.md — enumeration without moving turns onto
the serve, so it buys the Live panel with no warm sessions, no SSE loop and no lifetime questions.

NOT verified end to end: no OpenCode turn was running to enumerate, so the verb is wired and typechecked
but has never returned a non-empty list. See COMMS/BLOCKERS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 03:00:43 +00:00
co-authored by Claude Opus 5
parent 8b409e8af8
commit e8bd946272
5 changed files with 76 additions and 4 deletions
+24 -3
View File
@@ -105,16 +105,37 @@ chatRouter.get('/sessions/:id', async (ctx) => {
// 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();
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
// both registry calls swallow their errors and return [].
const [live, liveOpenCode] = await Promise.all([
sidecar.listLiveClaudeSessions(),
sidecar.listLiveOpenCodeSessions(),
]);
const sessions = live.map((session) => {
// 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, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
});
return ctx.json({ sessions });
// 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. Title and cwd come from the session store, which is keyed on the
// `ses_…` id the runner reports — not on our sessionKey — so a turn whose id has not been reported yet
// shows unnamed rather than guessing.
const openCodeSessions = liveOpenCode.map((session) => ({
sessionKey: session.sessionKey,
claudeSessionId: null,
isGenerating: true,
pendingTasks: 0,
harness: 'opencode' as const,
title: null,
cwd: null,
}));
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
});
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a