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 }