Queue Claude Code tasks now — they run automatically the moment your 5-hour session resets.
macOS only. The tool uses
nscurl(Apple's native URLSession),defaults(macOS plist reader), BSDdate -j, and/usr/bin/shlock— none of which exist on Linux or Windows.
When you hit the 5-hour session limit, you don't wait. You write /queue-task <task> → the task gets saved. The moment your session resets, the daemon runs it with Claude Code and shows you the result in the browser — no manual polling, no missed windows.
Reset time is detected automatically: first from the Claude Code CLI's own OAuth token, then from the session you are already signed into (the Claude desktop app, Firefox or Chrome), then from rusted-claude-meter if you run it. No DevTools and no copy-pasting on any of those paths.
- macOS (Ventura or later recommended)
- jq —
brew install jq - sqlite3 — pre-installed on macOS
- Claude Code CLI — installed and authenticated
- The Claude desktop app, Firefox and/or Chrome (optional but recommended, this is what enables automatic reset-time detection). The desktop app is tried first; between the browsers, whichever you actually use is tried first automatically
- python3 (ships with macOS), needed for the desktop-app and Chrome cookie paths. The desktop-app path uses the
cryptographymodule; the Chrome path additionally needspip3 install browser-cookie3. The Firefox path has no Python dependency
git clone https://github.com/davorinpavlica/claude-code-queue
cd claude-code-queue
./install.shinstall.sh does everything:
- Creates
~/.claude/queue/and symlinks all scripts - Registers the
UserPromptSubmithook and statusline in~/.claude/settings.json - Adds daemon auto-start to
~/.zshrc(login-shell context whereclaude --printworks)
| Component | What it does |
|---|---|
/queue-task |
Slash command — adds a task to the queue |
daemon.sh |
Background process — polls the queue, waits for the reset window, runs tasks via claude --print |
inject-hook.sh |
UserPromptSubmit hook — delivers finished results into the chat on your next message, warns if the queue is stuck |
session-start-hook.sh |
SessionStart hook — delivers finished results the moment a chat opens, without you typing anything |
queue-inbox.sh |
Library, not a command — the single claim used by /queue-resume and both hooks, so automatic delivery can never show less than the manual command |
nudge-session.sh |
Writes a pickup suggestion into the Claude desktop app's record for the chat a task was queued from. Only reaches a closed session, measured; see the caveat below |
release-check.sh |
Runs every test suite and refuses a release unless the end-to-end window test is present and green |
pre-push-hook.sh |
Installed as .git/hooks/pre-push — blocks a tag push when the release check fails |
get-next-reset.sh |
Resolves the next session-reset epoch: .next_reset cache, then OAuth, then cookie chain, then meter, then now + 5h |
lib/get-oauth-token.sh |
Library, not a command: reads the Claude Code OAuth Bearer token (Keychain, then ~/.claude/.credentials.json), shared by get-next-reset.sh and statusline.sh |
fetch-usage.sh |
Fetches raw usage JSON from claude.ai/api/…/usage using your session cookie |
.task-sessions (data file, not a script) |
Maps a task's TIMESTAMP to the session_id it was queued from, so the daemon can resume that exact chat |
claude-app-cookie.py |
Reads sessionKey / lastActiveOrg / cf_clearance from the Claude desktop app's own cookie store. Tried before the browsers |
firefox-cookie.sh |
Reads the same three cookies from Firefox's local SQLite DB |
chrome-cookie.py |
Same, for Chrome, via the browser_cookie3 Python library |
statusline.sh |
Status line for Claude Code: context and session usage gauges, refreshed from the OAuth API or, failing that, the session cookie |
/queue-run |
No args: force all PENDING tasks within ~60 s. With a task ID: run that task immediately |
/queue-status |
Shows PENDING / RUNNING / BLOCKED tasks, plus the next five windows and how full they are |
/queue-resume |
Pulls finished task results into the current chat, filtered to the current project |
/queue-retry |
Resets a BLOCKED task back to PENDING |
/queue-history |
Shows completed task history |
calc-fits.sh |
Estimates how many tasks still fit in the current window; returns OK / TIGHT / OVER |
reset-source.sh |
Library, not a command — the single place that answers "when is the next reset" |
task-costs.log (data file) |
Measured utilization cost of the last 50 tasks, used to learn the estimate |
Debugging a wrong or moving reset time? Read docs/RESET-TIME.md first. It documents what
reset_atactually means, the three ways it has broken, and a fast diagnosis recipe.
The daemon needs to know when your 5-hour Claude session resets so it can schedule tasks at the right moment. The reset time is stored in ~/.claude/queue/.next_reset and refreshed through a cascade, checked in this order: the cached value, the OAuth token, the cookie chain, the meter file, then a safe fallback.
If the stored value has not passed yet, it is used as is and nothing below runs.
Source 2: OAuth token via api.anthropic.com/api/oauth/usage (automatic, tried first among live sources)
get-next-reset.sh reads the same Bearer token used by claude itself, via scripts/lib/get-oauth-token.sh (Keychain first, then ~/.claude/.credentials.json), and calls api.anthropic.com/api/oauth/usage directly.
This goes first because when the token exists it is the cheapest and most direct source. The call needs no cookies, no browser, and no Cloudflare workaround: it is a plain authenticated API request.
But the token does not always exist, measured 2026-08-03. It is written only when you sign in through the CLI with claude login. If you signed in through the Claude desktop app, there is no such token: ~/.claude/.credentials.json is absent, and the Claude Code-credentials Keychain item may exist while holding only MCP plugin tokens and no claudeAiOauth.accessToken. In that case this source is skipped on every call and the cascade falls through to the cookie chain below, which is the intended behaviour and is covered by tests. Check yours with ls ~/.claude/.credentials.json.
fetch-usage.sh reads the claude.ai session cookie entirely locally, with no browser extension, no DevTools and no copy-paste, from whichever client you are actually signed into:
- Claude desktop app (
claude-app-cookie.py), tried first. The app keeps its own Chromium cookie store at~/Library/Application Support/Claude/Cookies, encrypted under theClaude Safe Storageitem in the macOS Keychain. The script copies the locked DB to a temporary file (mode0600, deleted on exit), decryptssessionKey,cf_clearanceandlastActiveOrg, and prints them to stdout. The first time this runs, macOS asks you to unlock the login Keychain for that item. Approve the prompt to finish importing; it will not ask again. - Firefox (
firefox-cookie.sh): stores cookie values unencrypted in an SQLite file at~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite. The script copies the locked DB to a temporary file (mode0600, deleted on exit) and queries it directly. - Chrome (
chrome-cookie.py): Chrome encrypts its cookies, with the decryption key in macOS Keychain under a "…Safe Storage" item, so this path uses thebrowser_cookie3Python library instead of hand-rolled decryption (pip3 install browser-cookie3). The same one-time Keychain prompt applies.
The desktop app goes first on purpose. It is the one client you are certainly signed into, and it refreshes its own session, whereas the browser paths quietly depend on claude.ai still being open and logged in somewhere. If the desktop app is not installed, or its Keychain prompt has not been approved yet, the chain falls through to the browsers unchanged.
Between the two browsers, the order is never hardcoded. fetch-usage.sh checks macOS's default browser setting (via LaunchServices) and tries that one first. If LaunchServices cannot determine the default, it falls back to whichever browser's cookie store was modified more recently. To force a specific browser regardless, write firefox or chrome (one word) to ~/.claude/queue/.browser-preference.
In every case, the raw cookie values are never written to disk, never logged, and never sent anywhere other than the single HTTPS request to claude.ai.
fetch-usage.sh then calls claude.ai/api/organizations/<org>/usage using those cookies. Because Cloudflare validates the TLS fingerprint that earned cf_clearance, it uses nscurl (Apple's URLSession stack) with a User-Agent matching whichever client actually produced the cookie: a Chrome UA for the desktop app (it is Electron, so Chromium) and for Chrome, a Firefox UA for Firefox. Plain curl would be fingerprinted as a bot and receive a 403 challenge.
The response contains five_hour.resets_at (an ISO 8601 timestamp). get-next-reset.sh converts it to a local epoch, snaps it to the nearest whole minute, writes it to .next_reset, and the daemon uses it.
Why the rounding matters. The reported resets_at jitters by about a second either side of the true boundary, and both the meter and the API do it. One reset of 12:10:00Z came back as 12:09:59.019, 12:09:59.512, 12:10:00.190 and 12:10:00.422 on consecutive reads (measured 2026-08-02). .next_reset only stores HH:MM, so simply truncating made the stored hour flip between 14:09 and 14:10 at random. Real resets land on whole minutes, so nearest-minute is both the stable answer and the correct one.
Safari is not automated yet. Safari's cookies require one-time manual "Full Disk Access" approval (System Settings → Privacy & Security → Full Disk Access) that can't be granted from a script regardless of implementation language. If you rely on Safari, use the manual ~/.claude/queue/.session-cookie fallback below, or open x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles to grant access ahead of a future Safari integration.
If rusted-claude-meter is installed and running, get-next-reset.sh reads its exported ~/.claudemeter/usage.json and uses session_usage.reset_at directly, with no cookies and no Cloudflare involved. This is used only if that file exists and its reset time is still in the future.
This one now runs late on purpose. The meter is a separately installed third-party tool holding its own cached copy of the same data OAuth and the cookie chain already fetch live, so it only earns a turn once both of those have failed or are unavailable.
When every live source fails (no OAuth token, no browser session, no meter, network error), the system writes now + 18000 s to .next_reset. The queue never deadlocks; tasks simply run on a fixed 5-hour cadence. You can override it manually with queue-set-reset HH:MM.
.next_reset cache (if still in the future)
↳ fallback: OAuth Bearer token (Keychain, then ~/.claude/.credentials.json)
└─ get-oauth-token.sh → api.anthropic.com/api/oauth/usage
└─ five_hour.resets_at → snap to minute → .next_reset → daemon scheduling
↳ fallback: Claude desktop app cookie store (local, read-only)
└─ claude-app-cookie.py → sessionKey / cf_clearance (stdout only, never on disk)
↳ fallback: Firefox cookies.sqlite, then Chrome
└─ firefox-cookie.sh / chrome-cookie.py → same three cookies
└─ fetch-usage.sh → nscurl + matching UA → claude.ai usage API
└─ five_hour.resets_at → snap to minute → .next_reset
↳ fallback: rusted-claude-meter (~/.claudemeter/usage.json)
↳ fallback: now + 5h → .next_reset
install.sh registers statusline.sh as Claude Code's status line. It shows your git branch, the model, a context gauge and a session-usage gauge:
🌿 main │ Opus 5 │ 🟢 Ctx ▓▓░░░░ 24% │ ⏳ 🟡 ▓▓▓▓░░ 61%
Usage figures are cached in ~/.claude/usage-exact.json and refreshed on a timer, so the status line never blocks on the network.
Two sources, tried in order. Both return the same fields, so the cache looks identical either way apart from its source marker.
- OAuth API. If Claude Code was authenticated through the CLI login flow, it stores a
claudeAiOauth.accessTokenin~/.claude/.credentials.jsonor in theClaude Code-credentialsKeychain item.statusline.shuses that token againstapi.anthropic.com/api/oauth/usage. The cache records"source": "api". - Session cookie. When there is no such token, or the token no longer works,
statusline.shcallsfetch-usage.shinstead and takes the same figures fromclaude.ai/api/organizations/<org>/usage. The cache records"source": "cookie".
Why the fallback exists. If you sign in through the Claude desktop app rather than the CLI, there is no OAuth token anywhere: the app authenticates with a session cookie, and the Claude Code-credentials* Keychain items hold only MCP server credentials. Before this fallback the refresh silently failed and the status line served whatever was last in the cache. On the author's machine that meant a frozen file from 2026-05-24 and a permanent, believable-looking 0% for over two months.
Because fetch-usage.sh already prefers the desktop app's own cookie store, this path works out of the box on a desktop-app machine after the one-time Keychain approval described above.
A failed refresh never damages a good cache: the new value is written to a temporary file and moved into place only once it parses.
To check which path is live:
jq '{source, timestamp, pct: .metrics.session.percent_used}' ~/.claude/usage-exact.jsonFrom Claude Code:
/queue-task Refactor the auth module to use the new SDK
From terminal:
~/.claude/queue/add-task.sh "Refactor the auth module to use the new SDK"Target a specific project (looked up under ~/Documents/GitHub/<name> or as an absolute path):
~/.claude/queue/add-task.sh --project my-project "Refactor the auth module to use the new SDK"
~/.claude/queue/add-task.sh --project /path/to/other-repo "Refactor the auth module to use the new SDK"Schedule into a later window instead of the next one, with --qN or -qN (both accepted; each step is +5 h; q1 is the default/next window, q2 is next-window-plus-5h, and so on):
/queue-task --q2 Refactor the auth module to use the new SDK
~/.claude/queue/add-task.sh --project my-project --q3 "Refactor the auth module to use the new SDK"Tasks run in a clean Claude Code session. When one finishes, the result is appended to the inbox
at ~/.claude/queue/last_response.md and a macOS notification fires.
The result then comes to you on its own. Two hooks deliver it, both filtered to the project the task was queued for:
SessionStart— you open or resume a chat on that project, and the result is already there.UserPromptSubmit— the chat is already open, so it arrives with your next message.
/queue-resume still exists and does exactly the same thing on demand. All three go through one
shared claim in queue-inbox.sh, so the automatic paths can never deliver less than the manual
command does.
The project filter is not cosmetic: without it, a task queued in one repo would surface in an unrelated repo's conversation. Results for other projects stay in the inbox for their own chats. Several completions accumulate and are all delivered together, so nothing is lost when two tasks finish in the same window.
You also get a per-task HTML archive in ~/.claude/queue/outputs/ and the raw text is copied to
the clipboard automatically.
Claiming is destructive, and that has one non-obvious consequence. A queued task runs as
claude --print inside its own project directory, which is a new session, so the delivery hooks
fire there too. Left alone they would hand every pending result to a throwaway session nobody
reads and consume it in the process. daemon.sh and queue-run.sh therefore set
QUEUE_TASK_RUN=1 around their claude call, and both hooks refuse to deliver when they see it.
Delivery belongs to your chat, never to a task's own run.
The Claude desktop app keeps a per-session record carrying a promptSuggestion field, and shows
it when it next loads that session. nudge-session.sh writes into it. .task-sessions maps a task to the CLI session_id captured
when you typed /queue-task, and the app's own record carries that same id in cliSessionId, so
the right window is one lookup away.
It does not reach a window that is already open. Measured 2026-08-03: a promptSuggestion
written into an open session's record was gone one turn later, and the file was byte-identical to
its backup apart from its timestamps. The app holds session state in memory and serialises it over
the file on every turn, so anything written from outside is discarded. The nudge therefore only
helps a session you are not currently sitting in, which is not the gap it was written for. It
is kept because it is harmless, not because it solves that problem. The open-window case is
designed in docs/superpowers/specs/2026-08-03-idle-window-delivery-design.md and needs a
different mechanism entirely.
This reads and writes an undocumented, app-internal store. It may change shape or disappear in
any Claude update. Every step is
therefore fail-open: a missing store, an unknown task, no matching session, unreadable JSON or a
missing jq all exit cleanly having written nothing. Only the one key is ever touched, nothing is
ever deleted, and a failure can never affect a task's outcome. If it stops working you lose a
convenience, not a result — the two hooks above deliver without it.
/queue-status estimates how many tasks still fit in the current 5-hour window, based on what
your previous tasks actually cost:
ocena: util 30%, ~14 tasks fit at avg cost 5.00%
q1 🌆 danes 20:09 — prazno ✔️
q2 🌙 jutri 01:09 — prazno ✔️
Only q1 is exact — it comes from the meter. q2 onward are q1 + 5 h steps, so they are the
earliest a window can open, never later than reality.
You get warned in three places, all advisory and none of them ever block a queue:
/queue-taskprints a note when the window is already TIGHT (over 70% used) or OVER (over 85%, or under 30 minutes to reset)- the chat itself warns you when you start typing into a nearly-full window, at most once every 30 minutes
- the daemon moves a task to the next window when it will not fit, and marks it BLOCKED after 3 such moves rather than shifting it forever
There is nothing to seed manually. The daemon polls every 60 seconds:
- Task queued during the current window → waits until
reset_time, then runs. - Task queued after the window already ended → runs immediately.
claude --printreturns session-limit error → waits for reset, retries.claude --printreturns auth error (401) → retries every 30 minutes.- Task hangs more than 2 hours → killed; counts as one attempt. Up to 3 retries, then
BLOCKED.
Force a run any time:
/queue-run # all PENDING tasks within ~60 s
/queue-run <id> # one specific task immediatelyYou open a chat in CC Desktop
└─ session-start-hook.sh fires (SessionStart: startup / resume)
└─ claims this project's results from last_response.md → shown in chat
You send a prompt in CC Desktop
└─ inject-hook.sh fires (UserPromptSubmit)
├─ claims this project's results from last_response.md → shown in chat
└─ warns (no auto-fix) if daemon is down or oldest task > 8 h old
daemon.sh (background, started from ~/.zshrc)
└─ every 60 s: reads tasks.md → picks next PENDING task
├─ calls get-next-reset.sh → reads .next_reset (or refreshes it)
├─ task window not over yet? → sleep, loop
└─ task ready → QUEUE_TASK_RUN=1 claude --print <task>
│ └─ the flag stops the two hooks above from
│ claiming the inbox inside the task's own session
├─ success → appends to last_response.md + HTML report + macOS notification
│ └─ nudge-session.sh writes a pickup suggestion into the
│ originating chat (only visible once that chat is
│ reopened; never affects the task)
└─ error → retry logic (see above)
One task per line, pipe-delimited:
STATUS|TIMESTAMP|RUN_AFTER|PROJECT_PATH|DESCRIPTION
Statuses: PENDING → RUNNING → DONE / FAILED / BLOCKED
Daemon not running:
pgrep -f daemon.sh
# If nothing, start it:
nohup ~/.claude/queue/daemon.sh >> ~/.claude/queue/daemon.log 2>&1 & disownWatch the daemon:
tail -f ~/.claude/queue/daemon.logCheck the current reset time:
cat ~/.claude/queue/.next_reset # "YYYY-MM-DD HH:MM CET/CEST"Override the reset time manually:
~/.claude/queue/queue-set-reset.sh 16:30 # sets today 16:30 as the next resetCookie expired / Source 3 failing:
The system degrades to now + 5 h automatically, no action needed. For live reset times again, make sure you are signed in somewhere: the Claude desktop app is enough on its own, otherwise open Firefox or Chrome and log in to claude.ai. The next daemon poll reads a fresh cookie.
If the desktop-app path specifically is not being used, the usual cause is an unapproved Keychain prompt. Run python3 ~/.claude/queue/claude-app-cookie.py once from a terminal and approve the dialog; exit 0 with a printed cookie line means it works, exit 2 means the Keychain is still refusing.
Tasks not running: Check daemon.log for the reason:
Waiting for next reset at …→ normal, waiting for the reset windowAuth broken (401)→ runclaude auth loginin a plain terminal- No output at all → daemon isn't running (see above)
- Task
BLOCKED→ run/queue-retry <id>to reset it
claude-app-cookie.py,firefox-cookie.shandchrome-cookie.pyall use an exact-domain match (host='claude.ai' OR host LIKE '%.claude.ai'in SQL;domain == "claude.ai" or domain.endswith(".claude.ai")in Python) to prevent look-alike domains from being matched.- The temporary DB copy is
chmod 0600and deleted via atrap EXIT— it never persists past the script. - Cookie values travel only as shell-variable stdout →
nscurlargument. They are never written to any log file. Note: while a request runs, the cookie is briefly present in thenscurlprocess arguments (visible viapsto your own user only) — an inherent trade-off of a CLI HTTP client, acceptable on a single-user machine. .session-cookie(manual fallback) is listed in.gitignore.
./scripts/release-check.shRuns every suite in tests/ and prints a per-suite result. tests/test-e2e-window.sh
is required, not merely included: if that file is missing the check fails rather than
passing a build nothing verified.
That distinction is not academic. v0.4.3 shipped with 423 green tests and a queue that
skipped whole 5-hour windows, because every suite cut daemon.sh at # ── Main loop and
tested only the functions above it. The loop that decides when a task runs had no test
at all, and every timing test was a snapshot of one moment, which cannot show drift.
test-e2e-window.shruns the real main loop against a simulated clock and a fakeclaude. Sleeps advance simulated time instead of waiting, so a five-hour window passes in milliseconds and every run is deterministic. It fails on the pre-fix commit and passes on the fix, which is the only evidence that a regression test is worth having.test-time-series.shcalls the timing functions over hundreds of consecutive polls with the clock moving, and asserts on the sequence: the anchor must not move while it is still ahead, must give way once it is past, and the release decision must always eventually fire.
install.sh copies scripts/pre-push-hook.sh to .git/hooks/pre-push. Pushing a tag
runs the full check first and is blocked unless everything is green. Pushing a branch is
not gated, and neither is deleting a tag: a gate slow enough to be routinely bypassed with
--no-verify protects nothing, so only the act of releasing pays for it.
./uninstall.shSee CHANGELOG.md or the GitHub Releases page.