every surface with no model picker was quietly running opus. the fallback was the bare harness, `claude-code`, and bare means the CLI picks its own tier — agent-files.ts says so in as many words. so a plugin's chat panel, a nudge, a task with no configured default, any chat opened where there is nowhere to choose: all opus, for work that rarely needs it. the commander pinned to sonnet on 2026-08-25 after spending ~$0.26 of opus on one routing decision was the same problem noticed in one place. DEFAULT_MODEL could not simply be changed, because it is two questions wearing one constant. decideResume uses it as a HARNESS IDENTITY when neither side knows which sidecar a resuming session belongs to, and that comparison is between harnesses — a tier suffix there would be comparing something else, and the three failures its comment records are all delivery failures. so it stays bare and keeps its one job, now stated. CHAT_DEFAULT_MODEL is the other question, answered separately. a floor, not a ceiling: a client that names a model still wins, an account default still wins, and only "nobody chose" changes. tasks get the same treatment for the same reason and a stronger one — a task runs unattended, often repeatedly, with nobody watching the bill. the nudger held a copy of the string; it holds the constant now. interim on purpose. per-surface defaults, or something that reads the work rather than the caller, is the better answer and is not this. one constant, one place, easy to move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
139 lines
6.3 KiB
TypeScript
139 lines
6.3 KiB
TypeScript
import { dueNudges, recordNudgeSent, getUserById } from 'officerdb';
|
|
import { resolveHomeDir } from '@@/user-home';
|
|
import { startClaudeTurn, CHAT_DEFAULT_MODEL } 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;
|
|
|
|
/**
|
|
* There is no safety check any more, and that is the point.
|
|
*
|
|
* ── What was here ──
|
|
*
|
|
* Three refusals: the session is not live, the session is generating, or anything else is live on the
|
|
* machine. Written on 2026-08-20 after a nudge fired at a garbage-collected session and a second Claude
|
|
* with bypassed permissions appeared in a repository another agent was mid-build in.
|
|
*
|
|
* ── Why all three are gone ──
|
|
*
|
|
* **Not live** was fixed elsewhere and the guard outlived it. `fire()` passes `resumeSessionId`, and
|
|
* `claude-manager` resolves `getClaudeSession(sessionKey) ?? params.resumeSessionId` into the SDK's
|
|
* `resume` — so a collected session resumes its own transcript rather than starting a new agent. That is
|
|
* what a schedule attached to a conversation always meant.
|
|
*
|
|
* **Anything else live** refused on the presence of any agent anywhere, because the sidecar does not report
|
|
* working directories and the check could not tell "another agent in this conversation" from "an unrelated
|
|
* chat". On a machine running commanders that is always true, so no nudge could ever fire — silently: the
|
|
* row stayed enabled, `sentCount` stayed 0, and whatever scheduled it reported success. It was a ban on the
|
|
* feature wearing the clothes of a safety check.
|
|
*
|
|
* **Generating** was the last one, and it went for a better reason than the others: it was refusing to do
|
|
* something that already works. Each session is one long-lived `query()` driven by a STREAMING INPUT QUEUE
|
|
* (`claude-manager.ts` → "Persistent streaming sessions"), and a turn is one message pushed onto it. A
|
|
* message sent to a busy session is not rejected and not lost — it is queued, and delivered when the
|
|
* current turn ends. Waiting is fine; disappearing is not, and refusing here meant disappearing.
|
|
*
|
|
* ── What this costs, stated plainly ──
|
|
*
|
|
* Nudging conversation A while conversation B is live in the same working tree still puts two agents in one
|
|
* directory. That is real and it is the owner's call, made 2026-08-25: a message that must arrive is worth
|
|
* more than a guard that could only ever be right by accident, since it never knew the directories either.
|
|
* Fixing it properly needs the sidecar to report each session's cwd.
|
|
*/
|
|
|
|
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,
|
|
// The constant, not a copy of its value — a nudge is an unattended turn and belongs with every
|
|
// other surface that never had a picker.
|
|
model: CHAT_DEFAULT_MODEL,
|
|
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()) {
|
|
console.log(`[nudger] session ${nudge.sessionId} — 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');
|
|
}
|