nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a turn, and at 3am nobody types the next message. this is the schedule that does. the door already existed. `startClaudeTurn` takes `ws: null` — extracted that way so command-hq could drive a turn over http — so this is a second caller of an existing path rather than a new way into a live session. i had said the platform lacked that; it did not. the rule that decides the design: never speak while the agent is working. 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 next tick, which means it fires as soon as the turn ends rather than sleeping through to the next hour. a strict alarm would skip an agent that finished four minutes late and leave it idle for the other fifty-six. `isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be mid-turn, and interrupting one is the failure this exists to avoid. matched on both sessionKey and claudeSessionId: officer's handle and the transcript's filename are different ids, only the agent holds the mapping, and a schedule stored against one would silently never match a live row keyed by the other. one nudge per (user, session) — a second schedule for one conversation is two alarms for one sleeper. counted even when the turn fails to start, so a refusal cannot become a tight loop. disables itself at the ceiling rather than leaving a spent row armed. the tick starts in server.tsx rather than on first use, because a schedule armed before a restart has to survive one.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
setChatNotifyPreference,
|
||||
} from 'officerdb';
|
||||
import type { NotifyPreference } from 'officerdb/types';
|
||||
import { setNudge, getNudge, cancelNudge } from 'officerdb';
|
||||
import {
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
@@ -548,3 +549,52 @@ chatRouter.post('/stt', async (ctx: Context) => {
|
||||
// Lives on this router because it is the same authority `chat` already grants: creating and naming
|
||||
// Claude sessions. See servers/api/chat/agent-panels-routes.ts and docs/agent-coordination.md.
|
||||
registerAgentPanelRoutes(chatRouter);
|
||||
|
||||
// ── Nudger ──
|
||||
//
|
||||
// A schedule that keeps one chat moving while nobody is watching it. The tick lives in ./nudger.ts; these
|
||||
// are only the three verbs the composer needs.
|
||||
//
|
||||
// Scoped to the caller like every other per-session route here. A nudge is a prompt sent AS someone, so a
|
||||
// schedule readable or writable across accounts would be a way to speak in another person's chat.
|
||||
|
||||
const MIN_EVERY_SECONDS = 60;
|
||||
const MAX_EVERY_SECONDS = 86_400;
|
||||
|
||||
// GET /chat/sessions/:id/nudge — the current schedule, or null.
|
||||
chatRouter.get('/sessions/:id/nudge', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
await chatIdentity(user);
|
||||
return ctx.json({ nudge: await getNudge(user.id, ctx.req.param('id')) });
|
||||
});
|
||||
|
||||
// PUT /chat/sessions/:id/nudge — arm or re-arm. Re-arming resets the count: it is a new night.
|
||||
chatRouter.put('/sessions/:id/nudge', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
await chatIdentity(user);
|
||||
|
||||
const body = await ctx.req.json<{ message?: string; everySeconds?: number; maxCount?: number }>();
|
||||
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
||||
if (!message) return ctx.text('message is required', 400);
|
||||
|
||||
const everySeconds = Number(body.everySeconds ?? 3600);
|
||||
const maxCount = Number(body.maxCount ?? 12);
|
||||
// The floor is the tick interval — anything under it is a lie about cadence rather than a faster nudge.
|
||||
if (!Number.isFinite(everySeconds) || everySeconds < MIN_EVERY_SECONDS || everySeconds > MAX_EVERY_SECONDS) {
|
||||
return ctx.text(`everySeconds must be between ${MIN_EVERY_SECONDS} and ${MAX_EVERY_SECONDS}`, 400);
|
||||
}
|
||||
if (!Number.isFinite(maxCount) || maxCount < 1 || maxCount > 100) {
|
||||
return ctx.text('maxCount must be between 1 and 100', 400);
|
||||
}
|
||||
|
||||
const nudge = await setNudge({ userId: user.id, sessionId: ctx.req.param('id'), message, everySeconds, maxCount });
|
||||
return ctx.json({ nudge });
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id/nudge — disarm. Idempotent: cancelling an absent schedule is success.
|
||||
chatRouter.delete('/sessions/:id/nudge', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
await chatIdentity(user);
|
||||
await cancelNudge(user.id, ctx.req.param('id'));
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user