Skip to content

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.

  1. 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.mjs as your statusline (see below) and every refresh tees the platform’s rate_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 carries rate_limits, and that block is not always populated — measured on a real install running mode: '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: cachedUsageUtilization is not a key of ~/.claude.json at all, and no percentage is reachable anywhere on the machine. Both channels above went dark at once, and a repo configured mode: pause, pause_at_percent: 99 drove 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 in subagents/*.jsonl too, 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.mjs for 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 on resetsAt alone would have held a working machine until the following Friday, which is a worse failure than the one being fixed.

  2. The gate. A PreToolUse hook (usage-gate) compares the sidecar against limits: 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, and git 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.

  3. The wind-down. The conductor follows the checklist: a checkpoint event (phase: usage-limit-pause), a gate event (kind: usage-limit, result: WAITING_ON_RESET — which renders as an open gate in STATE.md with zero new machinery), regenerated projections, a commit of the state files, then overnight.mjs schedule, then stop.

  4. 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): schedule spawns a detached watcher that sleeps to the reset and resumes the paused session with claude -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 (or overnight.mjs schedule --force-resume) opts into true multi-day autonomy, still with the loud notification.
  5. The resume. The watcher re-checks the world at wake: the .tyran/STOP brake 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, beyond wait_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.

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: 97

Keeping 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: default

With 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 … --blocking is 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.

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.

Terminal window
node scripts/overnight.mjs status # marker + watcher, one screen
node scripts/overnight.mjs cancel # stop the scheduled resume
node scripts/overnight.mjs cancel --clear # ...and take over: clears the marker
node scripts/overnight.mjs schedule --force-resume # resume despite a long-wait hold

cancel 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 bymeaning
usage.jsonthe statusline helperlatest platform-reported window telemetry
paused-until.jsonthe usage gatethe durable pause: window, percent, resume time, long-wait decision
resume.jsonthe schedulerwatcher state (waiting · holding · resuming · done · failed · skipped · aborted-stop · cancelled), pid
resume.logthe schedulerthe watcher’s append-only trace
conductor.jsonthe SessionStart probethis 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.

  1. 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 wrap overnight.mjs --wait in launchd/cron; nothing is auto-installed.
  2. 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.
  3. The gate binds at tool-call granularity and reads commands, never effects — the same declared floor as every other Tyran gate.
  4. The supervised-operator exemption inherits the platform’s permission_mode semantics: default means “the platform may prompt”, so an allow-listed autonomous flow under default escapes the pause. Fail-open, and stated rather than implied.
  5. Like every gate here, a deleted hook fails open; doctor --hooks and the session-start warning are the detection layer.

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.