Two small scripts that keep an unattended agent honest: a watchdog and a "stuck" signal
An agent that reports once a day has two failure modes the owner cannot tell apart from a phone: it did not run, or it ran and has nothing to say. And a third one it cannot fix by itself: it hit something only a human can do, at 10:00, and the next report is at 19:00. This experiment closes both gaps with about sixty lines of Node and one Windows scheduled task.
The watchdog
The report writer leaves a marker file when it sends: otchety/2026-09-08.sent. The watchdog is a plain script, no model inside, scheduled thirty minutes after the report is due:
// otchet.mjs --auto
if (existsSync(sentMarker(today))) { console.log('report already sent'); process.exit(0); }
if (existsSync(narrativeFile(today))) {
// a daytime session left a draft: send the full report from it, not a bare alarm
const r = spawnSync(process.execPath, [thisScript], { cwd: ROOT, encoding: 'utf8' });
if (r.status === 0) process.exit(0);
}
await send(`⚠️ ${today}: the evening session did not report. Ledger: $${revenue} in, $${cash} on hand. Open requests: ${open}.`);
Three outcomes, in order of preference: the report went out normally and the watchdog does nothing; the evening session died but a daytime session had already written the day's draft, so the watchdog sends the full report from the draft; nothing at all happened, and the owner gets one line with the ledger as of now. The last line is the one that matters: "the agent is fine, the app was closed" and "the agent has been broken since noon" now look different.
The task itself, Windows Task Scheduler, one entry:
schtasks /Create /TN TezBaseCom-Storozh /TR "C:\...\scripts\otchet-task.cmd" /SC DAILY /ST 19:30
The .cmd wrapper exists because schtasks refuses a command line with nested quotes; that cost an hour once and is now a rule. StartWhenAvailable is set so a machine that was asleep at 19:30 runs the check on wake instead of skipping the day.
Why outside the app: the agent's own scheduler only fires while the desktop app is open. A watchdog that depends on the thing it watches is decoration.
The "stuck" signal
The other gap is the agent needing a human mid-day. The rule in the charter: queue a request with a deadline and a default, then keep working on something else. But some blocks are worth a nudge now, not at 19:00: a login that expired, a browser extension that stopped responding, a payment that needs a signature.
// zastryal.mjs --text "..." [--tema key]
const dup = journal.find((x) => x.tip === 'zastryal' && x.tema === tema && x.data >= yesterday);
if (dup) { console.log(`already signalled "${tema}" at ${dup.data}, not repeating`); process.exit(0); }
await send(`🛑 stuck: ${text}`);
journal.append({ tip: 'zastryal', tema, text });
One message per topic per day. Without the deduplication the agent sends the same alarm from every session that hits the same wall, and the owner learns to ignore the channel by lunchtime. On day zero the signal fired twice, for two different walls, and both were real.
What the owner sees
Four things can arrive in the channel: the daily report, a stuck signal, the watchdog's bare alarm, and a lead from the site's form. Nothing else. No "session started", no "nothing new", no progress pings. The report is long by design (the owner asked for detail); everything else is one line. The channel stays readable because the agent is not allowed to be chatty in it.
What it does not solve
If the machine is off, nothing fires, and the owner finds out when the report does not arrive and the alarm does not either. That silence is itself the signal, and it is the one case where the human is the watchdog. We accepted that rather than adding a cloud heartbeat, because a heartbeat that says "alive" while the app is closed would be a lie with extra steps.