diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index cd8f4dcc..78cd7aaa 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono'; import { createRouter } from '../../create-router'; +import * as sidecar from '@@/sidecar-registry'; import { getUserSettings } from 'officerdb'; import { getGeneralChatSessionsCwd, @@ -91,6 +92,18 @@ chatRouter.get('/sessions/:id', async (ctx) => { }); }); +// GET /chat/live — the sessions the agent has a process behind RIGHT NOW, as opposed to the transcripts +// on disk that `/chat/sessions` lists. Asked over the wire because only the agent can answer: officer's +// own session records are in memory and die with `pm2 restart officer`, while the agent is a PM2 peer +// and keeps running. Without this a surviving session is invisible until a browser reconnects to it by +// id, which is a thing you can only do if you already knew the id. +// +// `pendingTasks` is background work started but not yet notified — with `isGenerating` it is what the +// agent's own idle GC consults, so a caller can tell "busy" from "merely open" the same way it does. +chatRouter.get('/live', async (ctx) => { + return ctx.json({ sessions: await sidecar.listLiveClaudeSessions() }); +}); + // DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a // Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so // deleting it deletes one conversation. diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index 1f04343f..c6339b57 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -6,6 +6,7 @@ import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, + LiveClaudeSession, OpenCodeRunParams, VncStartParams, } from './sidecar/protocol'; @@ -307,6 +308,31 @@ export function interruptClaude(sessionKey: string): void { * it is still typing is worse than leaving a spinner up a little longer. Only a registered agent that * says "no", or no agent at all, counts as dead. */ +/** + * Every session the agent is holding IN MEMORY right now — distinct from `listClaudeSessions` in + * `api/chat/claude-sessions`, which lists conversations from transcripts on disk. Those are the history; + * these are the ones with a process behind them. + * + * Officer's own session records live in memory and die with `pm2 restart officer`, while the agent — a + * PM2 peer, not a child — keeps running and keeps committing to chat_session_events. Until this existed + * a surviving session was invisible: `adoptOrphanedSession` only fires when a browser reconnects to one + * *by id*, so nothing could answer "what is still running". + * + * Fails *toward empty*, unlike `isClaudeGenerating` which fails toward alive. The asymmetry is + * deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing + * sessions, and an enumeration that reports things that may not exist is worse than a short one. + */ +export async function listLiveClaudeSessions(): Promise { + const sc = findSidecarByCapability('claude'); + if (!sc) return []; + try { + const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId() }); + return res.type === 'claude: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/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index f3c050e3..15b067a3 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; import type { ChatEvent, PromptImage } from '../../api/chat/types'; -import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol'; +import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { createParseState, processMessage } from './stream-parser'; @@ -501,8 +501,19 @@ export function clearSession(sessionKey: string): void { clearClaudeSession(sessionKey); } -export function getActiveSessionKeys(): string[] { - return Array.from(sessions.keys()); +/** + * Everything this process is holding, with the two facts that decide whether it is busy. + * + * Replaces a `getActiveSessionKeys` that returned bare keys and was never called by anything — the keys + * alone could not distinguish a session mid-turn from one merely open, which is the whole question a + * caller has. These are the same two fields `armIdle` consults before collecting a session. + */ +export function listSessions(): LiveClaudeSession[] { + return Array.from(sessions.values()).map((session) => ({ + sessionKey: session.sessionKey, + isGenerating: session.isGenerating, + pendingTasks: session.pendingTasks.size, + })); } /** diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 603bf69f..1068a5a6 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -215,6 +215,10 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { reply({ type: 'claude:interrupted', id: cmd.id }); break; + case 'claude:list': + reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions() }); + break; + case 'claude:is-generating': reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) }); break; diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index e2b865a4..bc9d7d07 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -4,6 +4,19 @@ import type { MessageCost, PromptImage, TurnMessage } from '../api/chat/types'; export type SidecarMessage = SidecarCommand | SidecarEvent; +/** + * A session the agent is holding in memory right now — the ground truth about what is alive. + * + * `pendingTasks` is background work started but not yet notified, and it is the field worth having: + * 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. + */ +export type LiveClaudeSession = { + sessionKey: string; + isGenerating: boolean; + pendingTasks: number; +}; + // ── Commands (API server → sidecar) ── export type SidecarCommand = @@ -23,6 +36,11 @@ export type SidecarCommand = // Which session key owns this transcript? The map lives on the agent's disk, so only it can answer — // see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked. | { type: 'claude:find-session'; id: string; claudeSessionId: string } + // Everything the agent is holding right now. `claude:is-generating` answers for a session you can + // already name; this is for the case where officer has forgotten every name it had — its session + // records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it + // a live session is invisible until a browser happens to reconnect to it by id. + | { type: 'claude:list'; id: string } // 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 } @@ -51,6 +69,7 @@ export type SidecarEvent = | { type: 'claude:session-cleared'; id: string } | { type: 'claude:generating'; id: string; generating: boolean } | { type: 'claude:session-key'; id: string; sessionKey: string | null } + | { type: 'claude:sessions'; id: string; sessions: LiveClaudeSession[] } // VNC | { type: 'vnc:started'; id: string; port: number; display: number } | { type: 'vnc:password'; id: string; password: string }