it fired once, in anger, and put a second claude with bypassed permissions
into a repository another agent was mid-build in. nothing was lost only
because that agent noticed the collision and stopped rather than committing
over the work. it also found a real bug while it was there, which is not a
defence of any of this.
the guard was exactly backwards. it asked one question — "is this session
generating" — and treated NO as permission. but a session the sidecar no
longer holds is also not generating, and `startClaudeTurn` given one of those
does not resume it, it starts a NEW one. so the check that was meant to
prevent interference is the thing that guaranteed a second agent, and the
likeliest state for an overnight target — garbage collected after 30 idle
minutes — is precisely the dangerous one.
three questions now, and only one answer is permission:
not live at all refuse AND disarm. nudging it would create an
agent, which is not what a schedule attached to a
conversation means and never was.
live but generating refuse, keep the schedule. it is working.
any other live session refuse, keep the schedule. two agents with bypassed
permissions in one tree is not something to arrange
on purpose.
the third is blunter than it needs to be — the sidecar does not report a
session's directory, so it refuses on ANY other live session rather than on a
comparison it cannot make. that will decline some safe nudges. given what the
precise version cost, the blunt one is the right trade until the sidecar can
say where a session is running.
169 lines
7.2 KiB
TypeScript
169 lines
7.2 KiB
TypeScript
import { dueNudges, recordNudgeSent, cancelNudge, getUserById } from 'officerdb';
|
|
import * as sidecar from '@@/sidecar-registry';
|
|
import { resolveHomeDir } from '@@/user-home';
|
|
import { startClaudeTurn } 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;
|
|
|
|
type Safety = { safe: true } | { safe: false; reason: string; disable: boolean };
|
|
|
|
/**
|
|
* May we speak into this session right now?
|
|
*
|
|
* ── The failure this replaced ──
|
|
*
|
|
* This used to ask one question, "is it generating", and treat NO as permission. That is exactly
|
|
* backwards, and on 2026-08-20 it cost a working agent its afternoon: the target session had been
|
|
* garbage-collected, so it was not generating, so the nudge fired — and `startClaudeTurn` given a session
|
|
* the sidecar no longer holds does not resume it, it starts a NEW one. A second Claude with bypassed
|
|
* permissions appeared in a repository another agent was mid-build in. Nothing was lost only because that
|
|
* agent noticed the collision and stopped rather than committing over the work.
|
|
*
|
|
* So there are three questions, and only one answer is permission:
|
|
*
|
|
* - **Not live at all** → refuse, and disarm. Nudging it would CREATE an agent, which is not what a
|
|
* schedule attached to a conversation means and never was.
|
|
* - **Live but generating** → refuse, keep the schedule. It is working; that is the whole rule.
|
|
* - **Another session live in the same directory** → refuse, keep the schedule. Two agents with
|
|
* bypassed permissions in one working tree is not something to arrange on purpose.
|
|
*/
|
|
async function checkSafe(userId: number, sessionId: string): Promise<Safety> {
|
|
try {
|
|
const live = await sidecar.listLiveClaudeSessions(userId);
|
|
// Matched on BOTH ids on purpose. `sessionKey` is officer's handle for the conversation;
|
|
// `claudeSessionId` is the transcript's own filename, and only the agent holds the mapping. A schedule
|
|
// stored against one would silently never match a live row keyed by the other.
|
|
const mine = live.find((s) => s.sessionKey === sessionId || s.claudeSessionId === sessionId);
|
|
|
|
if (!mine) {
|
|
return {
|
|
safe: false,
|
|
reason: 'the session is no longer live — nudging it would start a NEW agent, not continue this one',
|
|
disable: true,
|
|
};
|
|
}
|
|
if (mine.isGenerating) return { safe: false, reason: 'working', disable: false };
|
|
|
|
// Anything else alive belongs to another conversation. It may or may not share this one's directory —
|
|
// the sidecar does not say — so this refuses on the presence of any other live session rather than on
|
|
// a directory comparison it cannot actually make.
|
|
const others = live.filter((s) => s !== mine).length;
|
|
if (others > 0) {
|
|
return {
|
|
safe: false,
|
|
reason: `${others} other live session(s) — refusing to add a second agent`,
|
|
disable: false,
|
|
};
|
|
}
|
|
|
|
return { safe: true };
|
|
} catch {
|
|
// Unknown is not permission. A sidecar that cannot answer might be running a turn, and interrupting one
|
|
// is the failure this exists to avoid — so silence is the safe answer.
|
|
return { safe: false, reason: 'sidecar did not answer', disable: false };
|
|
}
|
|
}
|
|
|
|
async function fire(nudge: { id: number; userId: number; sessionId: string; message: string }): Promise<void> {
|
|
const user = await getUserById(nudge.userId);
|
|
const resolved = await resolveHomeDir(nudge.userId);
|
|
if (!user || !resolved.ok) return;
|
|
|
|
await new Promise<void>((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,
|
|
model: 'claude-code/sonnet',
|
|
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<typeof setInterval> | 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()) {
|
|
const check = await checkSafe(nudge.userId, nudge.sessionId);
|
|
if (!check.safe) {
|
|
if (check.disable) {
|
|
console.warn(`[nudger] session ${nudge.sessionId} disarmed — ${check.reason}`);
|
|
await cancelNudge(nudge.userId, nudge.sessionId);
|
|
}
|
|
continue; // otherwise reconsidered next tick
|
|
}
|
|
console.log(`[nudger] session ${nudge.sessionId} idle — 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');
|
|
}
|