Refreshing mid-turn appeared to kill the agent's output. It never did: the session survives a dropped socket, the agent keeps generating into it and keeps committing durable events, and `close` only detaches the socket and arms an hour-long idle timer. What broke was purely delivery — and the reconnect path that would have fixed it could not fire, because the browser came back having forgotten officer's session key. It lived in page state. The only id left was Claude's transcript uuid in the URL, and nothing accepted that. So accept it. `attach` carries the uuid, and the agent's on-disk session map — the single record relating the two — turns it back into the key everything else is written in terms of. The uuid now also goes out at `system.init` rather than only at `result`, which is what makes the first turn recoverable at all: until now a chat had no address until it had finished, and a long first turn is exactly the one worth reconnecting to. `sync:live` deliberately carries no messages. The harness writes its transcript as it goes, so the HTTP load on landing already supplies the past; sending the server's record of the same messages on top of it would duplicate them, and there is no shared id to reconcile the two by. Attach hands over the rest of the turn, the half-written paragraph the transcript cannot hold, and the session's cursor head — that last one so a *later* drop replays from the head instead of re-delivering the whole conversation from zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import type {
|
|
SidecarCommand,
|
|
SidecarEvent,
|
|
ClaudeState,
|
|
ClaudeSpawnParams,
|
|
ClaudeSpawnStreamingParams,
|
|
ClaudeCodeResult,
|
|
OpenCodeRunParams,
|
|
VncStartParams,
|
|
} from './sidecar/protocol';
|
|
import type { SidecarRegistration } from './sidecar/registration-protocol';
|
|
import type { TurnMessage } from './api/chat/types';
|
|
|
|
// ── Types ──
|
|
|
|
type RegisteredSidecar = {
|
|
id: string;
|
|
name: string;
|
|
capabilities: string[];
|
|
ws: ServerWebSocket<any>;
|
|
};
|
|
|
|
type PendingRequest = {
|
|
/** Which sidecar this request was sent to, so a disconnect only fails that sidecar's own work. */
|
|
sidecarId: string;
|
|
resolve: (value: any) => void;
|
|
reject: (error: Error) => void;
|
|
timer: Timer;
|
|
};
|
|
|
|
type EventHandler = (event: SidecarEvent) => void;
|
|
|
|
// ── State ──
|
|
|
|
const sidecars = new Map<string, RegisteredSidecar>();
|
|
const pending = new Map<string, PendingRequest>();
|
|
const eventHandlers = new Map<string, Set<EventHandler>>();
|
|
let cachedState: ClaudeState | null = null;
|
|
let idCounter = 0;
|
|
|
|
function nextId(): string {
|
|
return `sr_${Date.now()}_${++idCounter}`;
|
|
}
|
|
|
|
// ── Registration ──
|
|
|
|
export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarRegistration): string {
|
|
// If a sidecar with the same name is already registered, unregister it first
|
|
for (const [id, sc] of sidecars) {
|
|
if (sc.name === registration.name) {
|
|
console.log(`[registry] replacing existing sidecar "${registration.name}" (id=${id})`);
|
|
unregisterSidecar(id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const id = `sc_${registration.name}_${Date.now()}`;
|
|
sidecars.set(id, {
|
|
id,
|
|
name: registration.name,
|
|
capabilities: registration.capabilities,
|
|
ws,
|
|
});
|
|
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;
|
|
sidecars.delete(id);
|
|
console.log(`[registry] unregistered sidecar "${sc.name}" (id=${id})`);
|
|
|
|
// Reject the pending requests belonging to THIS sidecar, and only those. Until the pending entries
|
|
// carried a sidecarId this loop rejected the whole map, so restarting any one sidecar failed in-flight
|
|
// work on every other — `pm2 restart officer-music` could kill a running agent turn with the message
|
|
// `Sidecar "music" disconnected`, which is the sort of failure nobody traces back to its cause.
|
|
for (const [reqId, req] of pending) {
|
|
if (req.sidecarId !== id) continue;
|
|
clearTimeout(req.timer);
|
|
req.reject(new Error(`Sidecar "${sc.name}" disconnected`));
|
|
pending.delete(reqId);
|
|
}
|
|
|
|
// Clear cached state if the claude sidecar disconnects
|
|
if (sc.capabilities.includes('proxy')) {
|
|
cachedState = null;
|
|
}
|
|
}
|
|
|
|
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
|
// Check if this is a response to a pending request
|
|
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
|
const req = pending.get(msg.id)!;
|
|
pending.delete(msg.id);
|
|
clearTimeout(req.timer);
|
|
req.resolve(msg);
|
|
return;
|
|
}
|
|
|
|
// Otherwise dispatch as event
|
|
dispatchEvent(msg);
|
|
}
|
|
|
|
// ── Lookup ──
|
|
|
|
function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
|
|
for (const sc of sidecars.values()) {
|
|
if (sc.capabilities.includes(cap)) return sc;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ── Event dispatch ──
|
|
|
|
function dispatchEvent(msg: SidecarEvent) {
|
|
const handlers = eventHandlers.get(msg.type);
|
|
if (handlers) {
|
|
for (const handler of handlers) {
|
|
try {
|
|
handler(msg);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function on(eventType: string, handler: EventHandler): () => void {
|
|
if (!eventHandlers.has(eventType)) {
|
|
eventHandlers.set(eventType, new Set());
|
|
}
|
|
eventHandlers.get(eventType)!.add(handler);
|
|
return () => {
|
|
eventHandlers.get(eventType)?.delete(handler);
|
|
};
|
|
}
|
|
|
|
// ── Command sending ──
|
|
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
const LONG_TIMEOUT_MS = 6 * 60 * 1000;
|
|
|
|
function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const sc = findSidecarByCapability(cap);
|
|
if (!sc) {
|
|
reject(new Error(`No sidecar with capability "${cap}" is connected`));
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
pending.delete((cmd as any).id);
|
|
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
}, timeoutMs);
|
|
|
|
pending.set((cmd as any).id, { sidecarId: sc.id, resolve, reject, timer });
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
});
|
|
}
|
|
|
|
function sendFire(cap: string, cmd: SidecarCommand): void {
|
|
const sc = findSidecarByCapability(cap);
|
|
if (sc) {
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
}
|
|
}
|
|
|
|
function sendCommandToSidecar(
|
|
sc: RegisteredSidecar,
|
|
cmd: SidecarCommand,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete((cmd as any).id);
|
|
reject(new Error(`Sidecar command ${cmd.type} timed out`));
|
|
}, timeoutMs);
|
|
|
|
pending.set((cmd as any).id, { sidecarId: sc.id, resolve, reject, timer });
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
});
|
|
}
|
|
|
|
function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand): void {
|
|
sc.ws.send(JSON.stringify(cmd));
|
|
}
|
|
|
|
// ── Waiting for a sidecar to appear ──
|
|
|
|
// Officer no longer spawns any sidecar; PM2 owns every one of them. The only thing left to handle is
|
|
// startup order — PM2 brings `officer` and its peers up together, so the first request after a boot can
|
|
// arrive a beat before the sidecar has finished dialling in. Wait briefly rather than failing the
|
|
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
|
|
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
|
|
const CAPABILITY_WAIT_MS = 15_000;
|
|
const CAPABILITY_POLL_MS = 100;
|
|
|
|
async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
|
|
const existing = findSidecarByCapability(cap);
|
|
if (existing) return existing;
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(CAPABILITY_POLL_MS);
|
|
const sc = findSidecarByCapability(cap);
|
|
if (sc) return sc;
|
|
}
|
|
throw new Error(`No sidecar with capability "${cap}" registered within ${timeoutMs}ms`);
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
export function isConnected(): boolean {
|
|
return findSidecarByCapability('proxy') !== undefined;
|
|
}
|
|
|
|
export function getCachedState(): ClaudeState | null {
|
|
return cachedState;
|
|
}
|
|
|
|
export async function syncState(): Promise<ClaudeState> {
|
|
const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
|
|
if (res.type === 'state:sync') {
|
|
cachedState = res.state;
|
|
return res.state;
|
|
}
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export async function getProxySecret(): Promise<string> {
|
|
if (cachedState?.proxySecret) return cachedState.proxySecret;
|
|
const res = await sendCommand('proxy', { type: 'proxy:secret', id: nextId() });
|
|
if (res.type === 'proxy:secret') return res.secret;
|
|
throw new Error('Failed to get proxy secret');
|
|
}
|
|
|
|
export function getProxySecretSync(): string {
|
|
return cachedState?.proxySecret ?? '';
|
|
}
|
|
|
|
// ── Claude Code (the `officer-agent` sidecar, capability 'claude') ──
|
|
|
|
// Single-user platform, so there is exactly one agent sidecar and it is found by capability like every
|
|
// other one. The `email` on the params is still passed through to the sidecar — it needs it to resolve
|
|
// paths — but officer no longer uses it to *locate* anything.
|
|
|
|
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
|
const sc = await waitForCapability('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
|
|
if (res.type === 'claude:result') return res.result;
|
|
if (res.type === 'claude:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
|
|
const sc = await waitForCapability('claude');
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
|
|
if (res.type === 'claude:spawned') return;
|
|
if (res.type === 'claude:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function killClaude(sessionKey: string): void {
|
|
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
|
|
}
|
|
|
|
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Officer's session key for a Claude transcript uuid, or null if the agent has never seen it.
|
|
*
|
|
* The browser only ever has the uuid after a refresh — it is what the URL carries — and officer's own
|
|
* key is not derivable from it. The agent's on-disk map is the single record that relates them, so this
|
|
* is the hinge the whole reattach path turns on.
|
|
*
|
|
* Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls
|
|
* back to today's behaviour of leaving the socket unattached rather than binding it to a guess.
|
|
*/
|
|
export async function findClaudeSessionKey(claudeSessionId: string): Promise<string | null> {
|
|
const sc = findSidecarByCapability('claude');
|
|
if (!sc) return null;
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId });
|
|
return res.type === 'claude:session-key' ? res.sessionKey : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clearClaudeSession(sessionKey: string): void {
|
|
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
|
}
|
|
|
|
// Turn output arrives finished and already durable: the agent translated it and committed it to
|
|
// chat_session_events, and `seq` is its cursor id there. Officer relays it — it no longer builds or
|
|
// persists chat messages for this harness.
|
|
export function onClaudeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
|
|
return on('claude:message', (ev) => {
|
|
if (ev.type !== 'claude:message') return;
|
|
const msg = ev as SidecarEvent & { type: 'claude:message' };
|
|
handler(msg.sessionKey, msg.msg, msg.seq);
|
|
});
|
|
}
|
|
|
|
// ── OpenCode Code (single sidecar, capability 'opencode') ──
|
|
|
|
export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
|
|
const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
|
|
if (res.type === 'opencode:spawned') return;
|
|
if (res.type === 'opencode:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
export function killOpenCode(sessionKey: string): void {
|
|
sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
|
|
}
|
|
|
|
export function onOpenCodeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
|
|
return on('opencode:message', (ev) => {
|
|
if (ev.type !== 'opencode:message') return;
|
|
const msg = ev as SidecarEvent & { type: 'opencode:message' };
|
|
handler(msg.sessionKey, msg.msg, msg.seq);
|
|
});
|
|
}
|
|
|
|
export function onOpenCodeSession(handler: (sessionKey: string, sessionId: string) => void): () => void {
|
|
return on('opencode:session', (msg) => {
|
|
if (msg.type === 'opencode:session') {
|
|
handler(
|
|
(msg as SidecarEvent & { type: 'opencode:session' }).sessionKey,
|
|
(msg as SidecarEvent & { type: 'opencode:session' }).sessionId,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Terminal (PTY sidecar) ──
|
|
//
|
|
// Nothing here any more. The pty sidecar serves its own listener; `/api/terminal/*` is a proxy and
|
|
// `/api/terminal/ws` a byte relay, both keyed off the `pty:server` port like every other HTTP sidecar.
|
|
|
|
// ── VNC ──
|
|
|
|
export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> {
|
|
const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params });
|
|
if (res.type === 'vnc:started') return { port: res.port, display: res.display };
|
|
if (res.type === 'vnc:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response');
|
|
}
|
|
|
|
// Provision the VNC password without starting a server. The desktop UI needs it before it can open
|
|
// the WebSocket that would start one, so asking vnc:start here would be circular.
|
|
export async function ensureVncPassword(email: string): Promise<string> {
|
|
const res = await sendCommand('vnc', { type: 'vnc:ensure-password', id: nextId(), email });
|
|
if (res.type === 'vnc:password') return res.password;
|
|
if (res.type === 'vnc:error') throw new Error(res.error);
|
|
throw new Error('Unexpected response from VNC sidecar');
|
|
}
|
|
|
|
export function stopVnc(email: string): void {
|
|
sendFire('vnc', { type: 'vnc:stop', id: nextId(), email });
|
|
}
|
|
|
|
export function isVncConnected(): boolean {
|
|
return findSidecarByCapability('vnc') !== undefined;
|
|
}
|