end a turn whose agent has gone

restarting officer-agent takes every persistent session with it and nothing
downstream notices: the browser's socket is healthy, officer's subscription is
a bus filter, and there is simply never another event. the spinner ran forever
and a refresh didn't help, because the transcript has no ending to read.

keyed off the agent *registering*, not disconnecting — a disconnect fires on
every `pm2 restart officer`, when the turn is fine. a registration socket dies
with its process, so an agent appearing on it is a new one. covers the sitting
tab; the reconnect path covers the rest, with the client now sending its belief
that a turn is in flight and officer checking it against the agent over a new
claude:is-generating. the check fails toward alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 01:43:01 +00:00
co-authored by Claude Opus 5
parent d8ee678ec4
commit 876b39b301
8 changed files with 173 additions and 3 deletions
+45
View File
@@ -65,9 +65,31 @@ export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarR
console.log(
`[registry] registered sidecar "${registration.name}" (id=${id}, capabilities=[${registration.capabilities.join(', ')}])`,
);
// A registration socket lives and dies with its process, so an agent showing up here is an agent that
// has just started — and whatever it was running before is gone. Announced rather than inferred from
// the *disconnect*, which is the wrong signal entirely: every `pm2 restart officer` drops these sockets
// while the sidecars, and their turns, carry on perfectly well.
if (registration.capabilities.includes('claude')) {
for (const handler of claudeRestartHandlers) {
try {
handler();
} catch (err) {
console.error('[registry] claude-restart handler failed', err);
}
}
}
return id;
}
const claudeRestartHandlers = new Set<() => void>();
/** Notified when the agent sidecar registers — i.e. when a new agent process has come up. */
export function onClaudeSidecarStarted(handler: () => void): () => void {
claudeRestartHandlers.add(handler);
return () => claudeRestartHandlers.delete(handler);
}
export function unregisterSidecar(id: string): void {
const sc = sidecars.get(id);
if (!sc) return;
@@ -273,6 +295,29 @@ export function interruptClaude(sessionKey: string): void {
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey });
}
/**
* Is the agent still running a turn for this session?
*
* Asked of the sidecar because it is the only party that can answer: officer's own memory of a turn dies
* with `pm2 restart officer` while the turn itself carries on, so "we don't remember it" is not evidence
* of anything. Used on reconnect to tell those two apart — a turn that outlived a restart from one whose
* process is gone and is never going to produce another token.
*
* Fails *toward alive*: no answer means we don't know, and wrongly telling someone their turn died while
* 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.
*/
export async function isClaudeGenerating(sessionKey: string): Promise<boolean> {
const sc = findSidecarByCapability('claude');
if (!sc) return false;
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey });
return res.type === 'claude:generating' ? res.generating : true;
} catch {
return true;
}
}
export function clearClaudeSession(sessionKey: string): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
}