diff --git a/package.json b/package.json index 1606f46c..031bedc2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"", "gen:index": "bun run ./scripts/gen-index.ts", "fix:env": "bun run ./scripts/fix-env.ts", + "nudge": "bun run --env-file=.env ./scripts/nudge-agent.ts", "doctor": "bun run --env-file=.env ./scripts/doctor.ts", "secret:set": "bun run --env-file=.env ./scripts/secret-set.ts", "notify:test": "bun run --env-file=.env ./scripts/notify-test.ts", diff --git a/scripts/nudge-agent.ts b/scripts/nudge-agent.ts new file mode 100644 index 00000000..7f146b84 --- /dev/null +++ b/scripts/nudge-agent.ts @@ -0,0 +1,162 @@ +#!/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);