import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb'; import * as sidecar from '@@/sidecar-registry'; import { resolveHomeDir } from '@@/user-home'; import { startClaudeTurn } from './websocket'; // Nudger: the schedule that keeps a chat moving while nobody is watching it. // // ── Why this can exist at all ── // // `startClaudeTurn` already takes `ws: null`. It was extracted that way so `POST /command-hq/commanders/new` // could drive a turn over HTTP, and that is the whole door this feature needed — a way into a live session // that is not the chat websocket. Nothing new is exposed; this is a second caller of an existing path. // // ── The rule that matters ── // // NEVER speak while the agent is working. A prompt delivered mid-turn is noise at best and a derail at // worst, and the point of this is to restart a stopped agent rather than hurry a busy one. // // So `every_seconds` is a FLOOR, not a metronome. When a nudge comes due and the session is generating, // the row is left alone and reconsidered on the next tick — which means it fires as soon as the turn ends // rather than sleeping through to the next hour. "At most every N seconds, and as soon as idle after // that" is what someone asking for this actually wants; a strict hourly alarm would skip an agent that // finished four minutes late and leave it idle for the other fifty-six. const TICK_MS = 60_000; type Safety = { safe: true } | { safe: false; reason: string; disable: boolean }; /** * May we speak into this session right now? * * ── The failure this replaced ── * * This used to ask one question, "is it generating", and treat NO as permission. That is exactly * backwards, and on 2026-08-20 it cost a working agent its afternoon: the target session had been * garbage-collected, so it was not generating, so the nudge fired — and `startClaudeTurn` given a session * the sidecar no longer holds does not resume it, it starts a NEW one. A second Claude with bypassed * permissions appeared in a repository another agent was mid-build in. Nothing was lost only because that * agent noticed the collision and stopped rather than committing over the work. * * So there are three questions, and only one answer is permission: * * - **Not live at all** → refuse, and disarm. Nudging it would CREATE an agent, which is not what a * schedule attached to a conversation means and never was. * - **Live but generating** → refuse, keep the schedule. It is working; that is the whole rule. * - **Another session live in the same directory** → refuse, keep the schedule. Two agents with * bypassed permissions in one working tree is not something to arrange on purpose. */ async function checkSafe(userId: number, sessionId: string): Promise { try { const live = await sidecar.listLiveClaudeSessions(userId); // Matched on BOTH ids on purpose. `sessionKey` is officer's handle for the conversation; // `claudeSessionId` is the transcript's own filename, and only the agent holds the mapping. A schedule // stored against one would silently never match a live row keyed by the other. const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId); if (!mine) { return { safe: false, reason: 'the session is no longer live — nudging it would start a NEW agent, not continue this one', disable: true, }; } if (mine.isGenerating) return { safe: false, reason: 'working', disable: false }; // Anything else alive belongs to another conversation. It may or may not share this one's directory — // the sidecar does not say — so this refuses on the presence of any other live session rather than on // a directory comparison it cannot actually make. const others = live.filter((s) => s !== mine).length; if (others > 0) { return { safe: false, reason: `${others} other live session(s) — refusing to add a second agent`, disable: false, }; } return { safe: true }; } catch { // Unknown is not permission. A sidecar that cannot answer might be running a turn, and interrupting one // is the failure this exists to avoid — so silence is the safe answer. return { safe: false, reason: 'sidecar did not answer', disable: false }; } } async function fire(nudge: { id: number; userId: number; sessionId: string; message: string }): Promise { const user = await getUserById(nudge.userId); const resolved = await resolveHomeDir(nudge.userId); if (!user || !resolved.ok) return; await new Promise((resolve) => { let settled = false; const done = () => { if (!settled) { settled = true; resolve(); } }; // A turn that never reports back must not wedge the tick — the next one will reconsider the row. const timer = setTimeout(done, 30_000); void startClaudeTurn({ email: user.email, username: user.username ?? user.email, userId: nudge.userId, // No socket. Whoever is watching the chat gets the turn through their own connection, the same way // they would see a turn started from another tab. ws: null, sessionId: nudge.sessionId, model: 'claude-code/sonnet', msg: { prompt: nudge.message, resumeSessionId: nudge.sessionId }, effectivePrompt: nudge.message, onOutcome: (outcome) => { clearTimeout(timer); if (!outcome.ok) console.error(`[nudger] session ${nudge.sessionId}: ${outcome.error}`); done(); }, }).catch((err) => { clearTimeout(timer); console.error(`[nudger] session ${nudge.sessionId} threw:`, err instanceof Error ? err.message : err); done(); }); }); // Counted whether or not the turn reported success. A nudge that failed to start still consumed its slot; // retrying it immediately would be a tight loop against whatever refused it. await recordNudgeSent(nudge.id); } let timer: ReturnType | null = null; /** * Start the tick. Called once from `server.tsx`. * * One tick at a time — `running` guards against a slow tick overlapping the next, which would double-fire * a due nudge. `unref` so this never holds the process open on its own. */ export function startNudger(): void { if (timer) return; let running = false; timer = setInterval(async () => { if (running) return; running = true; try { for (const nudge of await dueNudges()) { const check = await checkSafe(nudge.userId, nudge.sessionId); if (!check.safe) { if (check.disable) { console.warn(`[nudger] session ${nudge.sessionId} disarmed — ${check.reason}`); await cancelNudge(nudge.userId, nudge.sessionId); } continue; // otherwise reconsidered next tick } console.log(`[nudger] session ${nudge.sessionId} idle — nudging (${nudge.sentCount + 1}/${nudge.maxCount})`); await fire(nudge); } } catch (err) { // A bad tick must not stop every later one. console.error('[nudger] tick failed:', err instanceof Error ? err.message : err); } finally { running = false; } }, TICK_MS); timer.unref?.(); console.log('[nudger] armed — checking every 60s'); }