A local batch queue for Claude Code — SGE-style job files that run prompts unattended in 5-hour windows.
Project description
windowsill
A local batch queue for Claude Code.
Where you leave things overnight, in the window, to be dealt with in the morning.
The problem
The binding constraint on a Claude Code subscription usually isn't the weekly cap — it's the 5-hour usage window. A window starts on your first message and lasts 5 hours; whatever's left when you stop working is gone, not banked. Most of that headroom evaporates overnight while you sleep.
windowsill is a cron-driven queue that submits Markdown job files — prompt bodies with a small directive header — and dispatches them into windows you'd otherwise waste, up to a monthly ceiling you configure.
You write jobs before bed; you run ccresume <id> in the morning to pick a conversation back up.
A quick note on words this project is careful about, since Claude Code overloads "session":
| Concept | Called | Where you meet it |
|---|---|---|
| The 5-hour billing segment | window | /usage's "Current session: N% used"; the monthly benchmark |
A Claude Code conversation with a session_id |
conversation | --resume, ~/.claude/projects/ |
| One execution attempt of a queued job | run | windowsill internals only |
Install
uv tool install windowsill
That puts five commands on your PATH: the four short verbs ccsub, ccstat, ccdel, ccresume,
and windowsill, the dispatcher that carries all of them plus the rest — windowsill show, windowsill report, windowsill tick.
windowsill --help is the one place that lists everything.
From a checkout of this repo, run it straight from the project environment instead:
uv run ccstat
uv run uses the project's own venv with windowsill installed editable,
so your source edits take effect immediately — prefer it while working in the repo.
uvx --from . ccstat runs it too, but builds the package into a separate, ephemeral tool
environment each time and runs that snapshot rather than your live source:
handy for a one-off try, redundant once the code is already local.
Shell completion
windowsill completions install
Detects your shell from $SHELL (or take --shell bash|zsh|fish to skip that) and writes
completion files for all five entry points — windowsill, ccsub, ccstat, ccdel, ccresume —
straight into the directory that shell already scans on startup, so there's no rc file to edit
and nothing to go stale: the script just re-invokes the program on each TAB press, so a CLI change
shows up the next time you press TAB, not the next time you reinstall completions.
- bash and fish autoload by filename, so install lands one real file — holding all five entry
points' scripts — plus a symlink per remaining name in
~/.local/share/bash-completion/completions/and~/.config/fish/completions/respectively. - zsh has no such fixed directory, so it gets five real files instead, under
~/.zsh/completions; if that isn't already on your$fpath,installprints thefpath+=(...)line to add.
Prefer an eval line in your rc over installed files? windowsill completions <bash|zsh|fish>
emits the windowsill script alone to stdout:
eval "$(windowsill completions bash)"
Either way, a TAB press costs about 70-90ms now — imperceptible; it was ~220ms before commit
6daf5c3 trimmed cli.py's import-time cost.
Running on a schedule (cron)
windowsill dispatches jobs two ways (see design/03-execution-model.md).
The fast path is the chain: when a job finishes it kicks the next tick itself, so back-to-back jobs in one open window start seconds apart.
Cron is the liveness floor underneath that — it catches a broken chain, an --at coming due, and a rate-limit blocked_until expiring.
Fifteen minutes is fine precisely because it is never the latency path.
Add one line to your crontab (crontab -e):
PATH=/home/you/.local/bin:/usr/bin:/bin
*/15 * * * * windowsill tick -q
-qsuppressestick's one-line summary, so cron has no output to mail you every 15 minutes.PATHmatters because cron runs with a nearly empty environment. Bothwindowsillandclaudehave to be findable on it — a tick with a due job spawnswindowsill run, which spawnsclaude.uv tool install windowsillputswindowsillin~/.local/bin, whereclaudeusually already lives; confirm withcommand -v windowsill claudeand set thePATH=line to match. (If you run from a checkout instead of installing, useuv run --project /path/to/windowsill windowsill tick -qand make sureuvis on thatPATHtoo.)HOMEis set by cron automatically, which is allclaudeneeds to find its credentials — a non-interactive job with no TTY authenticates fine (verified indesign/09-findings.md).
To keep a heartbeat and capture any errors, redirect to a log instead:
*/15 * * * * windowsill tick >> "$HOME/.windowsill/logs/tick.log" 2>&1
Drop the -q when you redirect — the summary line becomes a useful "it's alive" trace in the log rather than mail in your inbox.
windowsill creates ~/.windowsill/logs/ for you on first run.
The job file
A job is a UTF-8 text file: an optional shebang, a header of #$ directives, a blank line, then the prompt body — verbatim, no templating, any length.
#!/usr/bin/env ccsub
#$ --name refactor-auth
#$ --at 2026-07-18T02:00
#$ --model claude-opus-4-8
#$ --cwd ~/code/myapp
#$ --resume-from audit-auth
#$ --window fresh
#$ --isolate worktree
#$ --max-turns 60
#$ --retry 2
#$ --notify email
Continue the auth refactor from where the audit job left off.
Done means: every call site of `legacy_verify()` migrated to `verify_token()`,
`pytest tests/auth` green, and a single squashed commit on the branch.
If you hit a blocker, do not stop — write what you tried and what you would
need to `BLOCKED.md`, then continue with the next call site.
Parsing rules that matter:
- The
#!line, if present, is ignored — job files can bechmod +xand self-submitted.ccsubtakes the file as its only argument, so#!/usr/bin/env ccsubmakes./refactor-auth.mdsubmit itself. - Directives are lines matching
^#\$\s+(.*)$, and must appear before the first non-directive, non-blank, non-shebang line. Once prose starts, the header is closed — a#$line can't resume later in the file. - The first blank line after the header ends the header. Everything after it, to EOF, is the body: verbatim, unmodified.
- Bare
#lines and#$ #...inside the header are comments. - A trailing
\continues a directive onto the next line. - Directive arguments are tokenised with shell-like quoting rules (so
--name "has spaces"works as you'd expect).
Two kinds of directive.
windowsill recognises a fixed set of scheduler directives (below); everything else is passed through verbatim, in order, to claude as argv.
#$ --model claude-opus-4-8 becomes claude --model claude-opus-4-8.
This means a new claude flag works the day it ships, with no change here — and windowsill never validates or rejects a directive it doesn't recognise; unknown ones flow straight through and claude complains if they're wrong.
Scheduler directives
| Directive | Default | Meaning |
|---|---|---|
--name <slug> |
filename stem | Human label. Unique only among non-terminal jobs |
--at <when> |
ASAP | Earliest start. ISO-8601, tomorrow 02:00, or +3h |
--before <time> |
none | Do not start after this; job becomes expired if missed |
--window <policy> |
share |
fresh | share | offpeak |
--anchor |
false | This is an anchor job. Implies --model claude-haiku-4-5 unless overridden |
--cwd <path> |
submit-time cwd | Working directory. ~ expanded at submit |
--log <path> |
job dir only | Also write the rendered transcript here. A trailing / (or an existing directory) means "in there", as <id>-<name>.md; otherwise it is the file. Relative to --cwd |
--resume <conversation_id> |
— | Continue an explicit conversation |
--resume-from <job> |
— | Continue the conversation produced by a completed job (resolved at dispatch) |
--hold-jid <job>[,<job>] |
— | Wait for those jobs to reach done |
--retry <n> |
0 |
Retries on non-rate-limit failure. Rate-limit requeues are free and unbounded |
--priority <-20..19> |
0 |
Tie-break within a window; lower runs first |
--isolate <mode> |
config | none | worktree |
--allow-dirty |
false | Permit running against a dirty tree |
--notify <channel>[:<dest>] |
config (none) |
none | ntfy | email. May carry this job's own recipient or topic: email:me@example.com |
--hold |
false | Submit held; needs windowsill release |
--timeout <dur> |
4h |
Wall-clock kill |
--no-resume-on-limit |
false | On rate-limit requeue, restart from the original body instead of resuming |
--resume and --resume-from are mutually exclusive; both end up as claude --resume <id> at dispatch.
--resume is the one case where a scheduler directive shadows a real claude flag.
Every directive above is parsed and acted on today, with two exceptions: --isolate worktree is stored but not yet honoured (runs happen in --cwd regardless), and --allow-dirty is not yet checked.
Commands
Three commands you'll use constantly, each its own executable, after Grid Engine's qsub/qstat/qdel:
ccsub <file>— submit a job. Use-to read from stdin. Prints the job id.ccstat— see what's queued, running, held, or recently finished: id, name, state, model, when, cwd, elapsed.ccresume <id>— the morning-after command.execs intoclaude --resume <conversation_id>in the job's working directory, so you drop straight back into last night's conversation.
Working today (milestones M1 and M2). Every row is also reachable as a subcommand of windowsill,
which is the form cron and the job chain use:
| Command | Also | Behaviour |
|---|---|---|
ccsub <file> |
windowsill sub |
Submit. - reads stdin. Prints job id. --now bypasses --at |
ccstat |
windowsill stat |
id, name, state, model, when, cwd, elapsed, plus window state and 30d count |
ccstat -a |
windowsill stat -a |
Include terminal jobs from the last 7 days |
ccdel <id> |
windowsill del |
Cancel; SIGTERM the process group if running |
ccresume <id> |
windowsill resume |
exec into claude --resume <conversation_id> in the job's cwd |
| — | windowsill show <id> |
Directives, resolved argv, state, conversation id, Δ%, permission denials, transcript tail |
| — | windowsill log <id> [-f] |
Stream the transcript; -f follows a running job |
| — | windowsill hold <id> / windowsill release <id> |
Toggle held |
| — | windowsill usage [--probe] |
Cached window state; --probe reads /usage fresh (max once per 10s) |
| — | windowsill report [--since 24h] |
Digest of what ran: state, duration, Δ%, and a ccresume line each |
| — | windowsill tick |
Internal; cron and chain entry point |
Times are printed in your machine's own local time with no zone attached — 2026-07-28 02:00.
Pass --with-timezone to any of these for the offset-bearing ISO form instead.
Designed but not implemented yet — do not rely on these:
| Command | Intended behaviour |
|---|---|
windowsill sub -e |
$EDITOR on a template header; submit on save |
windowsill gc [--older-than 30d] |
Remove terminal job dirs and worktrees |
windowsill doctor |
Environment/config sanity check |
Correspondingly, the two things those cover — --isolate worktree and the dirty-tree refusal — are part of the design but not yet functional.
Everything else is live: window policies decide dispatch from a /usage probe, rate limits requeue instead of failing, --retry retries, and finished jobs notify.
Notifications
Off until you ask for them, and windowsill will not invent an address to send to.
# ~/.windowsill/config.toml
[notify]
default = "email" # or "ntfy", or "none" (the default)
[notify.email]
to = "you@your-real-domain"
sendmail = "/usr/sbin/sendmail"
[notify.ntfy]
url = "https://ntfy.sh"
topic = "your-private-topic"
There is no default recipient and no default ntfy topic.
A channel that is on with nowhere to send to sends nothing and records a notify_skipped event — it will not fall back to a placeholder, and it will not guess from $USER and your hostname.
A single job can carry its own destination with #$ --notify email:me@example.com, which is the whole configuration if you only want mail from one job.
windowsill show <id> prints the address a job would actually reach.
Similar tools
Running Claude Code unattended is a small, crowded field. What separates these tools isn't the queue — it's what triggers a dispatch.
claude-code-queue— triggered by hitting a limit. Runs, gets rate-limited, waits out the reset, retries. Markdown files with YAML frontmatter, priorities, a prompt bank, and a/queueskill it installs into Claude Code so the assistant can suggest queueing on its own. The closest neighbour by far; see below.jshchnz/claude-code-scheduler— triggered by the wall clock. A Claude Code plugin rather than a standalone CLI: you install it from the marketplace and schedule in natural language ("every weekday at 9am review yesterday's code"). Optional git worktree mode commits to a branch so your working tree stays clean — the pattern--isolate worktreefollows. The most established of these by some margin.dnvriend/claude-code-scheduler— triggered by the wall clock, plus interval and file-watch. Unrelated to the above despite the identical name. A PyQt6 desktop app with a REST API on :5679, a Job→Task→Run hierarchy, and profiles for AWS Bedrock and Z.AI.gruckion/claude-scheduler— triggered by the wall clock, one-shot. Small, but it captures the conversation id and hands you click-to-resume notifications — the ergonomicsccresumeis aiming at.- Claude Code's own scheduling — triggered by the wall clock.
/loopwith theCron*tools schedules within a session; the desktop app's scheduled tasks use aSKILL.mdwith YAML frontmatter, which is Anthropic's own take on a job file and worth reading before changingdesign/02-job-file.md.
Every one of these except claude-code-queue dispatches on a clock.
A clock knows what time it is; it doesn't know whether you have a window open.
claude-code-queue is the closest neighbour, and the overlap is real: Markdown job files with a directive header, a queue, retries, and an awareness that the 5-hour window is the thing that actually hurts.
The difference is which direction the tool faces.
claude-code-queue is reactive — it exists so that hitting a limit doesn't block you, and it waits a window out.
windowsill is opportunistic — it exists to spend windows that would otherwise expire unused, and it treats capacity as a budget rather than as free-until-blocked.
Three things follow from that, none of which have a counterpart above:
- Window state is read, not inferred.
A
/usageprobe (design/04-usage-oracle.md) is free and answers before dispatch, which is what makes--window freshand--window offpeakexpressible at all. Learning a window's state by crashing into it can only ever produce retry logic. - Anchors.
An anchor doesn't create capacity, it moves a boundary — and it fires conditionally, no-opping if a window is already open.
See
design/05-windows.md§5.2 for why a fixed cron time can't do this. - A ceiling. 30-day rolling window accounting against a configured monthly limit.
See design/index.md for the full design, including the execution model, window accounting, and SQLite schema.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file windowsill-0.1.0.tar.gz.
File metadata
- Download URL: windowsill-0.1.0.tar.gz
- Upload date:
- Size: 171.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
581466e5e8d4b550193275e62df39abcb639a164da43d4f2065eb06ce02cef53
|
|
| MD5 |
3c777fddc567cd50b9e422c56f3f8c79
|
|
| BLAKE2b-256 |
8cbda0aa57904065122637fd9b98cc8011ad33ff4b198d8019d46eb9c7f3bcaa
|
Provenance
The following attestation bundles were made for windowsill-0.1.0.tar.gz:
Publisher:
pypi.yaml on watercrossing/windowsill
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
windowsill-0.1.0.tar.gz -
Subject digest:
581466e5e8d4b550193275e62df39abcb639a164da43d4f2065eb06ce02cef53 - Sigstore transparency entry: 2334887635
- Sigstore integration time:
-
Permalink:
watercrossing/windowsill@7fe2a6a1e5d4d2acb0fa3fd071327a4dca5bdcfe -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/watercrossing
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yaml@7fe2a6a1e5d4d2acb0fa3fd071327a4dca5bdcfe -
Trigger Event:
release
-
Statement type:
File details
Details for the file windowsill-0.1.0-py3-none-any.whl.
File metadata
- Download URL: windowsill-0.1.0-py3-none-any.whl
- Upload date:
- Size: 88.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13b070e8685b626a9561fab1897523494a5fc0a87c019fc13b748252c66af873
|
|
| MD5 |
b4865464cef98c58580c127accc7c6d9
|
|
| BLAKE2b-256 |
b567028c775036d18cea3ed17da8a9b0c9279e2c67e53a913d7ac446c15bacfd
|
Provenance
The following attestation bundles were made for windowsill-0.1.0-py3-none-any.whl:
Publisher:
pypi.yaml on watercrossing/windowsill
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
windowsill-0.1.0-py3-none-any.whl -
Subject digest:
13b070e8685b626a9561fab1897523494a5fc0a87c019fc13b748252c66af873 - Sigstore transparency entry: 2334887642
- Sigstore integration time:
-
Permalink:
watercrossing/windowsill@7fe2a6a1e5d4d2acb0fa3fd071327a4dca5bdcfe -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/watercrossing
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yaml@7fe2a6a1e5d4d2acb0fa3fd071327a4dca5bdcfe -
Trigger Event:
release
-
Statement type: