Summary
The claimer works — it fires `claude -p "hey"` ~45s after each window opens and
claims windows reliably on a clean 5h cadence. But two design properties make it
behave in surprising and wasteful ways:
-
The claim chain free-runs on its own clock. Because every unattended claim
opens a real 5-hour window, the window boundaries get pinned to the
claimer's schedule rather than to the user's actual usage. Whenever the user
first sits down, the reset clock is wherever the free-running chain left it —
it can look exactly like a freshly-started window even though the claimer
opened it. Observed live: a user's first interactive prompt landed ~2 minutes
before a claim boundary, making the window appear to reset "almost exactly
5h from my first prompt."
-
Provisional-reset drift. `claim.py` writes the next reset as
`now + 5h`. Since the claim fires `LEAD_SECONDS` (~45s) after the window
actually opened, `now` is already ~45s into the window, so each cycle's
provisional reset is ~45–50s late. Observed claim times drifting later every
cycle: `00:40:45 → 05:41:34 → 10:42:24 → 15:43:14` UTC. The only thing that
re-anchors to Anthropic's true boundary is an interactive `statusLine` write,
which never happens on an idle headless host.
-
Every unattended claim is a real, billed Opus turn. Inspecting the
`claude -p "hey" --output-format json` envelope shows `total_cost_usd` and
full `usage`/`modelUsage` (~$0.05/turn in one sample) — and it consumes the
front slice of every window. On a 24/7 free-running chain that's a billed turn
every 5h around the clock, including hours/days the user never touches Claude.
Note: the JSON envelope from `claude -p` does not contain `resets_at` or any
rate-limit field (confirmed — top-level keys are type/usage/modelUsage/cost/etc.,
no reset info), so reading Anthropic's true reset time from the claim call is not
possible today. The README's statement on this is correct. That constraint is why
the fixes below work around it rather than reading the real value.
Proposed solution
1. Self-correcting boundary (kills the drift). In `claim.py`, instead of
`write_provisional_reset() = now + 5h`, read the previous reset from
`~/.claude/next-reset-time` and write `prev_reset + 5h` (advancing past `now` in
5h steps if the cache is stale). Anthropic's real boundary is exactly
`prev_reset + 5h`, so this re-anchors every cycle instead of accumulating the
~45s lead error. Drift → 0 without needing the statusline. Fall back to `now + 5h`
only when there is no usable prior value.
2. Gate the claim on recent activity (the main fix). Only claim when it
actually helps the user. Before firing, check the mtime of the most recent
interactive session file under `~/.claude/projects` (exclude the claimer's own
`-p "hey"` sessions). If nothing has been touched within a configurable window
(e.g. `ACTIVE_WITHIN_HOURS`, default ~10h), skip the claim. This stops the chain
from opening + partially burning + billing windows 24/7 when the user is away,
and makes window boundaries track the user's usage instead of a free-running
clock — directly fixing symptom #1.
3. (Cleanup, lower priority) Fix double-logging.
`claim-claude-window.service` routes both stdout and stderr to the log file via
`append:`, while `_setup_logging()` in `scheduler.py`/`claim.py` also adds a
`StreamHandler(sys.stderr)`. Result: every log line is written twice. Drop the
`StreamHandler` (systemd already captures/append the output).
4. (Cleanup, lower priority) Fix every-10-min reschedule churn.
`scheduler.py`'s idempotency check parses `NextElapseUSecRealtime` to compare the
existing claim timer against the target, but for a one-shot `--on-calendar`
transient timer that property is often empty, so the "already scheduled — no
action" path never matches and the scheduler cancels + recreates an identical
timer every tick. Switch the check to match the existing unit by its deterministic
name (`claim-claude-window-{fire_ts}`) instead of parsing the elapse property.
Priority
Items 1 and 2 are the substantive fixes (2 addresses the observed behavior and the
billing/usage waste; 1 removes the drift). 3 and 4 are independent low-risk
cleanups that can ship in the same PR or separately.
Summary
The claimer works — it fires `claude -p "hey"` ~45s after each window opens and
claims windows reliably on a clean 5h cadence. But two design properties make it
behave in surprising and wasteful ways:
The claim chain free-runs on its own clock. Because every unattended claim
opens a real 5-hour window, the window boundaries get pinned to the
claimer's schedule rather than to the user's actual usage. Whenever the user
first sits down, the reset clock is wherever the free-running chain left it —
it can look exactly like a freshly-started window even though the claimer
opened it. Observed live: a user's first interactive prompt landed ~2 minutes
before a claim boundary, making the window appear to reset "almost exactly
5h from my first prompt."
Provisional-reset drift. `claim.py` writes the next reset as
`now + 5h`. Since the claim fires `LEAD_SECONDS` (~45s) after the window
actually opened, `now` is already ~45s into the window, so each cycle's
provisional reset is ~45–50s late. Observed claim times drifting later every
cycle: `00:40:45 → 05:41:34 → 10:42:24 → 15:43:14` UTC. The only thing that
re-anchors to Anthropic's true boundary is an interactive `statusLine` write,
which never happens on an idle headless host.
Every unattended claim is a real, billed Opus turn. Inspecting the
`claude -p "hey" --output-format json` envelope shows `total_cost_usd` and
full `usage`/`modelUsage` (~$0.05/turn in one sample) — and it consumes the
front slice of every window. On a 24/7 free-running chain that's a billed turn
every 5h around the clock, including hours/days the user never touches Claude.
Note: the JSON envelope from `claude -p` does not contain `resets_at` or any
rate-limit field (confirmed — top-level keys are type/usage/modelUsage/cost/etc.,
no reset info), so reading Anthropic's true reset time from the claim call is not
possible today. The README's statement on this is correct. That constraint is why
the fixes below work around it rather than reading the real value.
Proposed solution
1. Self-correcting boundary (kills the drift). In `claim.py`, instead of
`write_provisional_reset() = now + 5h`, read the previous reset from
`~/.claude/next-reset-time` and write `prev_reset + 5h` (advancing past `now` in
5h steps if the cache is stale). Anthropic's real boundary is exactly
`prev_reset + 5h`, so this re-anchors every cycle instead of accumulating the
~45s lead error. Drift → 0 without needing the statusline. Fall back to `now + 5h`
only when there is no usable prior value.
2. Gate the claim on recent activity (the main fix). Only claim when it
actually helps the user. Before firing, check the mtime of the most recent
interactive session file under `~/.claude/projects` (exclude the claimer's own
`-p "hey"` sessions). If nothing has been touched within a configurable window
(e.g. `ACTIVE_WITHIN_HOURS`, default ~10h), skip the claim. This stops the chain
from opening + partially burning + billing windows 24/7 when the user is away,
and makes window boundaries track the user's usage instead of a free-running
clock — directly fixing symptom #1.
3. (Cleanup, lower priority) Fix double-logging.
`claim-claude-window.service` routes both stdout and stderr to the log file via
`append:`, while `_setup_logging()` in `scheduler.py`/`claim.py` also adds a
`StreamHandler(sys.stderr)`. Result: every log line is written twice. Drop the
`StreamHandler` (systemd already captures/append the output).
4. (Cleanup, lower priority) Fix every-10-min reschedule churn.
`scheduler.py`'s idempotency check parses `NextElapseUSecRealtime` to compare the
existing claim timer against the target, but for a one-shot `--on-calendar`
transient timer that property is often empty, so the "already scheduled — no
action" path never matches and the scheduler cancels + recreates an identical
timer every tick. Switch the check to match the existing unit by its deterministic
name (`claim-claude-window-{fire_ts}`) instead of parsing the elapse property.
Priority
Items 1 and 2 are the substantive fixes (2 addresses the observed behavior and the
billing/usage waste; 1 removes the drift). 3 and 4 are independent low-risk
cleanups that can ship in the same PR or separately.