Skip to content

Latest commit

 

History

History
529 lines (369 loc) · 13.5 KB

File metadata and controls

529 lines (369 loc) · 13.5 KB

claude-nightshift — Lean Build Plan

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


Design Principle

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.


How It Works

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.


Full Flow

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

v1 Component Specs

1. install.sh

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 StopFailure hook
  • 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; use python3 for JSON parsing.
  • Print exact changes made and backup path.

2. scripts/on-stop-failure.sh

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:

  1. Parse JSON with python3.
  2. Exit quietly unless error == "rate_limit".
  3. Validate cwd exists.
  4. Build a stable job id from cwd + session_id.
  5. Write job metadata to ~/.claude/nightshift/jobs/<job_id>.json.
  6. Call schedule-resume.sh <job_id>.
  7. 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.


3. scripts/schedule-resume.sh

Purpose: Create and load a one-shot macOS launchd job.

Input: $1 = job_id

Logic:

  1. Read ~/.claude/nightshift/jobs/<job_id>.json.
  2. Check ~/.claude/nightshift/locks/<job_id>.lock.
  3. If a fresh lock exists, exit quietly.
  4. Write/update lock with timestamp.
  5. Calculate resume time: now + 5 hours + 1 minute.
  6. Generate ~/Library/LaunchAgents/com.claude.nightshift.<job_id>.plist.
  7. Unload any stale job with the same label.
  8. Load the new job.
  9. 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.


4. scripts/claude-resume.sh

Purpose: Resume the interrupted Claude Code session.

Input: $1 = job_id

Logic:

  1. Read job metadata from ~/.claude/nightshift/jobs/<job_id>.json.
  2. Validate cwd still exists.
  3. Start a terminal session in that directory.
  4. Run claude --continue.
  5. 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.
  1. Remove the lock.
  2. Unload and remove the plist.
  3. 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.


5. scripts/claude-schedule-resume

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-resume

Logic:

  1. Detect current working directory.
  2. Find the latest Claude Code session/transcript for that directory when possible.
  3. Create a manual job id.
  4. Write job metadata.
  5. Call schedule-resume.sh.

This keeps v1 useful even if Claude Code changes hook behavior or the hook was not installed correctly.


6. uninstall.sh

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

Package Structure

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

Claude Code Settings

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.


Job State Format

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.


Requirements

Required

  • macOS 12 Monterey or later
  • Claude Code CLI installed and available as claude
  • python3
  • launchctl
  • zsh

Recommended

  • tmux

Optional

  • Warp terminal, if the user wants visible GUI resume
  • terminal-notifier, for desktop notifications

Feasibility and Risks

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

Test Plan

test/test-hook.sh

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.

test/test-schedule.sh

Validate scheduling without waiting 5 hours:

  • Use a short test delay.
  • Load the plist.
  • Confirm launchd executes the target script.
  • Confirm logs are written.

test/test-resume.sh

Dry-run resume:

  • Do not call real claude.
  • Replace claude --continue with echo.
  • Confirm command is formed correctly.
  • Confirm cleanup behavior.

Install Experience

Expected flow:

git clone https://github.com/yourname/claude-nightshift
cd claude-nightshift
./install.sh

Expected 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.

Roadmap

v1.0 — Lean macOS build

  • StopFailure(rate_limit) detection
  • Per-job launchd scheduling
  • tmux-first resume
  • Manual fallback command
  • Install/uninstall scripts
  • Dry-run tests

v1.1 — Better UX

  • claude-nightshift status
  • claude-nightshift cancel <job_id>
  • Desktop notification on schedule and resume
  • Configurable delay
  • Warp mode hardening

v2.0 — Cross-platform

  • Linux: systemd timer or at
  • Linux: tmux-only resume
  • Terminal.app and iTerm2 adapters

v3.0 — Smarter recovery

  • Optional task-state generation from transcript
  • Retry limits
  • Consecutive failure detection
  • Resume health checks

Removed From v1

These pieces are intentionally excluded from the first build. They may be revisited later, but they should not be part of the initial architecture.

scripts/detect-limit.sh

Removed because: Claude Code already exposes StopFailure with error == "rate_limit". Grepping transcript text is more brittle and duplicates platform behavior.

Transcript scanning for known strings

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.

scripts/on-stop.sh

Removed because: The normal Stop hook is for completed turns. Rate limits are a failed-turn case and should be handled by StopFailure.

stop_reason == "error" branching

Removed because: Current hook schemas separate normal stops from failed stops. The cleaner boundary is Stop for successful turns and StopFailure for API failures.

scripts/on-post-tool.sh

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.

scripts/save-state.sh

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.

TASK_STATE.md as required project state

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.

Continuous state saving on every tool call

Removed because: It is more complex than the value it provides. The transcript is already the authoritative record of the session.

Global lock file

Removed because: It prevents multiple independent projects from scheduling resumes. Per-job locks are nearly as simple and much more correct.

Warp as the primary resume mechanism

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.

test/test-permissions.sh

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.