From e8bd946272d01f9716de89b10c78d155f97e27de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 10 Aug 2026 03:00:43 +0000 Subject: [PATCH] show running opencode turns in the live panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/chat/chat.ts | 27 +++++++++++++++++++++++--- src/servers/sidecar-registry.ts | 19 ++++++++++++++++++ src/servers/sidecar/opencode/index.ts | 6 +++++- src/servers/sidecar/opencode/runner.ts | 15 ++++++++++++++ src/servers/sidecar/protocol.ts | 13 +++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 7a86f154..08c7d89e 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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 diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index c6339b57..6a615162 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -7,6 +7,7 @@ import type { ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession, + LiveOpenCodeSession, OpenCodeRunParams, VncStartParams, } from './sidecar/protocol'; @@ -333,6 +334,24 @@ export async function listLiveClaudeSessions(): Promise { } } +/** + * The OpenCode turns running right now, from the sidecar's own map. + * + * Fails toward EMPTY, matching `listLiveClaudeSessions` and for the same reason: an enumeration that + * invents sessions is worse than a short one. A sidecar that is down or does not understand the verb + * (an older build) simply contributes nothing to the Live panel rather than breaking it. + */ +export async function listLiveOpenCodeSessions(): Promise { + const sc = findSidecarByCapability('opencode'); + if (!sc) return []; + try { + const res = await sendCommandToSidecar(sc, { type: 'opencode:list', id: nextId() }); + return res.type === 'opencode:sessions' ? res.sessions : []; + } catch { + return []; + } +} + export async function isClaudeGenerating(sessionKey: string): Promise { const sc = findSidecarByCapability('claude'); if (!sc) return false; diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts index a6aa4016..e0437f9d 100644 --- a/src/servers/sidecar/opencode/index.ts +++ b/src/servers/sidecar/opencode/index.ts @@ -5,7 +5,7 @@ import { DATA_PATH } from '../../data-path'; import { createSidecarConnector } from '../connect'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { runOpenCodeTurn, killOpenCodeTurn } from './runner'; +import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns } from './runner'; // The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that // OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It @@ -147,6 +147,10 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { reply({ type: 'opencode:spawned', id: cmd.id, sessionKey }); break; } + case 'opencode:list': + reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningOpenCodeTurns() }); + break; + case 'opencode:kill': killOpenCodeTurn(cmd.sessionKey); sessionLog.drop(cmd.sessionKey); diff --git a/src/servers/sidecar/opencode/runner.ts b/src/servers/sidecar/opencode/runner.ts index 7fefb315..d2ea5a25 100644 --- a/src/servers/sidecar/opencode/runner.ts +++ b/src/servers/sidecar/opencode/runner.ts @@ -200,6 +200,21 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, }); } +/** + * The turns this process is running right now. + * + * The OpenCode analog of `claude-manager.listSessions`, and deliberately thinner. Claude holds a warm + * session that outlives a turn, so it can report one that is merely open; OpenCode spawns a subprocess + * per turn and has nothing between them. So a session appears here only while it is generating — which + * is exactly the state the Live panel exists to show, and the state that was invisible for OpenCode. + * + * No `pendingTasks`: `opencode run` has no background-task concept, so reporting 0 would suggest a + * capability that does not exist rather than an empty one. + */ +export function listRunningOpenCodeTurns(): { sessionKey: string }[] { + return Array.from(running.keys()).map((sessionKey) => ({ sessionKey })); +} + export function killOpenCodeTurn(sessionKey: string): void { const handle = running.get(sessionKey); if (!handle) return; diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 09bf2237..ad05f5ae 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -11,6 +11,14 @@ export type SidecarMessage = SidecarCommand | SidecarEvent; * together with `isGenerating` it is exactly what the agent's own idle GC consults before deciding a * session may be collected, so a caller can tell "busy" from "merely open" the same way it does. */ +/** + * 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). + */ +export type LiveOpenCodeSession = { + sessionKey: string; +}; + export type LiveClaudeSession = { sessionKey: string; /** @@ -52,6 +60,10 @@ export type SidecarCommand = // OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir) | { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams } | { type: 'opencode:kill'; id: string; sessionKey: string } + // Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a + // reason: OpenCode has no warm session between turns, so there is nothing to report but the running + // ones. See `listRunningOpenCodeTurns`. + | { type: 'opencode:list'; id: string } // VNC | { type: 'vnc:start'; id: string; params: VncStartParams } // Provision the VNC password without starting a server — the UI needs it before it can connect @@ -78,6 +90,7 @@ export type SidecarEvent = | { type: 'claude:generating'; id: string; generating: boolean } | { type: 'claude:session-key'; id: string; sessionKey: string | null } | { type: 'claude:sessions'; id: string; sessions: LiveClaudeSession[] } + | { type: 'opencode:sessions'; id: string; sessions: LiveOpenCodeSession[] } // VNC | { type: 'vnc:started'; id: string; port: number; display: number } | { type: 'vnc:password'; id: string; password: string }