Files
platform/scripts/nudge-agent.ts
T
pastilhas adb77882b6 nudge: poke an idle chat session on a timer, never a working one
for an agent left running overnight. it is told to record blockers and carry
on 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.

a script rather than a queue job, deliberately. 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 — so this would be real work in the
queue engine AND the chat api, done tonight, in a repository another agent is
currently working in. instead it talks to the same two doors a browser does
and changes nothing in the platform. if the loop is worth keeping, it belongs
in the queue with a real trigger, and this is what that would replace.

the one rule is that it never interrupts work: `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.

`?all=1` on the session list, because without it the list is scoped to the
owner's home and an agent started in a project directory is simply absent —
which this would have read as "the session is gone" and stopped the night.

mints its own owner token: it runs on the box, as the owner, and a token
pasted into a shell is a token in shell history. `--max` defaults to 12, so a
loop nobody is watching ends on its own.
2026-08-20 02:53:34 +00:00

163 lines
6.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* Poke a chat session on a timer, but ONLY when it is idle.
*
* bun run nudge -- --session <id> --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<Session[]> {
// `?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 <id> 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<void> {
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);