Overnight mode
Pause autonomous work before the subscription usage window is exhausted, and resume it after the window resets — so an initiative can run through the night without an operator watching the meter.
The platform’s own behaviour at the limit is a cliff: API calls start failing mid-flight and agents die between a write and its commit. Overnight mode converts the cliff into a wind-down. Both windows were hit live on 2026-08-13 while this feature was being built; the design below is the protocol that survived them, mechanized.
How it works, end to end
Section titled “How it works, end to end”-
Telemetry, from three channels in order. The hook payload carries no usage data — measured on Claude Code 2.1.197 — and Tyran deliberately does not ESTIMATE from transcripts, so it reads what the platform already records.
The sidecar is the sharp one: register
scripts/statusline.mjsas your statusline (see below) and every refresh tees the platform’srate_limits— the five-hour and seven-day windows, percent used and reset times — into.tyran/state/usage.json. Only numbers and a shape-validated session id are copied, never free strings.When that sidecar is absent or stale, the gate falls back to the platform’s own cache in
~/.claude.json. That fallback is why overnight mode works at all on most installs. The statusline writes only when the payload carriesrate_limits, and that block is not always populated — measured on a real install runningmode: 'pause', no sidecar had ever been written and the pause had never once been able to fire. The gate fails open, so nothing said so.The fallback reads only the two usage windows out of that file, never the account id or the email beside them.
And then that key went away too. Measured on Claude Code 2.1.197:
cachedUsageUtilizationis not a key of~/.claude.jsonat all, and no percentage is reachable anywhere on the machine. Both channels above went dark at once, and a repo configuredmode: pause, pause_at_percent: 99drove into the wall at full speed on three separate nights.So there is a third channel, and it is the wall itself. Every rejected request is written into the session transcript as an assistant record carrying
quotaLimits: {status: "rejected", rateLimitType, resetsAt}— 45 of them across one machine’s projects. It appears insubagents/*.jsonltoo, which is the seam this project had written off: the limit surfaces inside a subagent’s API call, where no hook can see it.This channel is exact and it is late. It reports 100% of a window that is already spent, so it cannot restore the early stop; what it restores is the clean wind-down and the scheduled resume. Read
scripts/usage-transcript.mjsfor the bounds that keep it cheap enough to run on every tool call — and for the rule that keeps it honest: a rejection counts only while nothing NEWER shows a model answering. A weekly rejection on this machine was followed by 565 successful messages, because an allowance can be topped up; acting onresetsAtalone would have held a working machine until the following Friday, which is a worse failure than the one being fixed. -
The gate. A
PreToolUsehook (usage-gate) compares the sidecar againstlimits:in.tyran/config.yaml. Below threshold, absent telemetry, stale telemetry,mode: off, a malformed config, a supervised operator — every unknown fails open; the gate never produces a false pause. Past the threshold for an autonomous actor it writes a durable pause marker (.tyran/state/paused-until.json) and switches to the wind-down allowlist: reads, writes under.tyran/state/**, the journal/project/overnight/stop-check scripts, andgit status/add/commit/diff/log. Everything else is refused — and the refusal text is the wind-down checklist itself, with absolute paths the allowlist will accept back. -
The wind-down. The conductor follows the checklist: a
checkpointevent (phase: usage-limit-pause), agateevent (kind: usage-limit,result: WAITING_ON_RESET— which renders as an open gate inSTATE.mdwith zero new machinery), regenerated projections, a commit of the state files, thenovernight.mjs schedule, then stop. -
Wait or hold — the two windows differ. The deciding variable is time until reset, against
limits.wait_max_hours(default 5):- within it (the five-hour window’s shape):
schedulespawns a detached watcher that sleeps to the reset and resumes the paused session withclaude -p --resume <session-id> --permission-mode acceptEdits. The operator gets a desktop notification with the resume time. - beyond it (the weekly window’s shape): this is a long pause.
The operator is notified — window, reset time, how far away — and by
default (
limits.long_wait: hold) nothing resumes without them. Nobody should read “resuming soon” when the truth is days.long_wait: resume(orovernight.mjs schedule --force-resume) opts into true multi-day autonomy, still with the loud notification.
- within it (the five-hour window’s shape):
-
The resume. The watcher re-checks the world at wake: the
.tyran/STOPbrake wins outright; a vanished marker or a journal showing the usage-limit gate already closed means someone resumed manually and the watcher stands down; fresh telemetry showing the weekly window still exhausted defers again (or holds, beyondwait_max_hours). Then it clears the marker and spawns the resume — and babysits it: a resumed headless session has no statusline, hence no fresh telemetry, so success is judged by journal movement, never by exit code. A resume that appended nothing is retried on a finite backoff ladder and then reported as failed, loudly.
Configuration
Section titled “Configuration”Overnight mode is driven by the optional limits: block in
.tyran/config.yaml. The annotated reference for all seven keys — defaults,
accepted ranges, and the quoted-'off' rule — lives in
the configuration reference. Enabling the pause is
minimal:
limits: # all seven keys, defaults and ranges: see the reference mode: pause pause_at_percent: 97Keeping the machine awake (limits.keep_awake, default false)
Section titled “Keeping the machine awake (limits.keep_awake, default false)”A watcher whose whole job is to be asleep for hours is waiting through exactly
the interval a laptop chooses to suspend in — and a suspended machine takes the
watcher, and the network, with it. With keep_awake: true the wait — and the
resume it fires, which is the actual work — is wrapped in a system-sleep
inhibitor: caffeinate -is on macOS,
systemd-inhibit --what=idle:sleep on Linux. Never caffeinate -d — that
would also block the screen lock, and a machine left running overnight that
never locks is a security regression; the display and the lock stay exactly as
you set them. The inhibitor is released on every exit path, including SIGINT
and SIGTERM; SIGKILL is the one signal nothing can catch, and it would leak
the inhibitor until the process table clears it. An unsupported platform or a
missing binary degrades to a no-op rather than refusing to wait —
keeping the machine awake is an optimization on the real work, never a
precondition for it. overnight.mjs schedule says which way the knob is set,
as a warning and never a refusal.
On Linux this also refuses a suspend you asked for. The two platforms are
not symmetric behind this one knob. systemd-inhibit --mode=block holds the
lock against every suspend, not only the automatic one — which is what makes
it work at all, since a laptop’s own idle suspend goes through the same logind
call — so while the watcher waits, closing the lid or running systemctl suspend is refused, with a message naming systemd-inhibit and Tyran’s
reason. systemd lets only a privileged user override a block lock. macOS does
not behave this way: caffeinate -i -s still lets an explicit sleep request
through. To put a Linux laptop in a bag mid-wait, cancel the watcher first —
node scripts/overnight.mjs cancel releases the inhibitor with it.
warn surfaces through doctor and never denies — as limit-near, which
compares the reading against your own thresholds. That comparison did not
exist until 0.1.46: the gate returned PASS on warn because “surfacing is
doctor’s job”, and doctor surfaced nothing, so warn differed from off in
nothing an operator could see.
The default is off because it is a behaviour change an operator should
choose, not because it needs a setup step: enabling it takes one edit and the
Settings tab will make it. limit-telemetry-missing fires only when NO
channel is reachable, which usually means signed out rather than
misconfigured.
Unattended delivery — the other half of a night
Section titled “Unattended delivery — the other half of a night”The pause keeps a run alive across the wall. It does nothing about the other way a night is lost: a question.
An ask has no timeout, no expiry and no auto-apply anywhere in Tyran. It
sits WAITING_ON_OPERATOR until a human answers, forever; doctor turns
ask-open into ask-stale after 72 hours of journal time and that is the
entire consequence. The ask protocol has always said to record a default —
“what ships if nobody ever answers” — and until 0.1.46 nothing ever took one.
It fired when a human clicked Take the default, at which point somebody
was awake and the promise was moot.
unattended: mode: on # bare on/off are fine — both spellings are accepted answer: recommendation # or: defaultWith mode: on, an ask that carries a recommendation is closed the moment it
is raised, and the run keeps going. Two refusals are mechanical, which is
what makes it safe to switch on:
- an ask with no recommendation and no default is a real question, and waits;
- an ask raised
journal.mjs ask … --blockingis its author saying this one must wake you — the irreversible, the outward-facing, the ones that spend money.
.tyran/STOP still outranks all of it.
Everything taken automatically is legible in the morning: actor overnight,
decision text prefixed (auto-accepted overnight), answer_mode: unattended.
An auto-ruling that looked like your own would be the failure this feature is
judged by.
Two things drive the sweep, deliberately: the conductor runs
answer.mjs auto --dir .tyran after raising an ask, and the served board runs
the same sweep on its own timer — so a question raised by a session that then
died still gets ruled. The board is also what watches for the usage wall when
no session is alive to be refused, which is the case a five-hour wall actually
produces.
Why a stale reading is still acted on
Section titled “Why a stale reading is still acted on”The platform refreshes its cache on its own schedule — measured over an hour old during continuous heavy use — so a freshness rule strict enough for the statusline would discard it every time and the fallback would be decorative.
It is sound because usage inside a window only ever goes UP. A reading taken inside the window that is still running is a LOWER BOUND on usage now: if it already says 97%, the true figure is at least that, and pausing is correct; if it says less, the true figure may be higher and the gate may fail to pause — which is exactly what happens today, and is the safe direction.
The bound holds only while the window has not rolled. A reading of 95% taken
before a reset says nothing about the fresh window after it, so a reading
whose own resets_at has passed is discarded. That check is what makes the
rest safe, and a pause derived this way records lower_bound: true in its
marker — “at least 97%” is a reason to stop and never a reason to start.
Installing the statusline (optional, and operator-only by design)
Section titled “Installing the statusline (optional, and operator-only by design)”Worth doing if you want the sharpest possible pause: the statusline is session-scoped and refreshed every few seconds, where the platform cache lags. It is no longer required.
A plugin cannot register a statusline, and Tyran’s policy gate refuses
agents writing your settings — so this step is yours. In your user settings
(~/.claude/settings.json):
{ "statusLine": { "type": "command", "command": "node /absolute/path/to/tyran/scripts/statusline.mjs" } }/tyran:setup prints this snippet with the path resolved. If you already
have a statusline you like, tee the platform’s JSON payload into the
sidecar writer before your own statusline consumes it:
tee >(node /absolute/path/to/tyran/scripts/statusline.mjs --sidecar-only) | existing-statusline.sh
— --sidecar-only prints nothing, so your display is untouched. The order
matters: the sidecar writer parses the platform payload, and what an
existing statusline emits is a rendered display line, not that JSON. npm
installs can use npx @jjanczur/tyran statusline.
The operator’s handles
Section titled “The operator’s handles”node scripts/overnight.mjs status # marker + watcher, one screennode scripts/overnight.mjs cancel # stop the scheduled resumenode scripts/overnight.mjs cancel --clear # ...and take over: clears the markernode scripts/overnight.mjs schedule --force-resume # resume despite a long-wait holdcancel reaches the watcher at any moment, including while a resume attempt is
already running: the watcher never parks its own signal handling, so the
SIGTERM is not deferred behind a claude -p that may run for hours. What it
stops is the babysitting — a resumed session already in flight is a separate
process and keeps going, so stop that one yourself if you meant it too.
The .tyran/STOP brake outranks everything: a watcher that finds it at wake
aborts instead of resuming. A supervised operator is never bound by the gate
— your own interactive work continues during a pause.
file (under .tyran/state/) | written by | meaning |
|---|---|---|
usage.json | the statusline helper | latest platform-reported window telemetry |
paused-until.json | the usage gate | the durable pause: window, percent, resume time, long-wait decision |
resume.json | the scheduler | watcher state (waiting · holding · resuming · done · failed · skipped · aborted-stop · cancelled), pid |
resume.log | the scheduler | the watcher’s append-only trace |
conductor.json | the SessionStart probe | this session’s resumable id, the time it last announced itself, and its cwd |
conductor.json is where a resumable session id comes from without a
usage-limit pause: before it existed, one was written only by the pause marker
or by the operator-installed statusline, so a repo with neither could not be
resumed at all. answer.mjs apply --resume reads the id out of it; the file
says nothing about whether that session is still running, because a
SessionStart hook has no measured signal that tracks its session’s lifetime
and a liveness field nobody can defend is worse than none.
All five are machine-local runtime, exempt — by name — from doctor’s
stray-file check, and kept out of history by .tyran/.gitignore. That
ignore file is seeded at adoption and brought up to date by re-running the
scanner (node scripts/scan-repo.mjs --ensure-policy or --write) — an
install that adopted before this feature should re-run it once so the newer
entries exist.
Reliability, stated plainly
Section titled “Reliability, stated plainly”- The detached watcher does not survive a reboot. Machine sleep delays
it (chunked sleeps re-read the clock); power loss kills it. The marker
then goes stale, doctor says so (
limit-pause-stale), the session-start notice says so, and the gate self-clears it on the next session’s first tool call after the reset. Operators who need reboot survival can wrapovernight.mjs --waitin launchd/cron; nothing is auto-installed. - Telemetry is event-driven: it updates when the statusline fires, so the gate acts on the last message’s numbers, not this instant’s. A single long generation can cross the threshold unseen — the session survives the hard limit (measured), and the watcher still resumes it.
- The gate binds at tool-call granularity and reads commands, never effects — the same declared floor as every other Tyran gate.
- The supervised-operator exemption inherits the platform’s
permission_modesemantics:defaultmeans “the platform may prompt”, so an allow-listed autonomous flow underdefaultescapes the pause. Fail-open, and stated rather than implied. - Like every gate here, a deleted hook fails open;
doctor --hooksand the session-start warning are the detection layer.
Doctor findings
Section titled “Doctor findings”limit-pause-active (info) · limit-pause-stale (warning) ·
limit-resume-watcher-dead (warning) · limit-telemetry-missing (warning)
— see the doctor reference for the full table.