import { dueNudges, recordNudgeSent, getUserById } from 'officerdb'; import { resolveHomeDir } from '@@/user-home'; import { startClaudeTurn, CHAT_DEFAULT_MODEL } 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; /** * There is no safety check any more, and that is the point. * * ── What was here ── * * Three refusals: the session is not live, the session is generating, or anything else is live on the * machine. Written on 2026-08-20 after a nudge fired at a garbage-collected session and a second Claude * with bypassed permissions appeared in a repository another agent was mid-build in. * * ── Why all three are gone ── * * **Not live** was fixed elsewhere and the guard outlived it. `fire()` passes `resumeSessionId`, and * `claude-manager` resolves `getClaudeSession(sessionKey) ?? params.resumeSessionId` into the SDK's * `resume` — so a collected session resumes its own transcript rather than starting a new agent. That is * what a schedule attached to a conversation always meant. * * **Anything else live** refused on the presence of any agent anywhere, because the sidecar does not report * working directories and the check could not tell "another agent in this conversation" from "an unrelated * chat". On a machine running commanders that is always true, so no nudge could ever fire — silently: the * row stayed enabled, `sentCount` stayed 0, and whatever scheduled it reported success. It was a ban on the * feature wearing the clothes of a safety check. * * **Generating** was the last one, and it went for a better reason than the others: it was refusing to do * something that already works. Each session is one long-lived `query()` driven by a STREAMING INPUT QUEUE * (`claude-manager.ts` → "Persistent streaming sessions"), and a turn is one message pushed onto it. A * message sent to a busy session is not rejected and not lost — it is queued, and delivered when the * current turn ends. Waiting is fine; disappearing is not, and refusing here meant disappearing. * * ── What this costs, stated plainly ── * * Nudging conversation A while conversation B is live in the same working tree still puts two agents in one * directory. That is real and it is the owner's call, made 2026-08-25: a message that must arrive is worth * more than a guard that could only ever be right by accident, since it never knew the directories either. * Fixing it properly needs the sidecar to report each session's cwd. */ 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, // The constant, not a copy of its value — a nudge is an unattended turn and belongs with every // other surface that never had a picker. model: CHAT_DEFAULT_MODEL, 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()) { console.log(`[nudger] session ${nudge.sessionId} — 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'); }