#!/usr/bin/env bun /** * Poke a chat session on a timer, but ONLY when it is idle. * * bun run nudge -- --session --every 3600 --message "Continue. If blocked, write it down and move on." * bun run nudge -- --list # which sessions exist, and which are working * * ── What this is for ── * * An agent left running overnight. It is told to record blockers and continue rather than wait for a human, * and mostly it does — but a turn that ends leaves it idle until someone types, and at 3am nobody does. * This is the someone. * * ── Why it is a script and not a queue job ── * * The platform has no recurring scheduler and no way to put a prompt into a live session: `pushTurn` has * exactly one caller, the chat websocket. Adding both is real work in the queue engine and the chat API, * and doing it tonight would mean editing a repository an agent is currently working in. * * So this talks to the same doors a browser does — `GET /api/chat/sessions` to see the state, then the chat * websocket to speak. Nothing new is exposed and nothing in the platform changes. If the loop is ever worth * making permanent, it belongs in the queue with a real trigger, and this script is what that would replace. * * ── The one rule ── * * NEVER interrupt work. A nudge sent mid-turn is at best noise and at worst a derail, so `isGenerating` is * checked immediately before speaking and a working session is skipped silently. The point is to restart a * stopped agent, not to hurry a busy one. */ import { sign } from '@@/jwt'; import { getOwnerUser } from 'officerdb'; const arg = (name: string): string | undefined => { const i = process.argv.indexOf(`--${name}`); return i === -1 ? undefined : process.argv[i + 1]; }; const has = (name: string) => process.argv.includes(`--${name}`); const PORT = process.env.OFFICERDEV_PORT ?? process.env.PORT ?? '9000'; const BASE = `http://127.0.0.1:${PORT}`; const owner = await getOwnerUser(); if (!owner) { console.error('[nudge] no owner account — nothing to sign as'); process.exit(1); } // Minted here rather than asked for: this runs on the box, as the owner, and a token pasted into a shell // is a token in the shell history. const token = await sign({ id: owner.id, email: owner.email, username: owner.username }, '2d'); type Session = { id: string; title?: string; isGenerating?: boolean; updatedAt?: string }; async function sessions(): Promise { // `?all=1` — every working directory, not just the default one. Without it the list is scoped to the // owner's home and an agent started in a project directory is simply absent, which reads as "the session // is gone" and stops the loop. This is the list's own Latest tab, so the shape is identical. const res = await fetch(`${BASE}/api/chat/sessions?all=1`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000), }); if (!res.ok) throw new Error(`GET /api/chat/sessions → ${res.status}`); const body = (await res.json()) as { sessions?: Session[] } | Session[]; return Array.isArray(body) ? body : (body.sessions ?? []); } if (has('list')) { for (const s of await sessions()) { console.log(`${s.isGenerating ? 'WORKING ' : 'idle '} ${s.id} ${s.title ?? ''}`); } process.exit(0); } const sessionId = arg('session'); const message = arg('message') ?? 'Continue with the next task. If you are blocked, write the blocker down and move on.'; const every = Number(arg('every') ?? 3600); /** A ceiling, because a loop nobody is watching should end on its own. Twelve hours by default. */ const max = Number(arg('max') ?? 12); if (!sessionId) { console.error('[nudge] --session is required. Use --list to find one.'); process.exit(2); } /** Send one prompt, the way the browser does. Resolves once the server acknowledges the turn. */ function nudge(): Promise { return new Promise((resolve, reject) => { const proto = 'ws'; const ws = new WebSocket(`${proto}://127.0.0.1:${PORT}/api/chat/ws?token=${token}&sessionId=${sessionId}`); const done = (err?: Error) => { try { ws.close(); } catch { /* already closing */ } err ? reject(err) : resolve(); }; const timer = setTimeout(() => done(new Error('no acknowledgement within 30s')), 30_000); ws.onopen = () => ws.send(JSON.stringify({ type: 'chat', prompt: message, sessionId })); ws.onmessage = (ev) => { // Anything that is not an error means the turn was taken. We do not wait for it to FINISH — that is // the agent's night, not ours. try { const msg = JSON.parse(String(ev.data)) as { type?: string; message?: string }; if (msg.type === 'error') { clearTimeout(timer); return done(new Error(msg.message ?? 'server refused the prompt')); } if (msg.type === 'assistant:delta' || msg.type === 'assistant:text' || msg.type === 'tool:start') { clearTimeout(timer); return done(); } } catch { /* not json — ignore */ } }; ws.onerror = () => { clearTimeout(timer); done(new Error('websocket error')); }; }); } const stamp = () => new Date().toISOString().slice(11, 19); console.log(`[nudge] session ${sessionId} — every ${every}s, at most ${max} times`); console.log(`[nudge] message: ${message}`); for (let sent = 0, tick = 0; sent < max; tick++) { if (tick > 0) await Bun.sleep(every * 1000); let state: Session | undefined; try { state = (await sessions()).find((s) => s.id === sessionId); } catch (err) { console.error(`[nudge ${stamp()}] could not read sessions: ${err instanceof Error ? err.message : err}`); continue; // a server restart is not a reason to give up on the night } if (!state) { console.error(`[nudge ${stamp()}] session ${sessionId} is gone — stopping`); break; } if (state.isGenerating) { console.log(`[nudge ${stamp()}] working — skipped`); continue; } try { await nudge(); sent++; console.log(`[nudge ${stamp()}] idle — nudged (${sent}/${max})`); } catch (err) { console.error(`[nudge ${stamp()}] failed: ${err instanceof Error ? err.message : err}`); } } console.log(`[nudge ${stamp()}] finished`); process.exit(0);