reported from the field on the first real push: it failed with BadDeviceToken and the server deleted the registration. the build was production-signed and the row said sandbox — a configuration mismatch, with the device and the app both perfectly fine. pruning there destroys the evidence. the phone re-registers on next launch, the row comes back looking healthy, and the next send fails identically; you can lose a day to that loop without ever seeing what is actually wrong. so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune — apple and google are saying the app is uninstalled. BadDeviceToken, DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check. everything else is transient and still counts toward three strikes. INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed MESSAGE — including a non-string in `data`, which is the mistake this codebase is most likely to make and already has a test for. deleting on it meant one bug in the payload builder would wipe every android device from the registry on a single send, presenting as "android push stopped working" with nothing left to explain why. misconfigured is also not counted as a strike. not deleting is not enough on its own — a persistent mismatch would reach three failures and delete the row anyway, which is the same bug with a slower fuse. the apns log now names the mismatch rather than the reason code, since the code alone sends people to look at the device: it prints the row's environment and topic and says to check the SHIPPED artefact rather than the intermediate build, which is exactly where this one hid.
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import type {
|
|
SidecarCommand,
|
|
SidecarEvent,
|
|
ClaudeState,
|
|
ClaudeSpawnParams,
|
|
ClaudeSpawnStreamingParams,
|
|
ClaudeCodeResult,
|
|
LiveClaudeSession,
|
|
LiveOpenCodeSession,
|
|
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;
|
|
handles: 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,
|
|
handles: registration.handles,
|
|
ws,
|
|
});
|
|
console.log(
|
|
`[registry] registered sidecar "${registration.name}" (id=${id}, handles=[${registration.handles.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.handles.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.handles.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 findSidecarHandling(kind: string): RegisteredSidecar | undefined {
|
|
for (const sc of sidecars.values()) {
|
|
if (sc.handles.includes(kind)) 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(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const sc = findSidecarHandling(kind);
|
|
if (!sc) {
|
|
reject(new Error(`No sidecar handling "${kind}" 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(kind: string, cmd: SidecarCommand): void {
|
|
const sc = findSidecarHandling(kind);
|
|
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 HANDLER_WAIT_MS = 15_000;
|
|
const HANDLER_POLL_MS = 100;
|
|
|
|
async function waitForHandler(kind: string, timeoutMs = HANDLER_WAIT_MS): Promise<RegisteredSidecar> {
|
|
const existing = findSidecarHandling(kind);
|
|
if (existing) return existing;
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(HANDLER_POLL_MS);
|
|
const sc = findSidecarHandling(kind);
|
|
if (sc) return sc;
|
|
}
|
|
throw new Error(`No sidecar handling "${kind}" registered within ${timeoutMs}ms`);
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
export function isConnected(): boolean {
|
|
return findSidecarHandling('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, handling 'claude') ──
|
|
|
|
// Single-user platform, so there is exactly one agent sidecar and it is found by what it handles, 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 waitForHandler('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 waitForHandler('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, userId: number): void {
|
|
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
|
|
}
|
|
|
|
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
|
export function interruptClaude(sessionKey: string, userId: number): void {
|
|
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
|
|
}
|
|
|
|
// Plan mode, both directions. Fire-and-forget on purpose: the agent's answer to either one comes back
|
|
// through the session log as a `plan:mode` / `plan:decided` message, which is durable, ordered with the
|
|
// rest of the turn, and delivered to every socket watching — none of which a command reply would be.
|
|
export function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): void {
|
|
sendFire('claude', { type: 'claude:set-plan-mode', id: nextId(), sessionKey, userId, enabled });
|
|
}
|
|
|
|
export function decideClaudePlan(params: {
|
|
sessionKey: string;
|
|
userId: number;
|
|
requestId: string;
|
|
approve: boolean;
|
|
feedback?: string;
|
|
}): void {
|
|
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
|
}
|
|
|
|
// The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the
|
|
// visible result is the tool's own output arriving in the durable log, where every socket watching this
|
|
// conversation sees it — including the one that did not click.
|
|
export function answerClaudeQuestion(params: {
|
|
sessionKey: string;
|
|
userId: number;
|
|
toolCallId: string;
|
|
answer: string;
|
|
}): void {
|
|
sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/**
|
|
* 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(userId: number): Promise<LiveClaudeSession[]> {
|
|
const sc = findSidecarHandling('claude');
|
|
if (!sc) return [];
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
|
|
return res.type === 'claude:sessions' ? res.sessions : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<LiveOpenCodeSession[]> {
|
|
const sc = findSidecarHandling('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, userId: number): Promise<boolean> {
|
|
const sc = findSidecarHandling('claude');
|
|
if (!sc) return false;
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
|
|
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, userId: number): Promise<string | null> {
|
|
const sc = findSidecarHandling('claude');
|
|
if (!sc) return null;
|
|
try {
|
|
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
|
|
return res.type === 'claude:session-key' ? res.sessionKey : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clearClaudeSession(sessionKey: string, userId: number): void {
|
|
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey, userId });
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
// See `PersistentSession.notify` in claude-manager.ts — a separate, non-durable ping, not part of the
|
|
// transcript the message above relays.
|
|
export function onClaudeNotify(
|
|
handler: (sessionKey: string, userId: number, kind: 'turn-ended' | 'input-needed') => void,
|
|
): () => void {
|
|
return on('claude:notify', (ev) => {
|
|
if (ev.type !== 'claude:notify') return;
|
|
handler(ev.sessionKey, ev.userId, ev.kind);
|
|
});
|
|
}
|
|
|
|
// ── OpenCode Code (single sidecar, handling '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 findSidecarHandling('vnc') !== undefined;
|
|
}
|