ask the agent what it is still running

Officer's session records are in memory and die with `pm2 restart officer`, while the agent is a PM2
peer and keeps generating. `adoptOrphanedSession` rebuilds a binding — but only when a browser
reconnects to a session *by id*, which you can only do if you already knew the id. So a session that
survived a restart was invisible, and nothing could answer "what is running right now".

`claude:list` returns each live session with `isGenerating` and `pendingTasks` — the same two fields the
agent's own `armIdle` consults before collecting a session, so a caller can tell "busy" from "merely
open" the way it does. Surfaced as `GET /chat/live`, which sits beside `/chat/sessions`: those are
transcripts on disk, these are the ones with a process behind them.

`getActiveSessionKeys` is replaced rather than joined. It returned bare keys, could not distinguish a
session mid-turn from one merely open, and had never been called by anything.

`listLiveClaudeSessions` fails toward EMPTY, where `isClaudeGenerating` beside it fails toward alive.
The asymmetry is deliberate: not knowing there means leaving a spinner up, and not knowing here would
mean inventing sessions.

Step 2 of docs/chat-session-lifetime.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 00:35:11 +01:00
co-authored by Claude Opus 5
parent be266da9e2
commit a75bf7a283
5 changed files with 76 additions and 3 deletions
+13
View File
@@ -1,5 +1,6 @@
import type { Context } from 'hono'; import type { Context } from 'hono';
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import * as sidecar from '@@/sidecar-registry';
import { getUserSettings } from 'officerdb'; import { getUserSettings } from 'officerdb';
import { import {
getGeneralChatSessionsCwd, 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 // 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 // Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
// deleting it deletes one conversation. // deleting it deletes one conversation.
+26
View File
@@ -6,6 +6,7 @@ import type {
ClaudeSpawnParams, ClaudeSpawnParams,
ClaudeSpawnStreamingParams, ClaudeSpawnStreamingParams,
ClaudeCodeResult, ClaudeCodeResult,
LiveClaudeSession,
OpenCodeRunParams, OpenCodeRunParams,
VncStartParams, VncStartParams,
} from './sidecar/protocol'; } 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 * 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. * 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<LiveClaudeSession[]> {
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<boolean> { export async function isClaudeGenerating(sessionKey: string): Promise<boolean> {
const sc = findSidecarByCapability('claude'); const sc = findSidecarByCapability('claude');
if (!sc) return false; if (!sc) return false;
+14 -3
View File
@@ -3,7 +3,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import type { ChatEvent, PromptImage } from '../../api/chat/types'; 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 { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { createParseState, processMessage } from './stream-parser'; import { createParseState, processMessage } from './stream-parser';
@@ -501,8 +501,19 @@ export function clearSession(sessionKey: string): void {
clearClaudeSession(sessionKey); 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,
}));
} }
/** /**
@@ -215,6 +215,10 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
reply({ type: 'claude:interrupted', id: cmd.id }); reply({ type: 'claude:interrupted', id: cmd.id });
break; break;
case 'claude:list':
reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions() });
break;
case 'claude:is-generating': case 'claude:is-generating':
reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) }); reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) });
break; break;
+19
View File
@@ -4,6 +4,19 @@ import type { MessageCost, PromptImage, TurnMessage } from '../api/chat/types';
export type SidecarMessage = SidecarCommand | SidecarEvent; 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) ── // ── Commands (API server → sidecar) ──
export type SidecarCommand = 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 — // 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. // see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked.
| { type: 'claude:find-session'; id: string; claudeSessionId: string } | { 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) // 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:run-streaming'; id: string; params: OpenCodeRunParams }
| { type: 'opencode:kill'; id: string; sessionKey: string } | { type: 'opencode:kill'; id: string; sessionKey: string }
@@ -51,6 +69,7 @@ export type SidecarEvent =
| { type: 'claude:session-cleared'; id: string } | { type: 'claude:session-cleared'; id: string }
| { type: 'claude:generating'; id: string; generating: boolean } | { type: 'claude:generating'; id: string; generating: boolean }
| { type: 'claude:session-key'; id: string; sessionKey: string | null } | { type: 'claude:session-key'; id: string; sessionKey: string | null }
| { type: 'claude:sessions'; id: string; sessions: LiveClaudeSession[] }
// VNC // VNC
| { type: 'vnc:started'; id: string; port: number; display: number } | { type: 'vnc:started'; id: string; port: number; display: number }
| { type: 'vnc:password'; id: string; password: string } | { type: 'vnc:password'; id: string; password: string }