Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

124 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

claude-code-queue

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), BSD date -j, and /usr/bin/shlock — none of which exist on Linux or Windows.

What it does

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.

Requirements

  • macOS (Ventura or later recommended)
  • jqbrew 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 cryptography module; the Chrome path additionally needs pip3 install browser-cookie3. The Firefox path has no Python dependency

Install

git clone https://github.com/davorinpavlica/claude-code-queue
cd claude-code-queue
./install.sh

install.sh does everything:

  • Creates ~/.claude/queue/ and symlinks all scripts
  • Registers the UserPromptSubmit hook and statusline in ~/.claude/settings.json
  • Adds daemon auto-start to ~/.zshrc (login-shell context where claude --print works)

Components

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

How reset-time detection works

Debugging a wrong or moving reset time? Read docs/RESET-TIME.md first. It documents what reset_at actually 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.

Source 1: cached .next_reset (if still in the future)

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.

Source 3: Claude desktop app, Firefox or Chrome → nscurl (automatic, recommended)

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 the Claude Safe Storage item in the macOS Keychain. The script copies the locked DB to a temporary file (mode 0600, deleted on exit), decrypts sessionKey, cf_clearance and lastActiveOrg, 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 (mode 0600, 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 the browser_cookie3 Python 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.

Source 4: rusted-claude-meter (automatic, if installed)

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.

Source 5: now + 5 h (safe degradation)

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

Status line

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.

Where the numbers come from

Two sources, tried in order. Both return the same fields, so the cache looks identical either way apart from its source marker.

  1. OAuth API. If Claude Code was authenticated through the CLI login flow, it stores a claudeAiOauth.accessToken in ~/.claude/.credentials.json or in the Claude Code-credentials Keychain item. statusline.sh uses that token against api.anthropic.com/api/oauth/usage. The cache records "source": "api".
  2. Session cookie. When there is no such token, or the token no longer works, statusline.sh calls fetch-usage.sh instead and takes the same figures from claude.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.json

Usage

Queue a task

From 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"

Where the result shows up

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 desktop-app nudge (optional, best-effort)

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.

Knowing whether a task will fit

/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-task prints 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

The daemon handles the rest

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 --print returns session-limit error → waits for reset, retries.
  • claude --print returns 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 immediately

Data flow

You 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)

tasks.md format

One task per line, pipe-delimited:

STATUS|TIMESTAMP|RUN_AFTER|PROJECT_PATH|DESCRIPTION

Statuses: PENDINGRUNNINGDONE / FAILED / BLOCKED

Troubleshooting

Daemon not running:

pgrep -f daemon.sh
# If nothing, start it:
nohup ~/.claude/queue/daemon.sh >> ~/.claude/queue/daemon.log 2>&1 & disown

Watch the daemon:

tail -f ~/.claude/queue/daemon.log

Check 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 reset

Cookie 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 window
  • Auth broken (401) → run claude auth login in a plain terminal
  • No output at all → daemon isn't running (see above)
  • Task BLOCKED → run /queue-retry <id> to reset it

Security

  • claude-app-cookie.py, firefox-cookie.sh and chrome-cookie.py all 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 0600 and deleted via a trap EXIT — it never persists past the script.
  • Cookie values travel only as shell-variable stdout → nscurl argument. They are never written to any log file. Note: while a request runs, the cookie is briefly present in the nscurl process arguments (visible via ps to 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.

Tests, and the one that matters

./scripts/release-check.sh

Runs 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.sh runs the real main loop against a simulated clock and a fake claude. 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.sh calls 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.

Release gate

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

./uninstall.sh

Changelog

See CHANGELOG.md or the GitHub Releases page.

About

Run queued Claude Code tasks automatically when your 5-hour session limit resets - a macOS daemon that doses work across reset windows so you never hit the cap!

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages