import { join } from 'node:path'; import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); /** * A resumable session, and whose it is. * * `userId` exists because the map is global to this sidecar and `sessionKey` travels in a client message. The * pty sidecar learned this first (`4d4a253f`): *"re-attaching to a session belonging to another account is * refused, otherwise a member resumes someone else's shell by guessing an id that travels in a query * string."* Chat never got the same treatment, because both gates made it unreachable and therefore invisible. * * Stored rather than derived: there is nothing in a `sessionKey` or a Claude transcript uuid that says who * owns it, so ownership has to be written down at the moment it is created. */ export type SessionRecord = { userId: number; claudeSessionId: string }; export type PersistedState = { proxySecret: string; claudeSessions: Record; // sessionKey → whose, and which transcript }; const DEFAULT_STATE: PersistedState = { proxySecret: '', claudeSessions: {}, }; let stateDir: string; let stateFile: string; let lockFile: string; let currentState: PersistedState = { ...DEFAULT_STATE }; let saveTimer: Timer | null = null; /** Call once at startup to configure paths. For proxy: no email. For per-user: pass email. */ export function initPaths(email?: string): void { if (email) { stateDir = join(DATA_PATH, email, 'sidecar'); stateFile = join(stateDir, 'claude-state.json'); lockFile = join(stateDir, 'claude.lock'); } else { stateDir = join(DATA_PATH, 'sidecar'); stateFile = join(stateDir, 'claude-state.json'); lockFile = join(stateDir, 'claude.lock'); } } // Default to proxy paths initPaths(); function ensureDir() { if (!existsSync(stateDir)) { mkdirSync(stateDir, { recursive: true }); } } /** * @param ownerUserId who existing sessions belong to — see the migration below. Omitted by the proxy * process, which holds no sessions; legacy entries are then dropped rather than attributed to a guess. */ export function loadState(ownerUserId?: number): PersistedState { ensureDir(); try { if (!existsSync(stateFile)) { currentState = { ...DEFAULT_STATE }; return currentState; } const text = readFileSync(stateFile, 'utf-8'); currentState = { ...DEFAULT_STATE, ...JSON.parse(text) }; currentState.claudeSessions = migrateSessions(currentState.claudeSessions, ownerUserId); return currentState; } catch { currentState = { ...DEFAULT_STATE }; return currentState; } } /** * Entries used to be a bare `sessionKey → transcript uuid` string. Adopt those to the owner. * * Safe because it is a statement about the past rather than a guess: until this commit, `api/chat/chat.ts` * and the chat socket refused every non-owner, so nothing but the owner could ever have created one. The * alternative — dropping unrecognised entries — would silently lose the owner's resumable history on upgrade, * and "my old chats stopped resuming" is a bad way to discover a migration. */ function migrateSessions(raw: Record, ownerUserId?: number): Record { const out: Record = {}; for (const [key, value] of Object.entries(raw ?? {})) { if (typeof value === 'string') { if (ownerUserId !== undefined) out[key] = { userId: ownerUserId, claudeSessionId: value }; } else if (value && typeof value === 'object' && 'claudeSessionId' in value && 'userId' in value) { out[key] = value as SessionRecord; } // Anything else is unreadable and dropped: a malformed entry cannot be attributed to anyone, and // guessing an owner for it is exactly the mistake this whole change exists to stop. } return out; } export async function saveState(): Promise { ensureDir(); await Bun.write(stateFile, JSON.stringify(currentState, null, 2)); } export function getState(): PersistedState { return currentState; } export function updateState(patch: Partial): void { Object.assign(currentState, patch); scheduleSave(); } /** * Both of these write through instead of debouncing. * * This map is the only thing that lets a conversation be reattached with `--resume` after the agent * sidecar dies, so it is precisely the state that must not be in a 30s window when the process is * killed rather than asked to stop — `flushAndSave` on SIGTERM covers a `pm2 restart`, but not a * crash or a SIGKILL, which is the case the resume path exists for. Losing it silently starts the * next turn as a fresh Claude session and orphans the transcript on disk. * * There is nothing to debounce: `onSessionId` fires on every message but with the same id, so the * equality guard collapses it to one write per session, and clearing happens once. */ export function setClaudeSession(sessionKey: string, sessionId: string, userId: number): void { const existing = currentState.claudeSessions[sessionKey]; if (existing?.claudeSessionId === sessionId && existing.userId === userId) return; currentState.claudeSessions[sessionKey] = { userId, claudeSessionId: sessionId }; writeThrough(); } export function clearClaudeSession(sessionKey: string): void { if (!(sessionKey in currentState.claudeSessions)) return; delete currentState.claudeSessions[sessionKey]; writeThrough(); } function writeThrough(): void { void flushAndSave().catch((err) => { console.error('[claude-state] save failed:', err instanceof Error ? err.message : err); }); } /** * The transcript for this session key, **only if it belongs to the caller**. * * A mismatch returns undefined rather than throwing: to the caller it is simply "no session to resume", which * is the truthful answer — there is no session of theirs under that key. Throwing would confirm that somebody * else's exists, which is the one thing a guesser learns from. */ export function getClaudeSession(sessionKey: string, userId: number): string | undefined { const record = currentState.claudeSessions[sessionKey]; if (!record || record.userId !== userId) return undefined; return record.claudeSessionId; } /** * The same map read backwards: Claude's transcript uuid → the key officer made up for the session. * * A browser that has refreshed holds only the uuid, because that is what is in the URL; officer's own * key lived in page state and is gone. This is the only record anywhere that relates the two, which is * why re-binding a socket to a running turn has to come through the sidecar rather than being answerable * on the platform side. * * A linear scan over a handful of live sessions. If that ever stops being true, add the inverse map — * but a second copy of a mapping is a second thing to keep honest, and this one is written on every turn. * Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants * the session generating now, not the one that produced the same file yesterday. */ export function findSessionKeyByClaudeSession(claudeSessionId: string): string | undefined { const keys = Object.keys(currentState.claudeSessions); for (let i = keys.length - 1; i >= 0; i--) { const key = keys[i]!; if (currentState.claudeSessions[key]?.claudeSessionId === claudeSessionId) return key; } return undefined; } function scheduleSave() { if (saveTimer) return; saveTimer = setTimeout(async () => { saveTimer = null; await saveState(); }, 30_000); } export async function flushAndSave(): Promise { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } await saveState(); } /** * Read the Anthropic proxy secret out of the *proxy* sidecar's state file. * * The proxy (`officer-anthropic-proxy`) and the agent (`officer-agent`) keep separate state — see * `initPaths`: `DATA_PATH/sidecar/` versus `DATA_PATH//sidecar/` — so the agent cannot reach * the secret through `getState()`. It used to be handed the secret in env by the main server, and * needing that handoff is precisely why the agent had to be spawned by `officer` (and therefore died * with it). Reading it off disk keeps the two processes independent, with the proxy still the only * writer. * * Returns '' when the secret is not on disk yet: `ensureProxySecret` persists through a 30s debounce, * so a brand-new install has a window where the file exists without it. Callers should treat '' as * "retry later" rather than fatal. */ export function readProxySecretFromDisk(): string { try { const proxyStateFile = join(DATA_PATH, 'sidecar', 'claude-state.json'); if (!existsSync(proxyStateFile)) return ''; const parsed = JSON.parse(readFileSync(proxyStateFile, 'utf-8')) as Partial; return parsed.proxySecret ?? ''; } catch { return ''; } } // ── Lockfile ── export function acquireLock(): boolean { ensureDir(); try { if (existsSync(lockFile)) { const pidStr = readFileSync(lockFile, 'utf-8').trim(); const pid = Number(pidStr); if (pid && isProcessAlive(pid)) { return false; } } writeFileSync(lockFile, String(process.pid)); return true; } catch { return false; } } export function releaseLock(): void { try { if (existsSync(lockFile)) { unlinkSync(lockFile); } } catch { // best effort } } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }