@idlepaydev/claude-code
v0.1.1
Published
Get paid for waiting. Sponsored status line for Claude Code — 50% of ad revenue goes to you.
Maintainers
Readme
@idlepaydev/claude-code
Get paid for waiting. While Claude Code's agent is working, your status line
shows one sponsored line. Advertisers bid; 50% of ad revenue goes to you —
the developer whose terminal showed the ad. Same backend, same ~/.idlepay
account, and same anti-fraud contract as the shipping
opencode plugin (@idlepaydev/plugin); a machine signed in from
either editor is recognized by the other.
● Ship faster with Acme CI · $0.42 today · $12.80 totalIntegration approach
Claude Code has no plugin runtime that stays resident the way opencode's TUI
plugin does. It instead lets you hook two extension points in
~/.claude/settings.json, and it runs each as a short-lived process —
spawned on demand, fed JSON on stdin, then it exits. The integration is built
entirely from those one-shot processes, coordinating through small JSON files
under ~/.idlepay (the same directory the opencode plugin uses):
| Process | Wired via | Role |
| --- | --- | --- |
| statusline.js | settings.json → statusLine | Renders the sponsored line. Also the impression clock tick, the ad/earnings refresher, the receipt flusher, and the /ad-nudge scheduler. |
| hook.js <Event> | settings.json → hooks | Busy detection. Translates a turn-lifecycle event into the busy flag + presence timestamp in runtime.json. |
| ad.js | ~/.claude/commands/ad.md (the /ad command) | Click-through. Opens the current ad's URL in the default browser and reports a click. |
| commands.js <sub> | ~/.claude/commands/idlepay{signin,signout,refresh}.md | Manual sign-in / sign-out / refresh — parity with opencode's /idlepay* commands. One binary, sub-command via argv. |
| cli.js | npx @idlepaydev/claude-code setup\|update\|remove | One-command installer (and update = re-fetch @latest and re-run setup). |
State files under ~/.idlepay:
credentials.json— machine token after device sign-in (shared with opencode).runtime.json— busy flag, last human-input time, current ad + signed assignment, pending sign-in code, cached earnings.cc-clock.json— the impression clock's running state, persisted because no process survives between ticks.cc-queue.json— pending impression receipts awaiting flush.health.json— last successful API call time (drives the connection dot).
Render surface — the status line
Configured as:
{
"statusLine": {
"type": "command",
"command": "node /…/@idlepaydev/claude-code/dist/statusline.js",
"refreshInterval": 1
}
}Claude Code runs the command, pipes the session JSON on stdin (session_id,
cost, model, …), and prints whatever the command writes to stdout as the
status line (docs). We read stdin
only to drain it; all integration state comes from runtime.json. The line uses
ANSI color (a green/amber connection dot, green "today", blue "total"), matching
the opencode TUI palette.
Refresh cadence assumption (this is load-bearing). The status-line command
is our only periodic wakeup. Claude Code re-runs it after each assistant
message, debounced at 300 ms, and — when refreshInterval is set (minimum
1 second) — also on that fixed timer. We set refreshInterval: 1, so during a
busy span ticks arrive ~1 s apart. With IMPRESSION_VIEW_MS = 5_000, one
impression needs ~5 consecutive busy + present ticks. The installer sets this
for you; without it the clock simply stops accruing during idle gaps (see
below), which is safe — it never over-credits.
Busy detection — hooks
Claude Code has no "session busy" event. It does fire hooks around the agent
turn (docs). We wire hook.js to seven
events and model busy as the span between a turn-starting event and its
matching Stop, keyed per session_id:
| Event | Effect |
| --- | --- |
| UserPromptSubmit | busy = true, and refresh lastInputAt (human present). |
| PreToolUse / PostToolUse / SubagentStart | busy = true (agent mid-turn). |
| Stop / SubagentStop / SessionEnd | busy = false (turn / session ended). |
Each event's session_id (from the hook payload) keys the busy state, so the
agent view's many concurrent sessions never cross-credit each other — see
Documented limitations → Agent view. The reducer (src/busy.ts) is pure and
unit-tested. The hook process is local-only, never blocks Claude (exits 0, no
stdout), and never touches the network. The event name is passed as argv so a
single binary serves every event.
Impression accrual
The four gates for a valid impression — busy + focused + adVisible + human
presence — are unchanged from the shared gates.ts. On each status-line tick
we rehydrate the persisted ImpressionClock, feed it one tick(now, gateState),
and re-persist it. Completed impressions are queued as receipts and flushed to
POST /api/telemetry/impressions (idempotent on jti + seq, so retrying a
flush is always safe). The clock's 2 s "stalled" guard discards any tick whose
gap exceeds 2 s, so laptop sleep, a suspended process, or a quiet status line
never credit wall-clock time the user didn't actually watch.
The server is the source of truth for all anti-fraud (signed machine-bound assignments, idempotent receipts, rate caps). These client gates are honesty, not security.
Click-through — the /ad slash command
The status line renders the ad's URL as plain text, so most terminals
auto-linkify it and an alt/ctrl-click opens the sponsor directly (the same
way the sign-in URL is clickable). That click goes through the terminal, not
the app, so it drives advertiser traffic but reports no credited click — by
design, we never over-credit. The credited path is the /ad command below.
A plain status line captures no mouse clicks of its own, so the billable click
path is an explicit command. setup installs a custom slash command at
~/.claude/commands/ad.md (slash-command docs).
Running /ad:
- reads
runtime.jsonfor the current ad (url,brandName) +assignment; - if there's no active ad, prints
No active ad right now.and stops; - otherwise opens
urlin your default browser (cross-platform:starton Windows,openon macOS,xdg-openon Linux); - best-effort reports the click via
POST /api/telemetry/clicksand prints a one-line confirmation (Opened <brand> ↗).
A click bills at 6x the impression rate (hard-capped at 1/min and 5/hour
per user), and the server additionally requires a prior validated impression
for that assignment and accepts one click per assignment, so a click may be
rejected — that's expected. Billing is
best-effort: the link always opens even when the click is rejected or you're
offline. The command file uses Claude Code's inline bash injection
(!`node …/ad.js`, with allowed-tools: Bash(node …)) so invoking /ad
actually runs the helper and injects its confirmation line; remove deletes the
file only if it carries our marker.
Discoverability nudge
Because /ad is invisible until you know it exists, the status line teaches it:
roughly once every 20-30 impressions (interval re-rolled randomly in
[20, 30] each cycle) it briefly appends · ↗ /ad. The nudge is visible for
~3 seconds, then the line reverts. Since the status-line command is a
stateless one-shot, this is driven by two persisted runtime fields
(nextNudgeAtSeq, nudgeUntilMs): when the impression clock's seq reaches the
threshold we stamp a 3 s window and roll the next threshold seq + randomInt(20,
30). The decision is a pure function (src/nudge.ts) and the renderer only
reads whether the window is currently open, so both stay unit-testable.
Documented limitations (read this before live-testing)
Claude Code's extension surface is narrower than opencode's TUI, and this integration is honest about what it cannot observe:
Click-through is command-driven, not click-on-the-line. A plain status line receives no mouse events, so the line itself can't be clicked. The click path is the explicit
/adslash command instead (see Click-through above): it opens the ad and reports a click, billed at 6x (capped 1/min, 5/hr) subject to a prior impression and one-click-per-assignment. The discoverability nudge teaches users the command exists. We do not claim a click the user didn't make — only an explicit/adinvocation reports one. (The status line now OSC 8-hyperlinks the advertiser URL to our/rforwarder, which records a credited click server-side before redirecting — so on terminals that support OSC 8 (iTerm2/Kitty/WezTerm; Windows Terminal may needFORCE_HYPERLINK=1; Terminal.app no) an alt-click is billed. Where OSC 8 is unsupported it degrades to a bare, uncredited link, so/adstays the reliable always-billable path.)Weaker focus / human-presence gate. Claude Code exposes no terminal focus signal and no keypress signal to hooks or the status line (unlike the opencode TUI, which gets renderer
focus/blurand keypress events). So:focusedis always reportedtrue— we cannot detect a blurred terminal. The gate field is retained only for parity with the shared logic.- Human presence uses the last
UserPromptSubmitas its proxy (tool activity does not count, since that's the agent acting, not the human), bounded by the 15-minuteHUMAN_PRESENCE_WINDOW_MS. This is the strongest "a human is here" signal Claude Code gives us, and it is weaker than the opencode keypress signal. It is documented, not hidden.
The server-side caps and the proof-of-work economics (a busy window burns the user's own API tokens, which cost more than the ad pays) remain the primary anti-farming controls, so the weaker client gate does not change the security posture — only the honesty of client-side display.
Tick cadence depends on
refreshInterval. If a user removesrefreshIntervalfrom their settings, the status line only re-runs on assistant messages, and the clock will rarely accrue a full 5 s window. This under-counts; it never over-counts.Agent view (
claude agents) — per-session earning, paused in the dashboard. Claude Code's agent view runs many background sessions at once and hides the per-session status line (our ad surface) while the dashboard is open. Both consequences are handled:- Per-session earning. Busy is keyed by
session_id— which both the hooks and the status-line stdin carry — so the status line only accrues for the session it is actually rendering. A busy background session can't make an idle attached session earn, and a backgroundStopcan't kill an attached session's earning (src/busy.tsmarkSessionBusy/isSessionBusy; theSubagentStartandSessionEndhooks keep the per-session state keyed and cleaned up). The impression clock is likewise stored per session (files.tsreadSessionClock/writeSessionClock) — critical because every session ticks the same status-line command, and with one shared clock an idle session's tick would reset a busy session's continuous-view progress before it could reach the 5 s window, so nobody would earn. - No earning in the dashboard. The dashboard renders no status line, so no
ad is shown and the tick stops; the impression clock's 2 s stall-guard
discards the in-progress window, so we never credit an unseen ad. Earning
resumes when you attach to a working session. Proven end-to-end in
test/agentview.test.ts.
- Per-session earning. Busy is keyed by
Install
npx @idlepaydev/claude-code setupThis edits ~/.claude/settings.json:
- sets
statusLineto the render command withrefreshInterval: 1(backing up any existingstatusLineto aidlepay-backupkey, restored on remove); - appends an Idlepay hook entry (tagged with a marker) to each of the seven turn/session-lifecycle events, leaving any of your existing hooks intact;
- installs the slash commands under
~/.claude/commands/(each removed onremove, but only if it still carries our marker):/ad(+/idlepayad), and the/idlepaysignin,/idlepaysignout,/idlepayrefresh,/idlepayupdateset.
Start (or restart) Claude Code. On first run the status line shows a sign-in code:
● Idlepay · sign in at https://idlepay.dev/activate with code ABC-123Activate it once and the machine token is saved to ~/.idlepay/credentials.json.
Commands (parity with the opencode /idlepay* family)
Sign-in is automatic (the status-line tick advances the device flow) and updates are automatic (see below), so these are explicit controls for when the automatic paths misbehave:
| Command | Does |
| --- | --- |
| /ad (alias /idlepayad) | Open the current sponsor in your browser and credit the click. |
| /idlepaysignin | Start (or surface) the device-code sign-in; opens the page and shows the code. |
| /idlepaysignout | Unlink this machine: delete credentials, reset runtime, drop the queue + clock. |
| /idlepayrefresh | Force an immediate ad + earnings refresh, bypassing the 60s cadence. |
| /idlepayupdate | Update to the latest version now (bypasses the 12h throttle). |
Updating
npx @idlepaydev/claude-code updateupdate re-fetches the package at @latest and re-runs its setup, which
re-bakes the dist paths in settings.json (we wire absolute paths, not an npm
spec, so "update" means fetch-newest-and-rewire).
The integration also checks for updates on its own — but only when you open a
new window (the SessionStart hook), throttled to once / 12h via
~/.idlepay/cc-update-check.json. It deliberately does not check from the
per-second status-line tick. You can also force it in-editor with
/idlepayupdate. Either way the new version applies on the next Claude Code
restart.
Remove everything we added (your prior statusLine/hooks are preserved):
npx @idlepaydev/claude-code removeEarnings dashboard: https://idlepay.dev/dashboard.
Configuration
| Env var | Default | Purpose |
| --- | --- | --- |
| IDLEPAY_API | https://idlepay.dev | API base URL (point at staging for testing). |
Development
npm run build # tsup → dist/{cli,statusline,hook}.js
npm run typecheck # tsc --noEmit
npm run test # vitest (pure logic only; no editor, no network)Pure, unit-tested modules:
src/gates.ts—gatesOpen,ImpressionClock(+snapshot/restorefor the one-shot process model).src/busy.ts— the hook-event → busy/presence reducer and event parser.src/render.ts— the status-line text formatter.src/nudge.ts— the/ad-nudge state machine (advanceNudge,nudgeActive); pure (state + seq + now + rng → next state).src/ad.ts— the/adhelper:selectAd(pure) +runAdwith the browser open and the click report injected so the side effects are mockable.
Tests live in test/ (gates.test.ts, busy.test.ts, render.test.ts,
nudge.test.ts, ad.test.ts) and mirror the opencode plugin's Vitest style.
They cover the impression clock (including persistence and the over-long-gap
guard), gatesOpen, receipt-seq idempotency/monotonicity, the busy reducer,
line rendering (with and without the nudge), the nudge schedule (fires at the
threshold, stays 3 s then hides, re-rolls 20-30 later, deterministic under a
fixed rng), and the /ad helper (selection, the no-ad path, swallowed click
failures).
What's needed to live-test later
Not done here (per the build constraints — no npm install, no editor launch):
- Install deps + build: from the repo root,
npm installthennpm run build --workspace packages/claude-codeto producedist/. - Backend: point at staging with
IDLEPAY_API=…(or the live API). The endpoints used arePOST /api/device/{start,poll},POST /api/ads/next,POST /api/telemetry/impressions, andGET /api/me/earnings. - Wire it up:
node packages/claude-code/dist/cli.js setup. - Smoke-test the status line without Claude Code (the docs' mock-input
trick):
On first run this prints the sign-in line; after activating the code and a couple more invocations it prints the sponsored line.echo '{"session_id":"t","model":{"display_name":"Opus"}, "workspace":{"current_dir":"/tmp"}}' \ | node packages/claude-code/dist/statusline.js - Smoke-test a hook:
echo '{"hook_event_name":"UserPromptSubmit","session_id":"t"}' \ | node packages/claude-code/dist/hook.js UserPromptSubmit # then inspect ~/.idlepay/runtime.json — busy should be true echo '{}' | node packages/claude-code/dist/hook.js Stop # busy should flip back to false - Smoke-test the
/adhelper (with an active ad inruntime.json):node packages/claude-code/dist/ad.js # opens the ad URL in your browser and prints "Opened <brand> ↗" # with no active ad it prints "No active ad right now." - End-to-end in Claude Code: run
setup, restart Claude Code, submit a prompt that keeps the agent busy for >5 s, and confirm: the line appears while busy, an impression is credited (visible on the dashboard), the line is absent when idle (killswitch off / no ad), the/adcommand opens the sponsor and a click shows on the dashboard, the· ↗ /adnudge appears briefly every 20-30 impressions, andremovecleanly revertssettings.jsonand deletes~/.claude/commands/ad.md.
Things to verify on a real install that can't be unit-tested here: that
refreshInterval: 1 actually produces ~1 s ticks during a busy span on the
target Claude Code version; that the status-line command runs fast enough to
not be cancelled (it makes at most one quick HTTP call per 60 s and otherwise
only touches local files); that the Git Bash / PowerShell path handling on
Windows resolves the node …/statusline.js command correctly; and that the
/ad inline-bash injection runs the helper on the target Claude Code version.
