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.
163 lines
6.2 KiB
TypeScript
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);
|