Target: macOS, Claude Code CLI
Goal: Automatically resume an interrupted Claude Code session after a rate-limit pause, with minimal moving parts
Distribution: GitHub package, one-command install for friends
The first version should optimize for reliability over richness.
The previous architecture tried to infer too much: it scanned transcripts for limit strings, updated a human-readable state file after every tool call, and drove Warp through GUI keystrokes. Those choices added fragility around the least reliable surfaces.
The lean version uses Claude Code's native StopFailure hook for rate-limit detection and keeps nightshift as a small scheduler/resume wrapper.
There are three phases:
| Phase | When | Who acts |
|---|---|---|
| Rate-limit detection | Claude Code turn fails due to rate limit | StopFailure hook |
| Resume scheduling | Immediately after rate limit | schedule-resume.sh writes a one-shot launchd job |
| Auto-resume | About 5 hours later | launchd runs claude-resume.sh |
Key simplification:
StopFailure(error=rate_limit) -> schedule launchd job -> run claude --continue later
No transcript grep is needed for v1.
ACTIVE CLAUDE SESSION
|
| Claude hits API usage limit / rate limit
v
CLAUDE CODE HOOK: StopFailure
input includes:
- session_id
- cwd
- transcript_path
- error = "rate_limit"
- last_assistant_message
|
v
scripts/on-stop-failure.sh
- parses hook JSON from stdin
- only proceeds when error == "rate_limit"
- writes a small nightshift job state file
- calls schedule-resume.sh
|
v
scripts/schedule-resume.sh
- creates per-project/per-session job id
- prevents duplicate schedules for the same interrupted session
- writes launchd plist for one-shot resume
- loads the launchd job
|
v
5 HOURS LATER
|
v
launchd runs scripts/claude-resume.sh
- reads job state
- cd's into the original project
- starts Claude Code with claude --continue
- sends a short resume instruction
- cleans up the completed job lock/plist
Purpose: One-command global setup.
What it does:
- Creates
~/.claude/nightshift/scripts/ - Creates
~/.claude/nightshift/jobs/ - Creates
~/.claude/nightshift/locks/ - Copies scripts with executable permissions
- Backs up
~/.claude/settings.json - Non-destructively merges a
StopFailurehook - Installs the manual fallback command
- Runs a basic dry-run validation
Safety requirements:
- Never overwrite existing Claude Code settings.
- Preserve existing hooks.
- Avoid depending on
jq; usepython3for JSON parsing. - Print exact changes made and backup path.
Purpose: Claude Code hook entry point for failed turns.
Triggered by: StopFailure hook.
Input: JSON on stdin.
Expected relevant fields:
{
"session_id": "abc123",
"transcript_path": "/Users/chris/.claude/projects/.../session.jsonl",
"cwd": "/Users/chris/project",
"hook_event_name": "StopFailure",
"error": "rate_limit",
"last_assistant_message": "Usage limit reached..."
}Logic:
- Parse JSON with
python3. - Exit quietly unless
error == "rate_limit". - Validate
cwdexists. - Build a stable job id from
cwd + session_id. - Write job metadata to
~/.claude/nightshift/jobs/<job_id>.json. - Call
schedule-resume.sh <job_id>. - Exit 0 always.
Why StopFailure:
Claude Code exposes this hook specifically for turns that end due to API errors. Its matcher supports error values such as rate_limit, so v1 should not infer this by grepping transcripts.
Purpose: Create and load a one-shot macOS launchd job.
Input: $1 = job_id
Logic:
- Read
~/.claude/nightshift/jobs/<job_id>.json. - Check
~/.claude/nightshift/locks/<job_id>.lock. - If a fresh lock exists, exit quietly.
- Write/update lock with timestamp.
- Calculate resume time: now + 5 hours + 1 minute.
- Generate
~/Library/LaunchAgents/com.claude.nightshift.<job_id>.plist. - Unload any stale job with the same label.
- Load the new job.
- Append a small scheduling note to the job JSON.
Important design choice:
Locks and launchd labels are per job, not global. This allows multiple projects or sessions to be scheduled independently.
Purpose: Resume the interrupted Claude Code session.
Input: $1 = job_id
Logic:
- Read job metadata from
~/.claude/nightshift/jobs/<job_id>.json. - Validate
cwdstill exists. - Start a terminal session in that directory.
- Run
claude --continue. - Send a short resume instruction:
Continue the interrupted task from this session. Use the transcript and project files to recover context. Do not ask the user what to do unless the next step is genuinely ambiguous.
- Remove the lock.
- Unload and remove the plist.
- Keep the job JSON as an audit record, marked
completed.
Preferred v1 transport: tmux
For unattended reliability, use tmux if available:
tmux new-session -d -s "claude-nightshift-$job_id" \
"cd \"$cwd\" && claude --continue"This avoids GUI focus, AppleScript, Warp autocomplete, Accessibility permissions, and timing sleeps.
Fallback v1 transport: Warp
If the user explicitly wants Warp-only behavior, keep the Warp driver as a fallback mode:
osascript -> activate Warp -> open new window -> type cd + claude --continue
Warp support should be treated as less reliable than tmux because it depends on macOS Accessibility and Automation permissions.
Purpose: Manual fallback command.
If automatic hook scheduling fails or the user sees a limit message that did not trigger StopFailure, they can run:
claude-schedule-resumeLogic:
- Detect current working directory.
- Find the latest Claude Code session/transcript for that directory when possible.
- Create a manual job id.
- Write job metadata.
- Call
schedule-resume.sh.
This keeps v1 useful even if Claude Code changes hook behavior or the hook was not installed correctly.
Purpose: Clean removal.
What it does:
- Removes the nightshift hook entries from
~/.claude/settings.json - Unloads any
com.claude.nightshift.*launchd jobs - Removes generated launchd plist files
- Removes installed scripts
- Leaves archived job records unless the user passes a purge flag
claude-nightshift/
├── README.md
├── PLAN.md
├── install.sh
├── uninstall.sh
│
├── scripts/
│ ├── on-stop-failure.sh
│ ├── schedule-resume.sh
│ ├── claude-resume.sh
│ └── claude-schedule-resume
│
├── templates/
│ └── launchagent.plist.template
│
└── test/
├── test-hook.sh
├── test-schedule.sh
└── test-resume.sh
install.sh should merge this into ~/.claude/settings.json, substituting the real absolute path:
{
"hooks": {
"StopFailure": [
{
"matcher": "rate_limit",
"hooks": [
{
"type": "command",
"command": "/Users/USERNAME/.claude/nightshift/scripts/on-stop-failure.sh"
}
]
}
]
}
}Existing hooks must be preserved.
Location:
~/.claude/nightshift/jobs/<job_id>.json
Example:
{
"job_id": "a1b2c3d4",
"created_at": "2026-05-18T14:32:11Z",
"resume_at": "2026-05-18T19:33:11Z",
"status": "scheduled",
"cwd": "/Users/chris/Downloads/PROJECTS/MyApp",
"session_id": "abc123def456",
"transcript_path": "/Users/chris/.claude/projects/.../session.jsonl",
"error": "rate_limit",
"last_assistant_message": "Usage limit reached..."
}This file is the source of truth for the resume job.
- macOS 12 Monterey or later
- Claude Code CLI installed and available as
claude python3launchctlzsh
tmux
- Warp terminal, if the user wants visible GUI resume
terminal-notifier, for desktop notifications
| Component | Confidence | Risk / Mitigation |
|---|---|---|
StopFailure hook for rate_limit |
High | Uses Claude Code's native error hook instead of transcript inference |
| launchd scheduling | High | Mature macOS scheduler; test sleep/wake behavior |
| Per-job locks | High | Prevents duplicate schedule without blocking other projects |
claude --continue |
Medium | CLI behavior may change; keep manual fallback |
| tmux resume | Medium-high | Requires tmux installed but avoids GUI fragility |
| Warp resume | Medium-low | Requires Accessibility/Automation permissions and is sensitive to focus/timing |
| Machine asleep at fire time | Medium | launchd should run after wake; resume may be delayed |
Simulate Claude Code calling the hook:
{
"session_id": "test-session",
"cwd": "/tmp/test-project",
"transcript_path": "/tmp/test-transcript.jsonl",
"hook_event_name": "StopFailure",
"error": "rate_limit",
"last_assistant_message": "Usage limit reached"
}Expected:
- Job JSON is created.
- Lock is created.
- launchd plist is generated.
- Script exits 0.
Validate scheduling without waiting 5 hours:
- Use a short test delay.
- Load the plist.
- Confirm launchd executes the target script.
- Confirm logs are written.
Dry-run resume:
- Do not call real
claude. - Replace
claude --continuewithecho. - Confirm command is formed correctly.
- Confirm cleanup behavior.
Expected flow:
git clone https://github.com/yourname/claude-nightshift
cd claude-nightshift
./install.shExpected output:
claude-nightshift installer
---------------------------
created ~/.claude/nightshift/scripts/
created ~/.claude/nightshift/jobs/
created ~/.claude/nightshift/locks/
copied scripts
backed up ~/.claude/settings.json
merged StopFailure(rate_limit) hook
installed claude-schedule-resume
ran dry-run validation
Done. claude-nightshift is active.
If tmux is not installed, print:
tmux not found. Nightshift can still use Warp mode, but tmux is recommended for unattended reliability.
StopFailure(rate_limit)detection- Per-job launchd scheduling
- tmux-first resume
- Manual fallback command
- Install/uninstall scripts
- Dry-run tests
claude-nightshift statusclaude-nightshift cancel <job_id>- Desktop notification on schedule and resume
- Configurable delay
- Warp mode hardening
- Linux: systemd timer or
at - Linux: tmux-only resume
- Terminal.app and iTerm2 adapters
- Optional task-state generation from transcript
- Retry limits
- Consecutive failure detection
- Resume health checks
These pieces are intentionally excluded from the first build. They may be revisited later, but they should not be part of the initial architecture.
Removed because: Claude Code already exposes StopFailure with error == "rate_limit". Grepping transcript text is more brittle and duplicates platform behavior.
Removed because: It depends on exact wording such as usage limit, 429, or Usage Limit Reached. Those strings can change, be localized, or appear in non-limit contexts.
Removed because: The normal Stop hook is for completed turns. Rate limits are a failed-turn case and should be handled by StopFailure.
Removed because: Current hook schemas separate normal stops from failed stops. The cleaner boundary is Stop for successful turns and StopFailure for API failures.
Removed because: Updating state after every tool call adds overhead and only captures shallow breadcrumbs. It cannot reliably know the real task, root cause, or next step.
Removed because: A rich TASK_STATE.md is not necessary for v1. The resume job already stores cwd, session_id, transcript_path, and the last assistant error message.
Removed because: Arbitrary markdown in the project root is not guaranteed to be loaded automatically by Claude Code. If used later, the resumed prompt should explicitly tell Claude to read it.
Removed because: It is more complex than the value it provides. The transcript is already the authoritative record of the session.
Removed because: It prevents multiple independent projects from scheduling resumes. Per-job locks are nearly as simple and much more correct.
Removed because: GUI automation is the least reliable part of the system. Warp support can remain as an optional adapter, but v1 should prefer tmux for unattended operation.
Removed because: It only matters for Warp GUI automation. If Warp mode is optional, permission testing belongs with that optional adapter, not the core v1 test suite.