One short sponsored line above the prompt while Claude works. 70% of the gross goes to you, paid in crypto from $5.

One short sponsored line next to your AI — Claude Code in the terminal, the Claude app, the Codex app, Cursor and VS Code, on macOS and Windows — shown only while the model is working. The advertiser pays, and 70% of the revenue is yours, paid in crypto from $5. The table under Where it works says exactly what draws the line where.
curl -fsSL https://raw.githubusercontent.com/Hitid123/tick-client/main/bootstrap.sh | sh
If piping a stranger's script into a shell makes you uncomfortable, good. This is the same thing with a reading step, and the script is about eighty lines:
curl -fsSL https://raw.githubusercontent.com/Hitid123/tick-client/main/bootstrap.sh -o tick-install.sh
less tick-install.sh
sh tick-install.sh
Either way it downloads six files — on a Mac, also the desktop satellite and its icon — checks them against published SHA256SUMS, and stops without installing anything if a checksum does not match. It needs no root. From a clone, skip the download: cd client && ./install.sh. On a Mac, --no-desktop leaves the desktop satellite out.
Removing it is one command and leaves nothing behind:
~/.tick/uninstall.sh
Requirements: jq, Node 20+, and a terminal. That is all.
On Windows TICK shows in the Claude app (drawn by Claude Code itself, through our plugin), and over the Codex app and Cursor through a small program of ours that lays the line over their window (the same as the Mac's desktop satellite, below). It needs Node 20+. In PowerShell:
iwr https://raw.githubusercontent.com/Hitid123/tick-client/main/install-windows.mjs -OutFile $env:TEMP\tick-install.mjs
node $env:TEMP\tick-install.mjs
It checks every file against SHA256SUMS like the Mac installer, adds our hook entries to %USERPROFILE%\.claude\settings.json (backed up first), one marked block to Codex's config.toml and three entries to Cursor's hooks.json where those apps are installed, and one value under HKCU\...\CurrentVersion\Run so the strip starts at login. Codex asks you to review a new hook once: allow ours. Removing it: node %USERPROFILE%\.tick\install-windows.mjs --uninstall. In an editor on Windows, the TICK extension is the way.
Only what works today gets a yes. No roadmap entries in this table.
| Status | |
|---|---|
| Claude Code, CLI, any terminal | Yes — this is the statusLine surface |
| Claude Code inside the VS Code / Cursor terminal | Yes, same CLI, same hook |
| The Claude desktop app, macOS | Yes, through the desktop satellite — see below. The app never runs a status line command (measured, not assumed), but it does run hooks, and a small program of ours draws the line |
| The Claude desktop app, Windows | Yes, drawn by Claude Code itself through our plugin |
| Claude Code's VS Code panel | Yes, through the TICK extension |
| Codex in the VS Code panel | Yes, the same extension. Codex asks you to approve our hook once, with /hooks |
| The Codex app, macOS and Windows | Yes, the desktop strip over its window. Codex asks you to review our hook once |
| Cursor's agent, macOS and Windows | Yes, the desktop strip over its window, from Cursor's own hooks |
| OpenCode | Not yet — the plugin reports its turns; nothing draws them yet |
| Codex CLI | No. Codex's [tui] status_line takes only its own built-in fields; there is no external-command hook and no plugin surface. Nothing to install into, and we will not patch it. Two upstream requests are open for the mechanism, and contributing it is how this changes |
This is the whole reason to pick us, so here it is without hedging.
| Never leaves your machine | Sent to the server |
|---|---|
| Your prompts | creative_id — which line was displayed |
| Model responses | Number of impressions |
| File contents, file names, paths | Aggregated display time, in milliseconds |
transcript_path and the transcript itself | api_ms_delta — how long the model worked |
| Your working directory | model.id, e.g. claude-opus-5 |
| Git branches and remotes | A salted SHA-256 hash of the session id |
| Environment variables, API keys, tokens | A device fingerprint |
| Shell history | Your country, derived from the IP of the request |
Your raw session_id | A one-way code of your IP address (below) |
| Where the line was on screen: terminal, editor, desktop strip or the Claude Code mod | |
Once a day: your system (macOS, Windows, Linux), its language and region (e.g. ru_RU), time zone, this client's version, and how many sessions each agent had that day (e.g. Claude Code 3, Cursor 1); and two yes-or-no answers: is this a virtual machine, and can the system tell someone is at the keyboard |
Why the daily line. Language, region and time zone decide which ads you are shown: an advertiser buying Russian-speaking developers should reach you if that is who you are, even behind a VPN. The counts per agent tell us how many people use which tools, in total; they are what an advertiser asks before buying. The session counts come from our hook's own marks, which hold a time, an event name and an agent tag — nothing from the conversation. To stop sending it, set "profile": false in ~/.tick/config.json; the line still shows and earns.
Nobody at the keyboard, nothing counted. An agent can run for hours with nobody at the screen, and then its line is shown to no one. So every few seconds the client asks the system one number: how long since any key or the mouse was last touched, the same number that starts a screensaver. After five minutes the line stays, but it stops counting until you are back. It never learns which key or where the pointer is, and the number itself never leaves your machine. Where the system cannot say (a server over SSH, Linux without a desktop), it counts as before.
Your IP address is kept only as a code. The server turns the address of each request into a keyed one-way code and keeps that, against fraud, together with the name your client gives itself (node for ours). The address itself is never stored, and the code cannot be turned back into it.
Clicks are the one exception, and only if you make one. When a creative has a destination, the line is a terminal hyperlink. Nothing is sent while it sits there. If you Cmd+click it, your browser opens our redirect, which records that this creative was clicked and forwards you to the advertiser. That request carries what any HTTP request carries — your IP address among it. We store the click, its creative, and the time; we do not store your IP or your browser's user agent. The advertiser sees a normal visit with utm_source=tick, and never learns anything about your session. Set "links": false in ~/.tick/config.json to turn the hyperlink off entirely; the line still shows and still earns.
The session id is hashed with a random salt that is generated on your machine, stored in ~/.tick/state/device.json, and never transmitted. The server cannot link a session hash back to a session id, and cannot correlate one across machines.
Everything above is enforced by a test that runs the real scripts from this repository, captures the real HTTP payload, and fails if any forbidden string appears in it. It runs before every release.
TICK keeps itself current, so you never have to reinstall it:
~/.tick (the daemon, the status line, the hooks, the desktop strip) updates itself. Every few hours, while it runs, the daemon asks our server which files the current release is made of (a name and a SHA-256 each), downloads only the ones that differ from this repository, and keeps a file only if its hash is the one the server named. Forging an update would take both this repository and our server. Nothing about you is sent: these are plain downloads. Each file is checked before it goes live, swapped in whole, and the previous one is kept in ~/.tick/state/previous. Not every machine updates at once: each waits its own few hours after a release, so a bad one reaches few. Your settings files are never touched by an update. To stay on what you have, set "auto_update": false in ~/.tick/config.json./plugin → Marketplaces → tick → Enable auto-update (Claude Code leaves it off for marketplaces that are not Anthropic's). The plugin reminds you once a week while it is off.The Claude desktop app has no status line to draw into, so on a Mac the installer adds a small program of ours, ~/.tick/TICK.app, started at login. While a desktop session is working and Claude is the app in front, it lays a strip over the empty row under Claude's message box: a quiet Ad, the offer, the promo code. It is a separate window on top. Claude itself is not modified.
What it can see. The outer frame of the app's window — where it is and how big — which macOS gives any program without asking. And one setting per app, so the strip matches the theme you chose there: userThemeMode from Claude's config.json, appearanceTheme from the [desktop] table of Codex's config.toml, and for Cursor the kind of theme (light or dark) it records in its own storage.json for its splash screen. The Windows strip reads the same three. That is all. It asks for no Accessibility and no Screen Recording permission, so it cannot see what is inside the window, your conversation included.
What it adds. Three hook entries in ~/.claude/settings.json (UserPromptSubmit, Stop, SessionEnd), in the same form the editor extension writes, so the two never duplicate. The hook writes three fields — a timestamp, an event name, and cd for the desktop app — and never opens the transcript. One login item, ~/Library/LaunchAgents/dev.gettick.satellite.plist. macOS will tell you that "TICK can run in the background" and list it as from an unidentified developer: it is signed for your Mac, not by an Apple developer account.
What counts. Five seconds of the strip on screen while the model works, as in the terminal — and only while Claude is in front and every point of the strip is inside Claude's window and on a display. Hidden, covered or dragged away, it earns nothing.
Moving it. Claude centres its message box between its side panels, which the satellite cannot see. Drag the strip sideways to where it belongs; it stays in that row and remembers. Resizing the window does not move it; opening or closing a panel might. A click that does not drag opens the advertiser, through our redirect.
Turning it off. "desktop": {"enabled": false} in ~/.tick/config.json, or install with --no-desktop. uninstall.sh removes the app, the login item and our hook entries.
Three pieces, split so that the fast one stays fast.
statusline.sh ──reads──► ~/.tick/state/current.json (what to display)
< 30 ms ──writes─► ~/.tick/state/ticks.ndjson (what was displayed)
no network
│
▼
daemon.mjs ◄── HTTPS ──► api
(background, every 30 s)
statusline.sh runs on every status refresh, several times a minute. Claude Code kills a status line script that is still running when the next update arrives, so it does no network I/O and no thinking: it reads one file, prints one line, appends one tick, and exits 0 — even when everything around it is broken.
daemon.mjs does the rest: it turns ticks into impressions, uploads them, keeps a queue of creatives, and writes your balance to disk. It starts itself when needed and exits 15 minutes after Claude Code goes away.
Five seconds of uninterrupted display of one creative, inside a session where the model is actually working. Time is discarded when:
cost.total_api_duration_ms has not grown in the last 120 seconds.That last rule is the point. An open, forgotten terminal earns nothing. Faking impressions means genuinely burning tokens, which costs more than it pays.
/tick-dashboard.node ~/.tick/daemon.mjs --dashboard.Each opens https://gettick.dev/dashboard, signed in as this computer. Your device token never goes into the address: the page opens with a code that works once, within five minutes. There you see what you have earned and ask for a payout from $5. You can also make it an account (GitHub, Google or an email), to see it from any browser and add your other computers to one balance.
Questions, a payout that did not arrive, anything else: support@gettick.dev.
Unsold inventory goes to you. Put this in ~/.tick/config.json:
{ "own_line": "hire me: dev@example.com" }
Up to 60 characters. It appears only when no paid creative is queued, only to you, and it earns nothing. Other people's custom lines are never shown to you.
It also joins the spinner's rotation — the word that cycles while Claude works. We append to Claude Code's own verbs rather than replacing them, so the spinner still reads as Claude Code's with your line as an occasional guest. Milestones ("you earned $10") go there too.
A paid line takes the spinner too, and takes it first: a sold creative replaces the rotation, your own line and milestones only fill in when nothing is sold, and with nothing to show at all the key is removed and Claude Code's own words come back.
Nothing extra is billed for that. The impression is already counted on the status line, where there is a way to measure it; the spinner cannot report what it displayed, so it is a second placement the advertiser gets for free rather than a second sale.
The key is written as {"mode": "append", "verbs": ["your line"]} — the shape Claude Code's own settings schema accepts. The published settings reference shows a plain array; that form is rejected and does nothing, which is worth knowing if you ever set it by hand.
Rules we hold ourselves to for that one key:
mode: "append" is Claude Code's own mechanism, so its verbs stay whatever it decides they are;spinnerVerbs holds anything that is not exactly what we wrote, it is yours now and we never touch it again;"spinner": false in ~/.tick/config.json switches it off;| Path | What it is |
|---|---|
~/.tick/statusline.sh | What Claude Code runs |
~/.tick/nojq.sh | Degraded parser, used only if jq disappears |
~/.tick/daemon.mjs | Background process |
~/.tick/config.json | Your settings: own_line, api_base |
~/.tick/state/current.json | The line being displayed right now |
~/.tick/state/ticks.ndjson | Local display log, consumed and cleared every 30 s |
~/.tick/state/balance.json | Last known balance, in micro-dollars |
~/.tick/state/device.json | Device id and the hashing salt. Not synced anywhere |
~/.tick/settings.backup.*.json | Your settings.json, as it was before install |
~/.tick/hook.mjs | The activity hook, Mac only: three fields per event, no transcript |
~/.tick/state/activity/ | One file per session from the hook: time, event, agent |
~/.tick/TICK.app | The desktop satellite, Mac only |
~/.tick/state/desktop-placement.json | Where you dragged the desktop strip |
~/Library/LaunchAgents/dev.gettick.satellite.plist | Starts the satellite at login, Mac only |
The line adapts to what the terminal actually supports. Nothing here needs configuring.
| Situation | What you get | |
|---|---|---|
| Any 256-colour terminal (iTerm2, Terminal.app, Alacritty, kitty, WezTerm, Windows Terminal, VS Code) | ▌ muted, the offer bright for two seconds and then a step down, the advertiser's name and promo code in their colour (amber unless they chose another) | |
NO_COLOR set to anything, including empty | The same line with no escape sequences | |
TERM=dumb, or TERM unset | No escape sequences | |
LANG/LC_ALL set to C, POSIX or an 8-bit charset | ASCII throughout: `\ | instead of ▌, ~ instead of …, - instead of ·` |
LANG unset entirely | UTF-8, which is what every modern terminal does |
Width is measured in terminal cells, not characters. CJK and emoji take two cells, combining marks and variation selectors take none, and ANSI sequences take none. A creative in Japanese is truncated at the same visual width as one in English, and a wide glyph that would straddle the boundary is dropped rather than split. The line cannot wrap onto a second row, which is what a status line looks like when it crashes.
Colour is decided from the environment, never from isatty: the script's stdout is a pipe into Claude Code, so a tty check would disable colour for everybody.
The footer gets quieter. With any status line configured, Claude Code stops showing most footer hints: esc to interrupt, the ? for shortcuts fallback, and the hold space to speak dictation hint. That is upstream behaviour for every status line, not something we add — but you will notice it, so we would rather say it here than have you discover it. esc still interrupts; only the reminder is gone.
The folder must be trusted. Until you accept workspace trust, Claude Code refuses to run any status line command and the line stays empty. claude --debug says Status line command skipped: workspace trust not accepted.
We never patch Claude Code. No modified cli.js, no touched install, no injected webview. Outside ~/.tick we write the statusLine field in ~/.claude/settings.json, backed up first, and on a Mac three hook entries there and one login item. The desktop strip is our own window laid over Claude's, not a change to Claude.
# Is the line configured?
jq '.statusLine' ~/.claude/settings.json
# What would the line print right now?
echo '{"session_id":"x","cost":{"total_api_duration_ms":1,"total_duration_ms":1}}' \
| ~/.tick/statusline.sh
# Is the daemon alive, and what does it say?
cat ~/.tick/state/daemon.pid && tail ~/.tick/state/daemon.log
An empty line is a valid state, not a failure: it means there is nothing to show.
hooks/register.js 391 lines1// TICK for Claude Code, as a mod: the sponsored line drawn by Claude Code
2// itself, in the band above the prompt, in the terminal and in the Claude
3// app's Code tab alike. No window of ours laid over Claude's, nothing to place.
4//
5// It is one more renderer of the client that is already there, not a second
6// client. The daemon still does all the network and all the money — the one
7// in ~/.tick if the client was installed, otherwise the copy this plugin
8// carries (runtime/daemon.mjs), run with the same ~/.tick for its state:
9// it picks the line (state/current.json) and turns ticks into impressions.
10// This file reads that line, draws it while Claude works, and appends the same
11// three-second ticks every other surface appends (TZ section 4.1):
12//
13// - drawn while a turn runs, gone the moment it ends;
14// - a tick every three seconds while drawn; api_ms grows only while the
15// turn runs, so the daemon's aggregate() counts it as it counts the rest;
16// - in a terminal where our status line already shows the line, the band
17// stays empty and counts nothing: one display, one impression;
18// - in the Claude app it tells the desktop strip to stand aside
19// (state/mod-desktop.json, every three seconds while the session lives
20// there), for the same reason.
21//
22// What it reads: the files of ours in ~/.tick, the session's id, whether a
23// turn is running, and Claude's theme setting. Never a prompt, a message or a
24// tool call: no hook here is on any of those events.
25
26// Out the moment the turn ends: the owner, 08.10, "as soon as it stops, it
27// should go". It used to stay four seconds.
28const LINGER_MS = 0
29const TICK_MS = 3000
30const BEAT_MS = 3000
31
32const ACCENTS = {
33 amber: ['#FFB000', '#8F5600'], green: ['#3DD68C', '#1A7044'], teal: ['#33D6C9', '#0D6E66'],
34 blue: ['#62AEFF', '#1F5FBF'], violet: ['#B794FF', '#6A3FC2'], pink: ['#FF85BE', '#A8235F'],
35 coral: ['#FF7466', '#B42318'],
36}
37
38let home = '' // ~/.tick
39let node = 'node' // the Node that runs the daemon
40let sid = ''
41let ourStatusLine = false
42let creative = null // the live line from current.json, or null
43let working = false
44let workEndedAt = 0
45let lastShown = { terminal: 0, desktop: 0 }
46let renderWorking = false
47let counter = { api: 0, dur: 0, last: 0 }
48let pending = [] // tick lines not yet appended
49let lightTheme = false
50
51export function register(on) {
52 on('session.start', async ($, e, next) => {
53 inApp = e.surface === 'desktop'
54 await $.command.register({ name: 'tick', description: 'TICK: what the sponsored line is doing' })
55 await $.command.register({ name: 'tick-dashboard', description: 'TICK: open your earnings and payouts in the browser' })
56 await setUp($)
57 $.clock.every(1000, () => pulse($))
58 // While a turn runs and nothing is on screen yet, look for a line four
59 // times a second: one arriving mid-turn shows within a quarter second.
60 $.clock.every(250, () => (working && !creative ? refresh($) : undefined))
61 $.clock.every(TICK_MS, () => count($))
62 $.clock.every(30000, () => ensureDaemon($))
63 await hint($)
64 return next(e)
65 })
66
67 // e.text is the prompt: never read. Only the fact that a turn began.
68 on('turn.start', async ($, e, next) => {
69 working = true
70 // The line as it stands this instant, not as of the last second's read,
71 // so the band lights up with the turn rather than up to a second after.
72 await refresh($)
73 $.ui.invalidate('ui.render')
74 // A daemon that went idle between turns is woken, without holding the
75 // turn up for it.
76 void ensureDaemon($)
77 return next(e)
78 })
79
80 on('turn.complete', async ($, e, next) => {
81 working = false
82 workEndedAt = await $.clock.now()
83 $.ui.invalidate('ui.render')
84 return next(e)
85 })
86
87 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88 const surface = e.surface === 'desktop' ? 'desktop' : 'terminal'
89 const now = await $.clock.now()
90 renderWorking = e.props.isWorking
91 const visible = e.props.isWorking || working || now - workEndedAt < LINGER_MS
92 if (!creative || !visible || (surface === 'terminal' && ourStatusLine)) return next(e)
93
94 lastShown[surface] = now
95
96 const { Box, Text, Link } = $.ui.resolve(e)
97 const pair = ACCENTS[creative.accent] || ACCENTS.amber
98 const accent = surface === 'desktop' && lightTheme ? pair[1] : pair[0]
99 const parts = split(creative.text, creative.promo_code)
100 const words = [
101 parts.name ? Text({ color: accent, bold: true, children: [parts.name] }) : null,
102 Text({ color: 'text', children: [parts.before] }),
103 parts.promo ? Text({ color: accent, bold: true, children: [parts.promo] }) : null,
104 parts.after ? Text({ color: 'text', children: [parts.after] }) : null,
105 ].filter(Boolean)
106 return Box({
107 flexDirection: 'row',
108 children: [
109 Text({ color: 'subtle', children: ['Ad '] }),
110 creative.click_url && (surface === 'desktop' || terminalLinks)
111 ? Link({ href: creative.click_url, children: words })
112 : Box({ flexDirection: 'row', children: words }),
113 ],
114 })
115 })
116
117 on('command.run', { command: 'tick' }, async ($) => {
118 const bundled = await $.fs.exists(`${$.plugin.root}/runtime/daemon.mjs`)
119 const shown = (await $.clock.now()) - Math.max(lastShown.terminal, lastShown.desktop) < TICK_MS * 2
120 const line = creative ? `“${creative.text}”` : 'nothing sold right now'
121 return {
122 text: [
123 `line: ${line}`,
124 `drawn: ${shown ? 'yes' : 'not right now (only while Claude works)'}`,
125 `terminal: ${ourStatusLine ? 'the status line shows it, so the band stays empty' : 'the band shows it'}`,
126 `home: ${home}${bundled ? '' : ' (the plugin\'s own daemon copy is missing: reinstall the plugin)'}`,
127 noNode ? 'Node.js was not found: install it from nodejs.org and start a new session' : `daemon: started with ${node}`,
128 `updates: the daemon updates itself${autoUpdate ? '; this plugin too' : '; this plugin only when you turn on auto-update in /plugin → Marketplaces → tick'}`,
129 await accountLine($),
130 ].join('\n'),
131 }
132 })
133
134 // The cabinet, opened in the browser as this computer. The plugin's own
135 // daemon copy does it, so an older ~/.tick/daemon.mjs is no obstacle: it
136 // trades the device token for a one-time code and opens the page with that.
137 on('command.run', { command: 'tick-dashboard' }, async ($) => {
138 if (noNode) return { text: 'Node.js was not found: install it from nodejs.org and start a new session.' }
139 let r
140 try {
141 const run = await $.process.run([node, `${$.plugin.root}/runtime/daemon.mjs`, '--dashboard'], { env: { TICK_HOME: home }, timeoutMs: 20000 })
142 r = JSON.parse(String(run.stdout).trim().split('\n').pop() || '{}')
143 } catch { r = null }
144 if (!r) return { text: 'The dashboard could not be opened: open https://gettick.dev/dashboard and sign in.' }
145 if (!r.opened) return { text: `No browser could be opened here. Open this address yourself, within five minutes:\n${r.url}` }
146 if (!r.registered) return { text: 'Opened the dashboard. This computer has not registered yet: it does so the first time Claude works with TICK on, so for now sign in there.' }
147 if (!r.signed_in) return { text: 'Opened the dashboard, but the server did not answer for this computer: sign in there, or try again in a minute.' }
148 return { text: 'Opened your TICK dashboard in the browser, signed in as this computer.' }
149 })
150}
151
152// ------------------------------------------------------------------ helpers
153
154// Claude Code updates a plugin from a marketplace like ours only when that
155// marketplace has auto-update on, and it is off by default for every
156// marketplace that is not Anthropic's. A plugin cannot turn it on itself, so
157// where it is off, the person is told how (hint, above). The daemon, which
158// does the real work, updates itself either way.
159let autoUpdate = false
160
161// One line at the start of a session, at most, and the most useful one:
162// a welcome the first time, then an account once there is money to keep,
163// then auto-update. Each repeats no more than once a week.
164async function hint($) {
165 try {
166 const now = await $.clock.now()
167 const shown = async (key, everyMs) => {
168 const last = await $.store.get(key)
169 if (typeof last === 'number' && now - last < everyMs) return true
170 await $.store.set(key, now)
171 return false
172 }
173 if ((await $.store.get('welcomed')) === undefined) {
174 await $.store.set('welcomed', now)
175 $.ui.toast('TICK is on: a short sponsored line shows while Claude works, and pays you 70%. Your earnings: /tick-dashboard', { timeoutMs: 12000 })
176 await checkAutoUpdate($)
177 return
178 }
179 const b = await balance($)
180 if (b && b.accrued > 0 && b.account === false && !(await shown('accountHint', WEEK))) {
181 $.ui.toast(`TICK: you have earned $${(b.accrued / 1e6).toFixed(2)}. Make an account so it stays yours if you reinstall, on one balance across computers: /tick-dashboard`, { timeoutMs: 15000 })
182 return
183 }
184 if (!(await checkAutoUpdate($)) && !(await shown('autoUpdateHint', WEEK))) {
185 $.ui.toast('TICK: to get new versions by itself, turn on auto-update in /plugin → Marketplaces → tick', { timeoutMs: 12000 })
186 }
187 } catch { /* a hint, never a failure */ }
188}
189const WEEK = 7 * 24 * 3600 * 1000
190
191// The balance the daemon last heard from the server, with whether it is in an account.
192async function balance($) {
193 try {
194 const b = JSON.parse(await $.fs.read(`${home}/state/balance.json`))
195 return { accrued: Number(b.accrued) || 0, account: typeof b.account === 'boolean' ? b.account : null }
196 } catch { return null }
197}
198
199async function accountLine($) {
200 const b = await balance($)
201 if (!b || b.account === null) return 'account: open /tick-dashboard to see your earnings'
202 return b.account
203 ? `account: yes, earned $${(b.accrued / 1e6).toFixed(2)} in all`
204 : `account: not yet, $${(b.accrued / 1e6).toFixed(2)} earned on this computer; make one at /tick-dashboard`
205}
206
207// True when it is on, or there is nothing to turn on.
208async function checkAutoUpdate($) {
209 try {
210 const settings = await $.settings.read()
211 const entry = settings?.extraKnownMarketplaces?.tick
212 let known = null
213 try {
214 const user = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
215 const dir = ((await $.env.get('CLAUDE_CONFIG_DIR')) || `${user}/.claude`).replace(/\\/g, '/')
216 known = JSON.parse(await $.fs.read(`${dir}/plugins/known_marketplaces.json`))?.tick ?? null
217 } catch { known = null }
218 if (!entry && !known) { autoUpdate = true; return true } // not installed from our marketplace: nothing to turn on
219 autoUpdate = typeof entry?.autoUpdate === 'boolean' ? entry.autoUpdate : known?.autoUpdate === true
220 return autoUpdate
221 } catch { return true }
222}
223
224async function setUp($) {
225 const explicit = await $.env.get('TICK_HOME')
226 // Windows first: under Git Bash HOME reads /c/Users/..., a path Windows
227 // programs, and so $.fs, do not open. USERPROFILE is the real one there and
228 // absent on a Mac.
229 const user = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
230 home = (explicit || `${user}/.tick`).replace(/\\/g, '/')
231 sid = await $.session.id()
232 const program = (await $.env.get('TERM_PROGRAM')) || ''
233 const term = (await $.env.get('TERM')) || ''
234 terminalLinks = Boolean(await $.env.get('WT_SESSION')) || Boolean(await $.env.get('KITTY_WINDOW_ID'))
235 || /iTerm|vscode|WezTerm|ghostty|Hyper|Tabby|rio|WarpTerminal/i.test(program)
236 || /kitty|ghostty|alacritty|foot|wezterm/i.test(term)
237 try {
238 const settings = await $.settings.read()
239 ourStatusLine = String(settings?.statusLine?.command ?? '').includes('.tick')
240 } catch { ourStatusLine = false }
241 // Windows: the installer wrote the Node it ran with, since a login item has
242 // no PATH to count on.
243 try { node = (await $.fs.read(`${home}/node-path.txt`)).trim() || 'node' } catch { node = 'node' }
244 try {
245 const config = JSON.parse(await $.fs.read(`${user}/Library/Application Support/Claude/config.json`))
246 lightTheme = config.userThemeMode === 'light'
247 } catch { lightTheme = false }
248 await ensureDaemon($)
249 await refresh($)
250}
251
252// Once a second. The band is drawn when Claude Code redraws it, and it does
253// not redraw on its own while nothing changes: until 08.10 the band went
254// stale mid-turn, the strip over the window took that for "the mod is gone"
255// and came back, and the mod's own ticks, timed from redraws, counted nothing.
256// So while a turn runs, or lingers, the band is redrawn every second, and the
257// strip is told every few seconds that the line is on screen here.
258async function pulse($) {
259 await refresh($)
260 const now = await $.clock.now()
261 // Where this session is drawn. Not settled at session.start in the Claude
262 // app (its surface attaches after), so asked again every few seconds.
263 if (now - surfacesAt > 5000) {
264 surfacesAt = now
265 try { inApp = (await $.session.surfaces()).includes('desktop') } catch { /* keep the last answer */ }
266 }
267 // And once more when it should go: without that last redraw the band kept
268 // its line after the linger, on screen and no longer counted (08.10).
269 const visible = Boolean(creative) && (working || renderWorking || now - workEndedAt < LINGER_MS)
270 if (visible || wasVisible) $.ui.invalidate('ui.render')
271 wasVisible = visible
272 // As long as this session lives in the Claude app, not only while the line
273 // shows: otherwise the strip saw a stale signal at the start of every turn
274 // and showed itself for the second before the band did.
275 if ((inApp || now - lastShown.desktop < 2000) && now - lastBeat > BEAT_MS) {
276 lastBeat = now
277 await $.fs.write(`${home}/state/mod-desktop.json`, JSON.stringify({ ts: now }))
278 }
279}
280let lastBeat = 0
281let wasVisible = false
282let inApp = false
283let lastUsd = null
284// Whether the terminal turns a link into a clickable word. Where it cannot,
285// Claude Code prints the address after the text — the owner's PowerShell
286// window on 08.10 showed the line followed by gettick.dev/c/… — so there the
287// line goes out as plain text. Known by what the terminal says about itself.
288let terminalLinks = false
289let surfacesAt = 0
290
291// The live line, as the daemon left it. Redrawn only when it changes.
292async function refresh($) {
293 let next = null
294 try {
295 const c = JSON.parse(await $.fs.read(`${home}/state/current.json`))
296 if (c && typeof c.text === 'string' && c.text && c.expires_at > (await $.clock.now())) {
297 next = {
298 id: typeof c.creative_id === 'string' ? c.creative_id : null,
299 text: clean(c.text).slice(0, 60),
300 promo_code: typeof c.promo_code === 'string' ? clean(c.promo_code) : '',
301 accent: typeof c.accent === 'string' ? c.accent : 'amber',
302 click_url: safeUrl(c.click_url),
303 }
304 }
305 } catch { next = null }
306 const changed = JSON.stringify(next) !== JSON.stringify(creative)
307 creative = next
308 if (changed) $.ui.invalidate('ui.render')
309}
310
311// One tick per period while the band shows, as every other surface does.
312async function count($) {
313 const now = await $.clock.now()
314 // Drawn within the last two seconds: with a redraw every second while it
315 // shows, a gap longer than that means it is not on screen.
316 const shown = now - Math.max(lastShown.terminal, lastShown.desktop) < 2000
317 if (!shown || !creative || !creative.id) {
318 counter = { api: 0, dur: 0, last: 0 }
319 } else {
320 const elapsed = counter.last > 0 ? Math.max(0, Math.min(now - counter.last, TICK_MS * 2)) : 0
321 // Model time grows only when the model really answered: the session's
322 // cost went up since the last tick. That is the terminal's own rule, where
323 // cost.total_api_duration_ms moves as each response lands, and it is the
324 // whole economic defence: the owner's PowerShell on 08.10 showed the line
325 // through "API error · Retrying", a turn running with no model behind it,
326 // which a turn-boundary rule would have paid for. Where the host keeps no
327 // cost ledger, the turn boundaries are all there is.
328 let busy = working || renderWorking
329 try {
330 const usd = (await $.session.usage()).cost?.usd
331 if (typeof usd === 'number') {
332 busy = lastUsd !== null && usd > lastUsd
333 lastUsd = usd
334 }
335 } catch { /* keep the turn rule */ }
336 counter = { api: counter.api + (busy ? elapsed : 0), dur: counter.dur + elapsed, last: now }
337 pending.push(JSON.stringify({
338 ts: now, sid: `mod:${sid}`, cid: creative.id, api_ms: counter.api, dur_ms: counter.dur, model: null,
339 }))
340 }
341 if (pending.length === 0) return
342 // $.fs.write replaces a file; ticks.ndjson is shared with the status line
343 // and drained by the daemon, so lines are appended by a one-line Node
344 // process, the way the other surfaces append.
345 const lines = pending.join('\n') + '\n'
346 pending = []
347 try {
348 await $.process.run([node, '-e', APPEND, `${home}/state/ticks.ndjson`], { stdin: lines, timeoutMs: 5000 })
349 } catch { /* the next period tries again with its own ticks; these are dropped, never doubled */ }
350}
351
352const APPEND = "const fs=require('fs');const f=process.argv[1];try{if(fs.statSync(f).size>2097152)fs.writeFileSync(f,'')}catch{}fs.appendFileSync(f,fs.readFileSync(0))"
353
354// The daemon does the network. Started if it is not running, detached so it
355// outlives the session; it refuses to run twice by itself.
356async function ensureDaemon($) {
357 try {
358 const r = await $.process.run([node, '-e', LAUNCH, home, `${$.plugin.root}/runtime/daemon.mjs`], { timeoutMs: 5000 })
359 noNode = r.exitCode !== 0 && /not found|ENOENT/i.test(String(r.stderr))
360 } catch { noNode = true }
361}
362let noNode = false
363
364// The installed client's daemon if there is one, else the plugin's own copy;
365// either way with ~/.tick as its home, and either way only one runs.
366const LAUNCH = "const fs=require('fs'),p=require('path'),{spawn}=require('child_process');const h=process.argv[1];let alive=false;try{process.kill(+fs.readFileSync(p.join(h,'state','daemon.pid'),'utf8'),0);alive=true}catch{}const own=p.join(h,'daemon.mjs');const d=fs.existsSync(own)?own:process.argv[2];if(!alive&&fs.existsSync(d))spawn(process.execPath,[d],{detached:true,stdio:'ignore',windowsHide:true,env:{...process.env,TICK_HOME:h}}).unref()"
367
368// The advertiser's name (before an early colon), and the promo code: its
369// first occurrence after the name. The same split as every other surface.
370function split(text, promo) {
371 const i = text.indexOf(':')
372 const name = i > 0 && [...text.slice(0, i)].length <= 24 ? text.slice(0, i) : ''
373 const rest = name ? text.slice(name.length) : text
374 const at = promo ? rest.indexOf(promo) : -1
375 if (at < 0) return { name, before: rest, promo: '', after: '' }
376 return { name, before: rest.slice(0, at), promo, after: rest.slice(at + promo.length) }
377}
378
379// A line, never a cursor movement: escapes, control characters of both
380// ranges and invisible direction tricks go. The daemon has already cleaned
381// it; Claude Code draws this into a terminal, so it is checked again here.
382function clean(s) {
383 return String(s)
384 .replace(/\u001b(\][^\u0007\u001b]*(\u0007|\u001b\\)?|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g, '')
385 .replace(/[\u0000-\u001f\u007f-\u009f\u200b\u200c\u200e\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, ' ')
386}
387
388function safeUrl(u) {
389 return typeof u === 'string' && /^https?:\/\/[A-Za-z0-9._~:/?#@!$&()*+,;=%-]+$/.test(u) ? u : ''
390}
391