Two skills that turn Claude Code from an interactive tool into an autonomous worker. Nightshift runs planned project work overnight. 24x7 runs an endless task queue.
Landing page: godmodeai2025.github.io/NightShift
Claude Code is powerful but requires constant babysitting. Every file edit, every shell command needs your approval. On longer tasks, context gets compressed and Claude forgets the plan. And if you walk away, Claude stops.
These two skills solve that. They generate a complete setup — runbook, hooks, watchdog, sandbox — that lets Claude Code work autonomously while you sleep, work on something else, or just aren't at the terminal.
Nightshift is for planned project work: you describe a task, choose a genre template, get a validated runbook, and Claude executes it overnight. One task, one project, one clean git commit in the morning.
24x7 is for continuous work: you drop task folders into an inbox, Claude processes them one by one, and results appear in an outbox. An endless loop that runs until you stop it.
Both skills are Claude Code skills — they run inside Claude (either Claude.ai or Claude Code CLI) and generate all files as a setup package. You don't install the skills on your machine directly; you install them into Claude's skill directory, then ask Claude to generate the setup for your specific project.
Running Claude Code with --dangerously-skip-permissions alone fails after ~20 minutes on longer tasks:
- Context compression — Claude's context window fills up,
/compactruns automatically, and Claude loses the plan, the conventions, and which steps are already done - No guardrails — Without approval prompts, a hallucinated
rm -rf ~/has full permission to execute. This has happened in documented incidents. - No monitoring — You don't know if Claude is still working, stuck in a loop, or has crashed
Both skills build four layers of protection around --dangerously-skip-permissions:
Layer 1: The Runbook (External Memory) A markdown file with checkboxes that Claude reads before each step. After context compression, a hook tells Claude to re-read the runbook and continue at the next unchecked item. The runbook is Claude's external memory — it survives any number of compressions.
Layer 2: Hooks (Guardrails) Claude Code hooks are scripts that fire on specific events:
PreToolUse— Two entries, because one question is not the other."matcher": "Bash"examines the command text and blocks patterns likerm -rf /,sudo,chmod 777,curl | bash,eval."matcher": "Write|Edit|MultiEdit|NotebookEdit"examines the target path and blocks every write outside the project directory. See The Path Guard.PostToolUse— Runs after every tool call. Writes a heartbeat timestamp to a log file.SessionStart(compact matcher) — Fires after every context compression. Injects "re-read the runbook" into Claude's context.Stop(Nightshift only) — Fires every time Claude finishes a response. Every 5 completed steps, reminds Claude of autonomy zones and error budget.
Hooks fire even with --dangerously-skip-permissions. A PreToolUse hook returning exit code 2 blocks the tool call unconditionally.
Layer 3: Isolation (Container by Default)
Nightshift generates a Dockerfile and a docker-compose.yml. The container mounts the project as /project and nothing else from the host — no home directory, no ~/.claude, no neighbouring projects. Outbound traffic runs through a proxy that only lets api.anthropic.com through and answers everything else with 403. nightshift-run.sh refuses to start when it finds neither a container nor a sandbox profile; NIGHTSHIFT_ALLOW_UNSANDBOXED=1 is the deliberate way out.
The sandbox-exec profile stays as the macOS option. It restricts writes at the kernel level: Claude can only write to the project directory and /tmp, and rm -rf ~/ fails without the hook having to catch it. What it does not restrict is reads outside the project and outbound traffic. Apple has deprecated sandbox-exec; it still works on current macOS versions. See What the Sandbox Does Not Cover and SECURITY.md.
24x7 gets the same two. runner.sh measures the same way, refuses to start unfenced with exit code 3, and CLAUDE_24X7_ALLOW_UNSANDBOXED=1 is the way past it. Its container mounts the workspace as /workspace and starts the daemon in it; ./24x7-docker.sh builds and runs it, tasks keep arriving in inbox/ on the host. Dockerfile, Compose file, docker script, seatbelt profile and the isolation check are one implementation in gemeinsam.py, filled with different names.
Layer 3b: Cost Governor (Nightshift)
nightshift-cost.sh reads Claude's stream-json output, adds up the usage fields per model and estimates the dollar figure from a dated price table. When the estimate passes the budget, it terminates Claude's whole process group — a grandchild that outlived the parent used to keep the pipe open and the run hanging — and the run ends with exit code 9. Without jq, and equally when the stream carries no usage events at all, it measures nothing, says unbekannt and lets the run continue — a broken counter must not kill a working night, and it must not report a zero it never measured.
Layer 4: Watchdog (Liveness Monitoring and Restart)
A separate script that checks the heartbeat file. If Claude hasn't written a heartbeat in N minutes (default: 10), it acts. Which action is NIGHTSHIFT_WATCHDOG_AKTION (CLAUDE_24X7_WATCHDOG_AKTION for 24x7): melden reports and is the default, beenden terminates the run, neustart terminates it and starts it again. A run that ended on its own is never restarted, because the watchdog only restarts what it just terminated itself. See The Restart Policy.
The 24x7 skill does NOT run Claude as one long session. Long sessions degrade — a phenomenon called "context rot" where Claude becomes increasingly unreliable after hours of accumulated context. Instead:
- The bash loop (
runner.sh) is the daemon — it runs forever - For each task, it spawns a fresh
claude -pcall - Claude processes the task, writes results, exits cleanly
- The loop checks for the next task
This means every task gets Claude at full quality. No accumulated errors, no context rot, no compact-related amnesia.
When you tell Nightshift "migrate auth to JWT", it doesn't just write "migrate auth to JWT" into the runbook. It detects the task type (migration) and applies a genre template with predefined phases:
| Genre | Phases | Use When |
|---|---|---|
| refactoring | Analysis → Test coverage → Restructure → Verify → Cleanup | Changing code structure without changing behavior |
| feature | Prep → Structure → Core logic → Integration → Tests → Cleanup | Adding new functionality |
| migration | Compat check → Parallel run → Stepwise migration → Verify → Remove old | Switching frameworks, versions, schemas |
| bugfix | Reproduce → Root cause → Fix → Regression test → Cleanup | Systematic debugging |
| testing | Coverage analysis → Prioritize → Write tests → Verify | Improving test coverage |
| cleanup | Inventory → Prioritize → Clean → Verify | Tech debt, formatting, dependencies |
| devops | Current state → Configure → Test → Deploy check | CI/CD, infrastructure |
| documentation | Inventory → Structure → Content → Review | Docs, README, API docs |
Each genre also includes risk checks specific to the task type. A migration genre asks "Is there a rollback strategy? Could data be lost?" A refactoring genre asks "Are existing tests green before starting?"
Every runbook includes three autonomy zones (inspired by AlpiType's Approval Loop article):
- Green (free): Read files, create/edit in src/tests/docs, install dependencies, run tests, git add + commit
- Yellow (log required): Delete files, modify config files, change more than 3 files at once — Claude must document the reason in log.md
- Red (forbidden): Access files outside the project, touch secrets/credentials, force-push
This gives Claude a decision framework. Without it, Claude either hesitates on trivial operations or overreaches on critical ones.
The runbook defines how many test failures are acceptable:
- 1 failing test: Try to fix, max 2 attempts, then continue
- 2-3 failing tests: Warning in log.md, finish the phase
- Over 3 failing tests: STOP,
git stash, write log.md
Without an error budget, Claude either stops at the first flaky test (wasting the entire overnight run) or ignores real regressions.
Before generating the setup, the skill validates the runbook against 15 checks:
Structure: Has preconditions? Has verification phase? Has git commit in conclusion? Has rollback instructions? Between 5-20 steps?
Quality: Every step contains a file path, command, or concrete action? No vague steps like "implement auth"? Test command is concrete?
Safety: No rm -rf in steps? No hardcoded secrets? No actions outside the project directory?
Autonomy: Has all three zones (green/yellow/red)? Has error budget? Error budget has a stop condition?
Validation is not a gate. The generator prints the score and writes the ZIP even when checks fail, so a failed check is a prompt to fix the runbook, not a stop.
Every 5 completed steps (checked boxes in the runbook), a Stop hook fires and reminds Claude of the autonomy zones and error budget. This prevents drift during long runs — Claude re-reads its constraints regularly, not just after context compression.
The Stop hook doesn't just check for checkpoints — it tracks progress. If the number of completed runbook steps hasn't increased after 3 consecutive checks, a STALL WARNING is injected into Claude's context. This catches the most common failure mode in autonomous runs: Claude gets stuck on a failing test and retries it endlessly, burning API credits without making progress.
The warning tells Claude to check its error budget and skip the step if the budget allows it. If the budget doesn't allow skipping, Claude stops the run and writes a log — which is the correct behavior for a real regression.
The watchdog sees heartbeats during a stall (Claude is alive and working), so without stall detection, you'd only discover the loop in the morning when you check the runbook and see step 7 still unchecked after 6 hours.
Nightshift runs are ephemeral — Claude starts fresh each time. But architecture decisions made in one run should inform the next. If Tuesday's run chose PostgreSQL over SQLite, Wednesday's run should know that.
The solution: a decisions.md file in the project root that persists across runs.
- The runbook's conclusion phase includes a step: "Document decisions in decisions.md"
- The CLAUDE-nightshift.md instructs Claude to read
decisions.mdat the start of every run - Format: date, decision, reasoning. Append-only, never overwrite.
This gives cross-run persistence without requiring a long-lived session or external memory system. It's not learning — it's structured remembering.
The 24x7 skill uses the same pattern at workspace level: each task reads decisions.md from the workspace root and appends relevant decisions after completing work.
- Claude Code CLI installed and authenticated (
claudecommand available) - bash, python3 3.9 or newer, jq in your PATH (the build scripts are tested against the macOS system Python 3.9)
- timeout or gtimeout for 24x7 only. macOS does not ship
timeout;brew install coreutilsprovidesgtimeout. The runner uses whichever it finds and refuses to start without one. - Docker with Docker Compose v2 for the Nightshift default path. Same on Linux and macOS. Without Docker the run needs
NIGHTSHIFT_ALLOW_UNSANDBOXED=1or the macOSsandbox-execoption. - An ANTHROPIC_API_KEY for the container path. The container has its own home and does not see an OAuth login on the host.
- A git repository for your project (Nightshift) or any directory (24x7)
Download both skills from the latest release and unpack them into Claude's skill directory:
mkdir -p ~/.claude/skills
curl -LO https://github.com/GodModeAI2025/NightShift/releases/latest/download/nightshift.skill
curl -LO https://github.com/GodModeAI2025/NightShift/releases/latest/download/24x7.skill
unzip nightshift.skill -d ~/.claude/skills/
unzip 24x7.skill -d ~/.claude/skills/Both files are ZIP archives, .skill is only the extension. Each one unpacks into a single directory, ~/.claude/skills/nightshift/ and ~/.claude/skills/24x7/, containing SKILL.md, scripts/build_zip.py, the license, and a VERSION file naming the release it came from.
The links resolve to the newest tag. As long as no tag exists they return 404. In that case, and whenever you want the current state of main rather than a release, clone and copy:
git clone https://github.com/GodModeAI2025/NightShift.git
mkdir -p ~/.claude/skills
cp -r NightShift/nightshift NightShift/24x7 ~/.claude/skills/The version of a release is in VERSION, what changed is in CHANGELOG.md.
Open Claude Code and ask:
Set up a nightshift run for /my/project — migrate auth to JWT
If the skill triggers, you'll see it generate a runbook, validate it, and produce the setup files. If Claude doesn't recognize the skill, check that ~/.claude/skills/nightshift/SKILL.md exists.
In Claude (claude.ai or Claude Code), say something like:
Set up a nightshift run for /Users/me/projects/my-api — refactor the auth module to use JWT tokens
Claude will:
- Detect the genre (refactoring)
- Ask you to confirm
- Generate a runbook with concrete steps
- Validate it (15 checks)
- Produce the setup files
cd /your/project
cp nightshift-setup/runbook.md .
cp nightshift-setup/nightshift-*.sh .
cp nightshift-setup/nightshift-sandbox.sb .
cat nightshift-setup/CLAUDE-nightshift.md >> CLAUDE.md
chmod +x nightshift-*.sh
# Hook configuration. An existing settings.json is never overwritten.
if [ -e .claude/settings.json ]; then
echo "STOP: .claude/settings.json exists, merge it instead of copying (see below)"
else
mkdir -p .claude
cp -R nightshift-setup/.claude/. .claude/
fi
# Without this file the run has no hooks and no protection layer
test -f .claude/settings.json && echo "hooks in place" || echo "WARNING: no hooks"If .claude/settings.json already exists, merging keeps your own hooks,
permissions, and MCP settings and appends the Nightshift hooks per event type:
jq -s '(.[0].hooks // {}) as $mine | (.[1].hooks // {}) as $new
| (.[0] * .[1])
| .hooks = (reduce (($mine | to_entries[]), ($new | to_entries[])) as $e
({}; .[$e.key] = ((.[$e.key] // []) + $e.value)))' \
.claude/settings.json nightshift-setup/.claude/settings.json \
> .claude/settings.merged.json
# read it, then take it over
mv .claude/settings.merged.json .claude/settings.jsonThis is your safety net. If anything goes wrong, git checkout . brings you back here.
git add -A && git commit -m "Checkpoint before Nightshift"In the container (the default):
export ANTHROPIC_API_KEY=sk-ant-...
./nightshift-docker.shBuilds both images, mounts the project as /project, runs the night, tears the containers down again. The budget defaults to 25 USD; NIGHTSHIFT_BUDGET_USD=5 ./nightshift-docker.sh changes it for one run.
With the macOS sandbox (option):
sandbox-exec -f nightshift-sandbox.sb ./nightshift-run.shNo environment variable is involved. The runner probes whether it is fenced: under the profile it can list the project but not /Users, and that is what earns the seatbelt state.
Without isolation (aborts unless you say so):
./nightshift-run.sh # exit code 3
NIGHTSHIFT_ALLOW_UNSANDBOXED=1 ./nightshift-run.sh # runs, on your headBackground (host, terminal can be closed):
./nightshift-run-bg.shIn a second terminal:
./nightshift-watchdog.sh # Alerts after 10 min without heartbeat
./nightshift-watchdog.sh 300 # Alerts after 5 minEvery run writes a receipt, including a run that crashed:
cat nightshift-receipts/*/receipt.md # Steps, diff, cost, isolation, exit code
cat nightshift-receipts/*/receipt.json # Same thing, machine readableFields the run could not determine read "unbekannt", never 0 or null. Cost stays unknown when jq is missing or the output format changed; isolation stays unknown when the run had none.
The raw material is still there if you want it:
git log --oneline -5 # See the commit
git diff HEAD~1 # See what changed
cat runbook.md # See which steps were completed [x]git checkout . # Revert all changes
# or
git stash # Save changes for reviewSet up a 24x7 runner at /Users/me/claude-workspace with idle behavior cleanup
Claude will generate the workspace structure with runner, watchdog, hooks, and sandbox profile.
cd /your/workspace
# The * glob does not match dotfiles, so .claude needs its own step
cp -r /path/to/24x7-setup/* .
# Hook configuration. An existing settings.json is never overwritten.
if [ -e .claude/settings.json ]; then
echo "STOP: .claude/settings.json exists, merge it instead of copying (see below)"
else
mkdir -p .claude
cp -R /path/to/24x7-setup/.claude/. .claude/
fi
# Without this file the daemon runs with no hooks at all
test -f .claude/settings.json && echo "hooks in place" || echo "WARNING: no hooks"
chmod +x *.sh
./runner-bg.shIf .claude/settings.json already exists, merging keeps your own entries
and appends the 24x7 hooks per event type:
jq -s '(.[0].hooks // {}) as $mine | (.[1].hooks // {}) as $new
| (.[0] * .[1])
| .hooks = (reduce (($mine | to_entries[]), ($new | to_entries[])) as $e
({}; .[$e.key] = ((.[$e.key] // []) + $e.value)))' \
.claude/settings.json /path/to/24x7-setup/.claude/settings.json \
> .claude/settings.merged.json
# read it, then take it over
mv .claude/settings.merged.json .claude/settings.jsonCreate a folder in inbox/ with a task.md and optionally a materials/ directory:
mkdir -p inbox/my-task/materialsWrite the assignment:
cat > inbox/my-task/task.md << 'EOF'
## Task: Create a REST API client
Priority: high
### Assignment
Create a TypeScript REST API client for the JSONPlaceholder API.
Include methods for all CRUD operations on /posts and /users.
Add error handling and TypeScript types.
### Input
- materials/api-spec.md — API specification
### Expected Output
- output/api-client.ts — The client
- output/types.ts — TypeScript interfaces
- output/api-client.test.ts — Unit tests
EOFCopy input files:
cp my-api-spec.md inbox/my-task/materials/api-spec.mdThe runner picks it up automatically. Results appear in outbox/my-task/output/.
./watchdog.shShows live status: ✅ 14:32:01: OK (12s) | 📥3 🔄1 ✅12 ❌0
ls outbox/my-task/output/ # Your files
cat outbox/my-task/log.md # What Claude didWhen the inbox is empty, Claude can:
- cleanup — Tidy the workspace, collect TODOs from outbox files
- docs — Update a workspace README with completed task summaries
- tests — Suggest tests for code in completed tasks
- sleep — Do nothing, save API costs
Configure by editing idle/idle-tasks.md or setting the idle behavior when generating the setup.
Both skills run Claude Code in headless mode. Every tool call, every file read, every response consumes API credits. A 24x7 runner generates continuous costs.
Nightshift measures and stops. The counter sums the usage fields of the stream-json stream and estimates the dollars from a price table with a date on it; at the budget it kills Claude's process group. On a real run against the API the estimate came out at 0.25860 USD against the 0.25863 USD Claude reported for the same request. A model the table does not know is billed at twice the most expensive known row, because a newer model can be dearer than anything in the table. The number in receipt.json is still an estimate, not an invoice — the invoice is at console.anthropic.com, and prices move.
NIGHTSHIFT_BUDGET_USD=5 ./nightshift-docker.sh # dollar ceiling, default 25
NIGHTSHIFT_BUDGET_TOKENS=2000000 ./nightshift-run.sh # additional token ceiling24x7 has none of this. It prints a warning at startup and that is the whole mechanism. Set idle to sleep there if cost is a concern, so Claude does not burn credits while the inbox is empty.
nightshift-run.sh measures how it is fenced before it calls Claude. It measures — it does not ask:
| State | How it is reached | How it is verified | Exit |
|---|---|---|---|
docker |
./nightshift-docker.sh, or any container |
/.dockerenv, /run/.containerenv, /proc/1/cgroup, or an overlay root |
runs |
seatbelt |
sandbox-exec -f nightshift-sandbox.sb ./nightshift-run.sh |
the runner can list the project but not /Users; the profile denies that read |
runs |
keine |
plain ./nightshift-run.sh |
neither probe answered | exit code 3 |
NIGHTSHIFT_SANDBOXED used to be the whole check, and any word passed it: NIGHTSHIFT_SANDBOXED=banane ran on a bare macOS shell with --dangerously-skip-permissions and wrote isolation: banane into the receipt. It is now a cross-check only. Set it, and if it disagrees with the measurement the run aborts with exit code 3; it grants nothing.
NIGHTSHIFT_ALLOW_UNSANDBOXED=1 runs anyway, and the receipt then says keine — never a word somebody typed. The state ends up in the receipt, so afterwards you can tell how a given night was fenced.
Isolation is not the same as the hook. The Bash hook greps command text, so it catches typos and obvious mistakes; the path guard measures a target path and holds against Write, Edit and NotebookEdit. Neither of them stops a read, and neither of them stops a write that a Bash command performs.
24x7 runs the same check, with CLAUDE_24X7_SANDBOXED as the cross-check and CLAUDE_24X7_ALLOW_UNSANDBOXED=1 as the opt-out. Since this release it also has a budget and a receipt, under CLAUDE_24X7_BUDGET_USD and CLAUDE_24X7_BUDGET_TOKENS. The budget covers the whole daemon run, not a single task, and the idle branch counts against it too, because idle calls Claude rather than sleeping. When the subscription's usage limit is reached, 24x7 puts the task back into inbox/ and pauses until the reported reset instead of failing the whole queue within seconds; see CLAUDE_24X7_LIMIT_PAUSE_SECONDS in the 24x7 skill.
The hook used to carry "matcher": "Bash" and nothing else. Write, Edit
and NotebookEdit never reached it, so an unattended run could write any file
on the disk and the protection layer did not even see it. There is a second
PreToolUse entry now, and it asks a different question:
| Matcher | Examines | Blocks |
|---|---|---|
Bash |
the command text | rm against dangerous targets, sudo, mkfs, dd to a device, chmod 777, curl | bash, eval |
Write|Edit|MultiEdit|NotebookEdit |
the target path | every write outside the project directory, plus .claude/settings.json inside it |
The path is normalised before the comparison: ~/ becomes the home
directory, . and .. are resolved, a relative path is resolved against the
working directory Claude Code sends with the call. $PROJECT/../elsewhere/x
therefore lands outside and gets blocked. $PROJECT-copy/x gets blocked too;
the comparison is against the directory, not against a prefix of the string.
Without jq neither hook can read its input, and both then block instead of
waving the call through.
The root comes from NIGHTSHIFT_PROJEKT (CLAUDE_24X7_WORKSPACE for 24x7)
and falls back to the path the setup was generated for. The container sets it
to /project, so the same hook fences the run there. That variable belongs to
whoever starts the run: hooks inherit the environment of the Claude process,
and an export inside a Bash tool call does not reach it.
Blocking its own configuration is deliberate. A run that may rewrite
.claude/settings.json has no barrier, only a suggestion.
What the path guard does not cover:
- Writes through Bash.
echo > file,tee,cp,mv,>>are Bash calls. They go to the first hook, and that one checks no paths. This is the largest remaining hole, and it is the reason the container is the default. - Reads. Neither hook looks at
Read,Greporcat. Whatever is readable stays readable. - Symlinks are followed now, but the check is not atomic. The hook resolves
both the root and the target physically before comparing, so a link inside the
project that points outside is blocked, and so is a link that points at
.claude/settings.json. What no hook can close is the gap between the check and the write: a link created in that window is followed by the write and was not there when the hook looked. The container is what bounds that case. - Two names for one directory.
/tmpand/private/tmpare one directory on macOS, and since both sides are resolved physically the hook now treats them as one. This used to fall in favour of the barrier and refuse a legitimate write. - Tools from MCP servers. They carry their own tool names, and no matcher here catches them.
- A settings.json that was never installed. Both hooks exist only if
.claude/settings.jsonis in the project. The install step is a separate command precisely becausecp -r dir/* .skips dotfiles.
Measured in CI: 14 write targets that must be blocked and 9 that must pass,
per skill, driven as real tool calls through the hook command taken out of the
generated settings.json. Plus Edit, MultiEdit and NotebookEdit on both
sides of the boundary, an input without a path, and a run with jq removed
from PATH. See tests/test_pfad_schranke.py.
The sandbox is the only layer that a kernel enforces, and what it enforces is writes. It restricts neither reads nor network traffic. The generated profile allows file-read* broadly, denies /Users and /home and then re-grants $HOME/.claude, $HOME/.config and the other tool directories, and it allows (allow network-outbound (remote tcp "*:443")) without a destination. ~/.claude holds the transcripts of your other projects, ~/.config commonly holds CLI tokens. Anything the run can read, it can also send.
Reads are open on purpose, and it is a concession, not a design goal: a profile that allowed only the handful of paths this one used to list no longer starts any program on current macOS. Measured on Darwin 27, sandbox-exec -f nightshift-sandbox.sb /bin/echo hi ended with SIGABRT — the dyld cache sits outside that list. A profile under which nothing runs protects nobody, so the profile now keeps the write fence and the home-directory fence and gives up the read fence it never actually delivered.
Calling the sandbox a security boundary is only accurate for writes to the filesystem. SECURITY.md has the threat model, the trust boundaries, and the list of known gaps.
The container is the Linux route, and it is the same route on macOS:
export ANTHROPIC_API_KEY=sk-ant-...
./nightshift-docker.shWhat the container gives you that the seatbelt profile does not: reads are fenced too, because nothing but the project is mounted, and outbound traffic is fenced, because the runner hangs in an internal network whose only bridge is a proxy with an allowlist. What it costs: Docker, a build of about a gigabyte, and an API key in the environment rather than an OAuth login on the host.
The dedicated user account remains a weaker fallback for machines without Docker:
sudo useradd -m clauderunner
sudo cp -r /your/project /home/clauderunner/project
sudo chown -R clauderunner: /home/clauderunner/project
sudo -u clauderunner env NIGHTSHIFT_PROJEKT=/home/clauderunner/project \
NIGHTSHIFT_ALLOW_UNSANDBOXED=1 \
bash /home/clauderunner/project/nightshift-run.shThree things about that recipe:
- The copy that
sudo cp -rcreates belongs to root. Without thechown, the runner account cannot write in its own working copy. NIGHTSHIFT_PROJEKTmoves the run into the copy. Without it the script uses the path from generation time and works in the original directory.- The new account has no Claude Code credentials. Authenticate as that user once before the first run.
Even then the account reaches the network and can read every world-readable file on the machine. That is why the run needs NIGHTSHIFT_ALLOW_UNSANDBOXED=1: this is permission scoping, not isolation.
Always commit before starting a Nightshift run. Without a clean git state, you have no rollback. The runner checks for uncommitted changes and warns you (non-blocking).
Both runners write a PID file. If you try to start a second instance, it refuses with a clear error. To force a restart after a crash:
rm /tmp/nightshift.pid # or /tmp/24x7.pidVague runbook steps produce vague results. "Implement auth" can mean anything — Claude will guess, and in headless mode, nobody corrects the guess. Good steps look like:
- [ ] Create src/services/token-service.ts with functions: generateAccessToken(userId), verifyToken(token)
The 15-point validation catches the worst offenders, but you should review the runbook before starting.
Nightshift handles context compression with two mechanisms:
SessionStarthook re-injects "read the runbook" after every/compactStophook repeats autonomy zones and error budget every 5 steps
This works well for 10-20 step runbooks. For very long tasks (30+ steps), split into multiple Nightshift runs — the quality degrades even with these mechanisms.
24x7 avoids the problem entirely by using fresh sessions per task.
The watchdog used to detect a stall and then do nothing about it: one line on stdout and a macOS notification that nobody sees at three in the morning. It still detects the same thing, but what it does with it is now a choice.
NIGHTSHIFT_WATCHDOG_AKTION |
On a stall |
|---|---|
melden (default) |
Report and keep watching. The old behaviour. |
beenden |
TERM to the PID in /tmp/nightshift.pid, KILL after NIGHTSHIFT_WATCHDOG_FRIST seconds (default 20), then the watchdog exits. |
neustart |
The same, then start the run again, at most NIGHTSHIFT_WATCHDOG_NEUSTARTS times (default 1). |
24x7 has the same three under CLAUDE_24X7_WATCHDOG_AKTION.
The PID file is /tmp/nightshift.pid and /tmp/24x7.pid. Runner and
watchdog both take it from NIGHTSHIFT_PIDDATEI or CLAUDE_24X7_PIDDATEI
and fall back to those paths. Set the variable for both processes or for
neither: a watchdog that looks somewhere else finds no PID and reports the
run as already finished. Two projects on one machine need two different
values, because the lock refuses a second run while the first holds it.
TERM before KILL is not politeness. Both runners trap TERM and use it to
shut down: Nightshift ends Claude's process group, 24x7 moves the task it was
working on to failed/ and writes a note. A watchdog that went straight to
KILL would leave a task stuck in working/ forever.
A run that ended on its own is never restarted. That is the whole safety rule, and it comes from the order of operations rather than from a list of exceptions: the watchdog restarts only what it just terminated itself, and it recognises that by a live PID in the PID file. A budget stop terminates the run, so afterwards there is no live PID, and the watchdog reports instead of restarting. A crash and a finished run look the same to it. The roadmap asked for a restart that stays blocked after a budget stop; this is that, without a second mechanism that could disagree with the first.
What the watchdog does not cover:
- A busy loop. The heartbeat comes from the
PostToolUsehook. Claude retrying the same failing test forever keeps writing heartbeats, and to the watchdog that looks perfectly healthy. TheStophook's stall detector is the answer to that case, and it writes text into Claude's context rather than stopping anything. - A run in the container.
/tmpin the container is a tmpfs of its own, so the heartbeat never reaches the host and a watchdog started there waits forever for a file that will not appear.docker compose logs -fis what you watch instead. This is the reason a host-side restart policy is not the right shape for the default path:docker composerestart policies are. - The reason for the stall. It restarts, it does not diagnose. A run that hangs on the same step every time burns the restart budget and then stops.
- Being started at all. It is a separate script in a second terminal, and nothing starts it for you.
- The isolation the original run had. The restart goes through
nightshift-run-bg.sh(runner-bg.shfor 24x7), a barenohup bash run.sh, and the new run inherits the watchdog's environment rather than the terminated run's. A run fenced bysandbox-exectherefore comes back unfenced, measureskeineand refuses with exit code 3; so does a restart whose watchdog shell has noNIGHTSHIFT_ALLOW_UNSANDBOXED=1when the original run had it. That fails closed rather than open, but it meansneustarttoday only completes for a host run whose watchdog shell carries the same opt-out. On the container path the question does not arise, because the watchdog cannot see that heartbeat at all. The CI test uses a stub start script, so it measures that the restart happens, not what the restarted run is fenced by.
Measured in CI against a stand-in run, for both skills: melden leaves the
process alive, beenden ends it and exits 0, neustart ends it and starts the
run script again, a restart budget of 0 ends it without a restart, an already
dead run is reported and not restarted, an unknown action behaves like
melden, and the terminated process really receives TERM before KILL. See
tests/test_watchdog.py.
Both runners handle SIGTERM and SIGINT (Ctrl+C) gracefully. On 24x7, an in-progress task is moved to failed/ with a note. The PID file is cleaned up.
# Graceful stop
kill $(cat /tmp/nightshift.pid)
# Or for 24x7
kill $(cat /tmp/24x7.pid)| File | Purpose |
|---|---|
runbook.md |
Task plan with checkboxes, autonomy zones, error budget |
.claude/settings.json |
All hooks: PreToolUse, PostToolUse, SessionStart, Stop |
nightshift-run.sh |
Main script: isolation check, claude -p + --dangerously-skip-permissions, PID lock, graceful shutdown, receipt from the exit trap |
nightshift-run-bg.sh |
Background wrapper using nohup |
nightshift-docker.sh |
Builds the images and runs the night in the container |
Dockerfile |
Two targets: runner with Claude Code, egress with the allowlist proxy |
docker-compose.yml |
Project as the only host mount, internal network, read-only root, dropped capabilities |
nightshift-cost.sh |
Token counter and budget stop, reads the stream-json stream |
nightshift-receipt.sh |
Writes receipt.json and receipt.md per run |
nightshift-watchdog.sh |
Heartbeat monitor with configurable timeout |
nightshift-sandbox.sb |
macOS sandbox profile (Seatbelt), the option next to the container |
CLAUDE-nightshift.md |
Conventions + run memory (decisions.md) to append to CLAUDE.md |
README-nightshift.md |
Quick reference for the generated setup |
| File | Purpose |
|---|---|
runner.sh |
Endless loop: poll inbox → spawn Claude → route results |
runner-bg.sh |
Background wrapper using nohup |
24x7-docker.sh |
Builds the container and starts the runner in it |
Dockerfile |
Two targets: runner and egress proxy |
docker-compose.yml |
Workspace at /workspace, internal network, hardening |
watchdog.sh |
Heartbeat monitor with live inbox/outbox counters. Host runs only, the container has its own /tmp |
sandbox.sb |
macOS sandbox profile |
.claude/settings.json |
PreToolUse + PostToolUse hooks |
CLAUDE.md |
Workspace rules: autonomy zones, error tolerance, workspace memory (decisions.md) |
idle/idle-tasks.md |
Configurable idle behavior |
inbox/beispiel-task/ |
Example task with task.md template |
Ordered by what blocks users today. No dates attached, this is a private project.
Next
- Budget and receipt for 24x7. The container is there now, the other two are not. The runner measures nothing and leaves a
log.mdper task instead of a report. A budget for a task loop is not the Nightshift counter with a new name: it has to carry a total across tasks, and the counter starts from zero per invocation. - Egress control for the seatbelt path. The container has an allowlist proxy; the seatbelt profile still allows outbound 443 to any host.
After that
- SpecForge tasks.md as a runbook source. See Related Projects. The validation checks German section headings and the three zones, so this needs a converter, not a new entry in the genre table.
Done in the meantime
- Container isolation as the default for Nightshift, with the run refusing to start unfenced.
- A cost governor that measures first and then stops, with the tokens in the receipt.
- A morning receipt as JSON and Markdown, written from the exit trap so a crashed run has one too.
- A second
PreToolUsematcher forWrite,Edit,MultiEditandNotebookEditthat measures the target path instead of a command string. Both skills, one implementation ingemeinsam.py. - Container isolation for 24x7, from the same templates as Nightshift's, with the runner refusing to start unfenced.
- A restart policy in both watchdogs. A restart after a budget stop stays blocked, because the watchdog only restarts a run it terminated itself.
Test coverage
CI compiles both generators under Python 3.9, runs them, checks the generated ZIP, drives the block list of the PreToolUse hook against a table of dangerous and harmless commands, drives the path guard against a table of write targets inside and outside the project, unpacks the release artifact and runs the generator from that location, drives the three watchdog actions against a stand-in run, validates the generated docker-compose.yml of both skills, runs nightshift-run.sh and runner.sh against a Claude stub for the isolation check, nightshift-run.sh for the budget stop and the receipt, and builds the release artifacts on every push. What CI does not do is start a container: the image build needs a network and minutes, so that proof lives in the pull request rather than in the pipeline. See .github/workflows/ci.yml and tests/.
moinsen-dev/NightShift is an independent reimplementation of the same idea, not a fork. GitHub reports no fork relationship, and the two repositories share no history. Its plugins/nightshift/.claude-plugin/plugin.json names GodModeAI as the author and this repository as its home, so the lineage is acknowledged from that side. It ships under the plugin name nightshift at version 2.0.0, which is worth knowing before you install both.
No code moved in either direction. Nothing here is derived from that repository, so there is no NOTICE file to go with it; if anything is ever taken from there, Apache-2.0 section 4 applies and the attribution comes with it.
Where the two differ, as of 2026-09-04:
- Both have a shared module now. The block pattern and both hook bodies live in
gemeinsam.py, and a test fails if the two generators stop agreeing. The sandbox profile and the watchdog are still there twice; that duplication is real and it is ours. - Its blocklist is longer. Fork bombs, force-push and a strict mode are on it. The hook here has no fork-bomb pattern;
nightshift/SKILL.mdsays so in the Security section. - Its cost tracker cannot stop a run. In
plugins/nightshift/scripts/shared/cost_tracker.py,CLAUDE_PIDappears exactly once, in thekillon line 33, and is never assigned; the budget query usesgrep -oP, which BSD grep on macOS rejects; and the sums live in the subshell of a pipeline. The counter here writes its state to a file, gets the PID from the runner and kills the process group —kill -- -PGIDagainst a group the runner opens withset -m, which is what actually reaches a grandchild that outlived its parent — and the budget stop is checked in CI against a stub with exactly such a grandchild. Measuring is the easy half — stopping is the half that has to work. - Its zone enforcement still stops at Bash.
matcher: "Bash"there, blocked paths matched against command text. Here a second matcher measures the target path ofWrite,Edit,MultiEditandNotebookEdit, and a table in CI drives real tool calls through the generated hook. What passes in both is a write that a Bash command performs. - What is only here: tests and CI, a tagged release with artifacts, a SECURITY.md, the landing page, and container isolation with an egress allowlist plus a receipt that says what a night cost.
The intended distinguishing feature is executing a tasks.md produced by SpecForge as an unattended night. That is not implemented. The 15-point validation expects German section headings and the three autonomy zones, so a SpecForge tasks.md fails it by construction; this needs a converter and a second validation path, not another entry in the genre table. It is in the Roadmap as such, and it is a plan, not a feature.
Autonomy zones and error budget inspired by AlpiType — Solving the AI Agent Approval Loop
This project was created privately, to the best of the author's knowledge. Use at your own risk. No warranty of completeness, correctness, or fitness for any particular purpose.
Apache-2.0 — see LICENSE