SLOPSHOPPER

tick

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

newbandcommandtoastprocesstimer
★ 1v0.1.12MITupdated 2026-10-12Hitid123/tick-client/claude-mod/tick
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tick
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ tick │ ⏺ Read(src/auth.ts) │ TICK is on: a short sponsored line shows │ ⎿ Read 6 lines │ while Claude works, and pays you 70%. Your │ ⏺ Update(src/auth.ts) │ earnings: /tick-dashboard │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tick ⎿ tick: line: nothing sold right now ⎿ tick: drawn: not right now (only while Claude works) ⎿ tick: terminal: the band shows it ⎿ tick: home: /Users/dev/.tick (the plugin's own daemon copy is missing: reinstall the plugin) ⎿ tick: daemon: started with node ⎿ tick: updates: the daemon updates itself; this plugin too ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

TICK client

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.

Install

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.

Windows: the Claude app, the Codex app, Cursor

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.

Where it works

Only what works today gets a yes. No roadmap entries in this table.

Status
Claude Code, CLI, any terminalYes — this is the statusLine surface
Claude Code inside the VS Code / Cursor terminalYes, same CLI, same hook
The Claude desktop app, macOSYes, 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, WindowsYes, drawn by Claude Code itself through our plugin
Claude Code's VS Code panelYes, through the TICK extension
Codex in the VS Code panelYes, the same extension. Codex asks you to approve our hook once, with /hooks
The Codex app, macOS and WindowsYes, the desktop strip over its window. Codex asks you to review our hook once
Cursor's agent, macOS and WindowsYes, the desktop strip over its window, from Cursor's own hooks
OpenCodeNot yet — the plugin reports its turns; nothing draws them yet
Codex CLINo. 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

What is sent, and what is not

This is the whole reason to pick us, so here it is without hedging.

Never leaves your machineSent to the server
Your promptscreative_id — which line was displayed
Model responsesNumber of impressions
File contents, file names, pathsAggregated display time, in milliseconds
transcript_path and the transcript itselfapi_ms_delta — how long the model worked
Your working directorymodel.id, e.g. claude-opus-5
Git branches and remotesA salted SHA-256 hash of the session id
Environment variables, API keys, tokensA device fingerprint
Shell historyYour country, derived from the IP of the request
Your raw session_idA 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.

Updates

TICK keeps itself current, so you never have to reinstall it:

  • The client in ~/.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.
  • The Claude Code plugin updates when its marketplace has auto-update on. Our installers turn it on; if you installed the plugin yourself, run /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 VS Code / Cursor extension updates through the marketplace, as every extension does.

The desktop satellite (macOS)

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.

How it works

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.

What counts as an impression

Five seconds of uninterrupted display of one creative, inside a session where the model is actually working. Time is discarded when:

  • more than 10 seconds pass between two ticks (the laptop was shut, the window was idle);
  • the creative changed between two ticks;
  • 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.

Your earnings and payouts

  • In Claude Code: type /tick-dashboard.
  • In VS Code or Cursor: run TICK: Open dashboard.
  • From a terminal: 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.

Your own line

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:

  • we append, never replace, and mode: "append" is Claude Code's own mechanism, so its verbs stay whatever it decides they are;
  • we write only when the value would actually change, which is once when you set a line and once per milestone — not on a schedule;
  • if 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;
  • uninstall removes only what we put there.

Files

PathWhat it is
~/.tick/statusline.shWhat Claude Code runs
~/.tick/nojq.shDegraded parser, used only if jq disappears
~/.tick/daemon.mjsBackground process
~/.tick/config.jsonYour settings: own_line, api_base
~/.tick/state/current.jsonThe line being displayed right now
~/.tick/state/ticks.ndjsonLocal display log, consumed and cleared every 30 s
~/.tick/state/balance.jsonLast known balance, in micro-dollars
~/.tick/state/device.jsonDevice id and the hashing salt. Not synced anywhere
~/.tick/settings.backup.*.jsonYour settings.json, as it was before install
~/.tick/hook.mjsThe 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.appThe desktop satellite, Mac only
~/.tick/state/desktop-placement.jsonWhere you dragged the desktop strip
~/Library/LaunchAgents/dev.gettick.satellite.plistStarts the satellite at login, Mac only

Terminals

The line adapts to what the terminal actually supports. Nothing here needs configuring.

SituationWhat 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 emptyThe same line with no escape sequences
TERM=dumb, or TERM unsetNo escape sequences
LANG/LC_ALL set to C, POSIX or an 8-bit charsetASCII throughout: `\ instead of ▌, ~ instead of …, - instead of ·`
LANG unset entirelyUTF-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.

Known trade-offs

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.

Troubleshooting

# 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.

Source 1 files
hooks/register.js 391 lines
1// 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