Get paid for the one-line sponsored messages Clickback shows above your prompt after a turn. Ads go to you, never to Claude. Connect each computer once, from…

Clickback pays you for the one-line sponsored messages it shows you above the prompt after Claude finishes a turn:
Sponsored · Shipfast: Deploy Laravel apps in 30 seconds → shipfast.example.com
❯
The whole line is a link to the ad, so clicking anywhere on it opens it. The ad goes to you, not to Claude. It doesn't cost you any tokens and Claude never sees it. It goes away when you send your next prompt.
It works in Claude Code in a terminal and in the Code tab of the Claude Desktop app. It needs Claude Code 2.1.287 or later, the first version with mods. Check with claude --version.
You need a Clickback account at https://clickback.dev. In Claude Code:
/plugin marketplace add clickbackads/plugin
/plugin install clickback@clickback
Until this computer is connected, the line above the prompt asks you to connect it:
Clickback · Connect this computer to start earning: https://clickback.dev/link (code ABCD-EFGH)
The line goes within a few seconds, and ads start after your next turn. Every session on the computer shows the same code, and connecting once covers them all. A code lasts 15 minutes; after that, the next turn shows a new one.
To disconnect a computer, use the Connect your agent page of your Clickback dashboard. The next turn then asks you to connect again.
The plugin also has an MCP server with tools you use by asking Claude. They sign in separately, with Claude Code's own /mcp:
/mcp.plugin:clickback:clickback and choose Authenticate.In the Claude Desktop app, which has no /mcp panel, run claude mcp login plugin:clickback:clickback in a terminal instead. Ads don't need this sign-in.
hooks/clickback.ts). Mods are small modules that run inside a Claude Code session. After a turn ends, this one asks Clickback for an ad and draws it in the band above the prompt. The first time the ad is drawn, it tells Clickback, and that's when you're paid for it. Requests run after the turn has finished, so they never hold up Claude, and each gives up after 3 seconds. It asks at most once a minute, counted across your terminal sessions (the Desktop app keeps its own count), and Clickback spaces ads out further for your whole account, so most turns show nothing. It skips claude -p, the Agent SDK and the VS Code panel, where nothing it draws would be seen. Run claude plugin validate on this folder to see exactly which events it hooks and what it calls.<server>/mcp with the account tools: earnings, get_profile, update_profile, recent_ads and report_ad.The mod keeps these in the plugin's own store, a JSON file under ~/.claude/plugins/store/ that every session on the computer shares and other mods can't read:
token: the ad token you got by connecting. It fetches ads and confirms them, and nothing else. It can't read your earnings or change your account.link: the code waiting for you to press Connect, while there is one.linkPolledAt: when any session last checked that code, so open sessions take turns checking it rather than all asking at once.lastAskedAt: when any session last asked for an ad.It sends Clickback:
claude-code, or claude-code-desktop in the Desktop app), then the link's device code every few seconds until you press Connect. However many sessions are open, one of them checks each time.surface=mod, which app you're in and the session id.Your prompts, Claude's replies, the transcript and your working directory never leave your machine. The mod adds nothing to what Claude reads, never changes Claude's messages and touches no tool call. Its requests are plain HTTP from the mod itself, not tool calls, so they never go through Claude or a permission prompt.
The MCP tools only read and change your own Clickback account.
Most turns show nothing on purpose: the plugin asks at most once a minute and Clickback leaves a couple of minutes between ads.
The plugin never adds lines to your transcript. It writes what happened at each step to Claude Code's debug log, led by clickback: for example asking for an ad, got ad 41 from shipfast.example.com and confirmed ad 41 as shown, charged: yes, or why it didn't ask or what failed. Start Claude Code with claude --debug to write that log. The Desktop app doesn't write one.
Collapse the band for a while with ctrl+x ctrl+a. To stop ads, disable the plugin in /plugin, or uninstall it with /plugin uninstall clickback@clickback. If you're done with it for good, also disconnect the computer on the Connect your agent page, so its ad token stops working.
claude plugin validate plugins/clickback
claude plugin test plugins/clickbackhooks/clickback.ts 901 lines1// Clickback mod: shows you one sponsored line above the prompt after Claude
2// finishes a turn, and pays you for it.
3//
4// Display only. The ad is drawn in the AbovePrompt band and kept in $.state.
5// It never goes to the model: this module adds no prompt context, rewrites no
6// message and touches no tool call.
7//
8// Linking: the mod fetches ads over plain HTTP ($.http.fetch) with an ad token
9// of its own, kept in $.store. It isn't a tool call, so no permission mode
10// stops it. Until this computer has a token, the band shows a link and a code
11// to approve in the browser, and the mod polls until the person does. The
12// pending link sits in $.store too, so every session shows the same code, and
13// the sessions take turns polling it.
14//
15// Paying: the server only charges for an ad, and credits you, once the mod
16// says it drew it. The first time the band actually draws an ad, the mod
17// confirms it with one request to the ad's shown_url.
18//
19// It asks at most once a minute, across the Claude Code sessions that share
20// this plugin's $.store (the time of the last ask is kept there). Terminal
21// sessions share one store; the Desktop app loads the plugin its own way and
22// keeps another. A turn that ends sooner shows nothing new.
23//
24// Privacy: it sends Clickback the session id, surface=mod and which app you
25// are in. Your prompts, Claude's replies, the transcript and your working
26// directory never leave this machine.
27//
28// It never gets in the way: requests run on timers after the turn has
29// finished, give up after a few seconds, and any failure (no ad, a slow or
30// broken server, an answer it doesn't understand) shows no ad. The reason goes
31// to the debug log (`claude --debug`), never the transcript.
32
33import type { EngineInterface, On, PluginOptions, RenderSurface, Timer } from 'claude-code'
34
35import type { ClickbackAd, ClickbackLink } from '../types'
36
37/** The ad on screen, held by the host so a hot reload keeps it. */
38const AD = { plugin: 'clickback', key: 'ad' } as const
39
40/** How many prompts this session has submitted so far. */
41const PROMPTS = { plugin: 'clickback', key: 'prompts' } as const
42
43/** The line asking the person to connect this computer, while a link is pending. */
44const LINK = { plugin: 'clickback', key: 'link' } as const
45
46export const LABEL = 'Sponsored'
47export const CONNECT = 'Clickback · Connect this computer to start earning:'
48export const SURFACE = 'mod'
49export const TIMEOUT_MS = 3000
50
51/** At most one ad request this often, from every session on the computer. */
52export const INTERVAL_MS = 60_000
53
54/**
55 * The longest `Link` href, percent-encoded. A longer one keeps the whole band
56 * from drawing, so an ad or approval page with one isn't shown.
57 */
58export const MAX_HREF = 2048
59
60/** How many times a draw may try to confirm one ad. */
61export const CONFIRM_TRIES = 3
62
63/** The $.store key holding when any session last asked, in epoch milliseconds. */
64export const LAST_ASKED = 'lastAskedAt'
65
66/** The $.store key holding the ad token. */
67export const TOKEN = 'token'
68
69/** The $.store key holding the link waiting for approval, a PendingLink. */
70export const PENDING_LINK = 'link'
71
72/**
73 * The $.store key holding when any session last polled the pending link, as
74 * `{ deviceCode, at }` (epoch milliseconds), so open sessions take turns.
75 */
76export const LINK_POLLED = 'linkPolledAt'
77
78/**
79 * A poll goes out once the last one, from any session, is at least the
80 * link's interval less this old, so one session always gets through each
81 * interval though their timers drift.
82 */
83export const POLL_SLACK_MS = 1000
84
85/** A link waiting for the person to approve it, as kept in $.store. */
86export type PendingLink = {
87 deviceCode: string
88 userCode: string
89 /** verification_uri, shown in the connect line. */
90 uri: string
91 /** verification_uri_complete, which the link opens. */
92 url: string
93 /** When it stops working, in epoch milliseconds. */
94 expiresAt: number
95 /** How often to poll, in seconds. */
96 interval: number
97}
98
99type Served = Omit<ClickbackAd, 'prompt'>
100
101type Answer = { status: number; data: Record<string, unknown> | undefined }
102
103/** Where Clickback lives, from the `base_url` option, with no trailing slash. */
104let baseUrl = 'https://clickback.dev'
105
106/** Only one serve request at a time. A reload resets it, which is harmless. */
107let isServing = false
108
109/** Only one link started at a time. */
110let isLinking = false
111
112/** The timer polling the pending link. A reload stops it, and session.start starts another. */
113let polling: Timer | undefined
114
115/** Only one poll at a time. */
116let isPolling = false
117
118/**
119 * Each drawn ad's confirmation in this module instance, by shown_url: being
120 * sent, done, or how many tries have failed.
121 */
122const confirmations = new Map<string, 'sending' | 'done' | number>()
123
124export function register(on: On, options: PluginOptions) {
125 baseUrl = typeof options.base_url === 'string' ? options.base_url.replace(/\/+$/, '') : baseUrl
126
127 on('session.start', async ($, e, next) => {
128 const result = await next(e)
129
130 // Ask to connect straight away, not only after a turn, or pick up a link
131 // another session started. On a timer, where work that outlives an event
132 // belongs.
133 $.clock.after(0, () => void start($))
134
135 return result
136 })
137
138 on('turn.complete', async ($, e, next) => {
139 const result = await next(e)
140
141 // Main-loop turns that finished normally. Subagents, interrupts, refusals
142 // and API errors get nothing. The request runs on a timer, after this hook
143 // has returned, so the session never waits for Clickback.
144 if (e.agentId === undefined && e.reason === 'answer') {
145 $.clock.after(0, () => void serve($))
146 }
147
148 return result
149 })
150
151 on('prompt.submit', async ($, e, next) => {
152 await clearSeen($)
153
154 return next(e)
155 })
156
157 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158 // Read first, so the band redraws when an ad or the connect line arrives or goes.
159 const { value: ad } = await $.state.get(AD)
160 const { value: link } = await $.state.get(LINK)
161
162 if ((!ad && !link) || e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) {
163 return next(e)
164 }
165
166 const { Box, Text, Link } = $.ui.resolve(e)
167
168 // The whole line is one Link, so a click anywhere on it opens the ad (or
169 // the approval page). Where a surface won't open the address (the Desktop
170 // app takes https and http://localhost) it draws the text plain and the
171 // rest of the band stands.
172 let line: ReturnType<typeof Link> | undefined
173
174 if (ad) {
175 line = Link({ href: ad.url, children: `${LABEL} · ${ad.advertiser}: ${ad.headline} → ${ad.host}` })
176 } else if (link) {
177 line = Link({ href: link.url, children: `${CONNECT} ${link.label} (code ${link.code})` })
178 }
179
180 const tree = Box({
181 flexDirection: 'column',
182 children: [
183 Box({
184 paddingX: 1,
185 // Wraps rather than cuts, so a long ad shows whole.
186 children: Text({ dimColor: true, wrap: 'wrap', children: line }),
187 }),
188 await next(e),
189 ],
190 })
191
192 if (ad) {
193 drawn($, ad)
194 }
195
196 return tree
197 })
198}
199
200/**
201 * On session start: where the band draws and this computer isn't linked,
202 * shows the connect line, reusing a link another session started. Never throws.
203 */
204async function start($: EngineInterface): Promise<void> {
205 try {
206 if (agentFor(await $.session.surfaces()) === undefined) {
207 return
208 }
209
210 if ((await tokenOf($)) !== undefined) {
211 // Linked, maybe by another session while this module reloaded.
212 await clearLine($)
213
214 return
215 }
216
217 await startLinking($)
218 } catch (error) {
219 debug($, `starting failed: ${messageOf(error)}`)
220 }
221}
222
223/**
224 * Asks Clickback for an ad with the ad token and keeps it for the band, or
225 * starts linking when there's no token. Never throws.
226 */
227export async function serve($: EngineInterface): Promise<void> {
228 if (isServing) {
229 return
230 }
231
232 isServing = true
233
234 try {
235 // Only where someone will see it: a terminal or the desktop app, the two
236 // surfaces that draw AbovePrompt. Empty under `claude -p` and the SDK.
237 const surfaces = await $.session.surfaces()
238 const agent = agentFor(surfaces)
239
240 if (agent === undefined) {
241 debug($, `nothing here draws the band (${surfaces.join(', ') || 'no surface'}), so not asking`)
242
243 return
244 }
245
246 // An ad is still up, or one landed after the last prompt went in and hasn't
247 // been on screen yet. Show that one instead of paying for another.
248 const { value: held } = await $.state.get(AD)
249
250 if (held) {
251 debug($, 'the last ad has not been seen yet, so not asking')
252
253 return
254 }
255
256 const token = await tokenOf($)
257
258 if (token === undefined) {
259 debug($, 'this computer is not linked yet, so not asking')
260 await startLinking($)
261
262 return
263 }
264
265 // Once a minute at most, counting every session on this computer.
266 const now = await $.clock.now()
267
268 if (!(await isDue($, now))) {
269 debug($, 'asked less than a minute ago, so not asking now')
270
271 return
272 }
273
274 // Claim the minute before asking, so another session finishing now waits.
275 // Two that read the store at the same moment can both ask; the server
276 // answers the second with nothing.
277 await $.store.set(LAST_ASKED, now)
278
279 debug($, 'asking for an ad')
280
281 const answer = await send($, 'asking for an ad', `${baseUrl}/api/ads/serve`, {
282 surface: SURFACE,
283 agent,
284 session_id: await $.session.id(),
285 }, token)
286
287 if (answer === undefined) {
288 return
289 }
290
291 if (answer.status === 401) {
292 debug($, 'the ad token was refused (HTTP 401), so linking again')
293 await forget($, token)
294 await startLinking($)
295
296 return
297 }
298
299 if (answer.status !== 200) {
300 debug($, `asking for an ad failed: HTTP ${answer.status}`)
301
302 return
303 }
304
305 if (answer.data?.ad === null) {
306 debug($, 'no ad this time')
307
308 return
309 }
310
311 const ad = adOf(answer.data)
312
313 if (ad === undefined) {
314 debug($, 'the answer had no ad the band could show')
315
316 return
317 }
318
319 const { value: prompts = 0 } = await $.state.get(PROMPTS)
320
321 await $.state.set(AD, { ...ad, prompt: prompts })
322 debug($, `got ad ${ad.impressionId} from ${ad.host}`)
323 } catch (error) {
324 debug($, `asking for an ad failed: ${messageOf(error)}`)
325 } finally {
326 isServing = false
327 }
328}
329
330/**
331 * Shows the connect line and polls until the link is approved, denied or
332 * expired. Reuses the link in $.store until it expires, so every session
333 * shows the same code; otherwise asks Clickback for a new one. Never throws.
334 */
335async function startLinking($: EngineInterface): Promise<void> {
336 if (isLinking || polling !== undefined) {
337 return
338 }
339
340 isLinking = true
341
342 try {
343 // Only where someone will see the code, and only while there's no token
344 // (another session may have stored one after a 401 here).
345 const agent = agentFor(await $.session.surfaces())
346
347 if (agent === undefined || (await tokenOf($)) !== undefined) {
348 return
349 }
350
351 const now = await $.clock.now()
352 let pending = pendingOf(await $.store.get(PENDING_LINK))
353
354 if (pending !== undefined && pending.expiresAt > now) {
355 debug($, `using the link already started, code ${pending.userCode}`)
356 } else {
357 debug($, 'linking this computer to Clickback')
358
359 const answer = await send($, 'starting a link', `${baseUrl}/api/link`, { agent })
360
361 if (answer === undefined) {
362 return
363 }
364
365 pending = answer.status === 201 ? linkOf(answer.data, now) : undefined
366
367 if (pending === undefined) {
368 debug($, answer.status === 201 ? 'starting a link failed: the answer was incomplete' : `starting a link failed: HTTP ${answer.status}`)
369
370 return
371 }
372
373 await $.store.set(PENDING_LINK, pending)
374 }
375
376 await showLine($, pending)
377 debug($, `showing the code ${pending.userCode}`)
378
379 polling = $.clock.every(pending.interval * 1000, () => void poll($))
380 } catch (error) {
381 debug($, `starting a link failed: ${messageOf(error)}`)
382 } finally {
383 isLinking = false
384 }
385}
386
387/**
388 * One poll of the pending link: stores the token once it's approved, and
389 * stops when it's denied or expired, or another session has linked. Never throws.
390 */
391async function poll($: EngineInterface): Promise<void> {
392 if (isPolling) {
393 return
394 }
395
396 isPolling = true
397
398 try {
399 if ((await tokenOf($)) !== undefined) {
400 debug($, 'another session linked this computer')
401 await stopLinking($)
402
403 return
404 }
405
406 const pending = pendingOf(await $.store.get(PENDING_LINK))
407
408 if (pending === undefined) {
409 // Another session dropped it. A later turn starts a new one.
410 debug($, 'the link is gone, so not polling any more')
411 await stopLinking($)
412
413 return
414 }
415
416 const now = await $.clock.now()
417
418 if (pending.expiresAt <= now) {
419 debug($, `code ${pending.userCode} expired before it was approved`)
420 await stopLinking($, pending)
421
422 return
423 }
424
425 // Another session may have started a newer link: show its code.
426 await showLine($, pending)
427
428 // Every open session polls on its own timer, and the server limits polls
429 // per IP address, so they take turns: skip this one if any session polled
430 // this link less than an interval ago. Stamp before asking, so the next
431 // session to tick sees it. Two that tick at the same moment can both ask.
432 if (!(await isPollDue($, pending, now))) {
433 return
434 }
435
436 await $.store.set(LINK_POLLED, { deviceCode: pending.deviceCode, at: now })
437
438 const answer = await send($, 'checking the link', `${baseUrl}/api/link/token`, { device_code: pending.deviceCode })
439
440 if (answer === undefined) {
441 return
442 }
443
444 if (answer.status === 202) {
445 debug($, `code ${pending.userCode} is still waiting to be approved`)
446
447 return
448 }
449
450 if (answer.status === 200) {
451 const token = answer.data?.token
452
453 if (typeof token !== 'string' || token === '') {
454 debug($, 'checking the link failed: the answer had no token')
455
456 return
457 }
458
459 await $.store.set(TOKEN, token)
460 await stopLinking($, pending)
461 debug($, 'linked this computer')
462
463 return
464 }
465
466 if (answer.status === 403) {
467 debug($, `code ${pending.userCode} was denied`)
468 await stopLinking($, pending)
469
470 return
471 }
472
473 if (answer.status === 410) {
474 // A link answers once. If another session got the token, this is it.
475 if ((await tokenOf($)) !== undefined) {
476 debug($, 'another session linked this computer')
477 await stopLinking($)
478 } else {
479 debug($, `code ${pending.userCode} expired before it was approved`)
480 await stopLinking($, pending)
481 }
482
483 return
484 }
485
486 debug($, `checking the link failed: HTTP ${answer.status}`)
487 } catch (error) {
488 debug($, `checking the link failed: ${messageOf(error)}`)
489 } finally {
490 isPolling = false
491 }
492}
493
494/**
495 * Stops polling and takes the connect line down. With `pending`, also drops
496 * that link from $.store, unless another session has already replaced it.
497 */
498async function stopLinking($: EngineInterface, pending?: PendingLink): Promise<void> {
499 polling?.cancel()
500 polling = undefined
501
502 if (pending !== undefined && pendingOf(await $.store.get(PENDING_LINK))?.deviceCode === pending.deviceCode) {
503 await $.store.delete(PENDING_LINK)
504 await $.store.delete(LINK_POLLED)
505 }
506
507 await clearLine($)
508}
509
510/**
511 * Whether it's this session's turn to poll `pending`: no session has polled
512 * it within its interval (less POLL_SLACK_MS). A stamp for another link, or
513 * one in the future (a clock that moved back), doesn't hold it back.
514 */
515async function isPollDue($: EngineInterface, pending: PendingLink, now: number): Promise<boolean> {
516 const polled = recordOf(await $.store.get(LINK_POLLED))
517 const at = Number(polled?.at)
518
519 if (polled?.deviceCode !== pending.deviceCode || !Number.isFinite(at) || at > now) {
520 return true
521 }
522
523 return now - at >= pending.interval * 1000 - POLL_SLACK_MS
524}
525
526/**
527 * Puts the connect line for `pending` in the band, unless it's already there.
528 */
529async function showLine($: EngineInterface, pending: PendingLink): Promise<void> {
530 const url = urlOf(pending.url)
531
532 if (url === undefined) {
533 return
534 }
535
536 const line: ClickbackLink = { url: url.href, label: pending.uri, code: pending.userCode }
537 const { value: shown } = await $.state.get(LINK)
538
539 if (shown?.url !== line.url || shown.code !== line.code || shown.label !== line.label) {
540 await $.state.set(LINK, line)
541 }
542}
543
544async function clearLine($: EngineInterface): Promise<void> {
545 const { value: shown } = await $.state.get(LINK)
546
547 if (shown) {
548 await $.state.set(LINK, null)
549 }
550}
551
552/**
553 * Called each time the band returns the ad's tree. The first time, confirms
554 * the ad was drawn. A render hook may not write $.state, and shouldn't wait
555 * on the network, so the request runs on a timer. A failed confirmation is
556 * tried again on a later draw, CONFIRM_TRIES times at most.
557 */
558function drawn($: EngineInterface, ad: ClickbackAd): void {
559 const tries = confirmations.get(ad.shownUrl) ?? 0
560
561 if (typeof tries !== 'number' || tries >= CONFIRM_TRIES) {
562 return
563 }
564
565 confirmations.set(ad.shownUrl, 'sending')
566
567 try {
568 $.clock.after(0, () => void confirm($, ad, tries))
569 } catch (error) {
570 confirmations.set(ad.shownUrl, tries)
571 debug($, `confirming ad ${ad.impressionId} failed: ${messageOf(error)}`)
572 }
573}
574
575/**
576 * Tells Clickback the ad was drawn, which is what it charges and credits for.
577 * Never throws.
578 */
579async function confirm($: EngineInterface, ad: ClickbackAd, tries: number): Promise<void> {
580 let outcome: 'done' | number = tries + 1
581
582 try {
583 const token = await tokenOf($)
584
585 if (token === undefined) {
586 debug($, `confirming ad ${ad.impressionId} failed: this computer is not linked`)
587
588 return
589 }
590
591 const answer = await send($, `confirming ad ${ad.impressionId}`, ad.shownUrl, {}, token)
592
593 if (answer === undefined) {
594 return
595 }
596
597 if (answer.status === 200) {
598 outcome = 'done'
599 debug($, `confirmed ad ${ad.impressionId} as shown, charged: ${answer.data?.charged === true ? 'yes' : 'no'}`)
600 } else if (answer.status === 410) {
601 outcome = 'done'
602 debug($, `ad ${ad.impressionId} expired before it was confirmed (HTTP 410)`)
603 } else if (answer.status === 404) {
604 outcome = 'done'
605 debug($, `Clickback doesn't know ad ${ad.impressionId} (HTTP 404)`)
606 } else if (answer.status === 401) {
607 outcome = 'done'
608 debug($, 'the ad token was refused (HTTP 401), so linking again')
609 await forget($, token)
610 await startLinking($)
611 } else {
612 debug($, `confirming ad ${ad.impressionId} failed: HTTP ${answer.status}`)
613 }
614 } catch (error) {
615 debug($, `confirming ad ${ad.impressionId} failed: ${messageOf(error)}`)
616 } finally {
617 confirmations.set(ad.shownUrl, outcome)
618 }
619}
620
621/**
622 * POSTs JSON to Clickback and reads the JSON answer, or logs why there is
623 * none and resolves undefined. Gives up after TIMEOUT_MS; a late answer is
624 * logged and ignored.
625 */
626async function send($: EngineInterface, what: string, url: string, body: Record<string, unknown>, token?: string): Promise<Answer | undefined> {
627 const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' }
628
629 if (token !== undefined) {
630 headers.Authorization = `Bearer ${token}`
631 }
632
633 const request = $.http.fetch(url, { method: 'POST', headers, body: JSON.stringify(body) })
634 const outcome = await within($, TIMEOUT_MS, request)
635
636 if ('error' in outcome) {
637 debug($, `${what} failed: ${outcome.error}`)
638
639 if (outcome.isLate) {
640 void late($, what, request)
641 }
642
643 return undefined
644 }
645
646 let data: unknown
647
648 try {
649 data = JSON.parse(outcome.value.text)
650 } catch {
651 data = undefined
652 }
653
654 return { status: outcome.value.status, data: recordOf(data) }
655}
656
657/**
658 * Logs how a request the mod stopped waiting for ended. Never throws.
659 */
660async function late($: EngineInterface, what: string, request: Promise<{ status: number }>): Promise<void> {
661 try {
662 const response = await request
663
664 debug($, `${what}: answered after the limit (HTTP ${response.status}), so it was ignored`)
665 } catch (error) {
666 debug($, `${what} failed: ${messageOf(error)}`)
667 }
668}
669
670/**
671 * Whether a minute has passed since any session on this computer last asked.
672 * A time in the future (a clock that moved back) doesn't block forever.
673 */
674async function isDue($: EngineInterface, now: number): Promise<boolean> {
675 const last = Number(await $.store.get(LAST_ASKED))
676
677 if (!Number.isFinite(last) || last > now) {
678 return true
679 }
680
681 return now - last >= INTERVAL_MS
682}
683
684async function tokenOf($: EngineInterface): Promise<string | undefined> {
685 const token = await $.store.get(TOKEN)
686
687 return typeof token === 'string' && token !== '' ? token : undefined
688}
689
690/**
691 * Deletes a token the server refused, unless another session has already
692 * stored a new one.
693 */
694async function forget($: EngineInterface, token: string): Promise<void> {
695 if ((await $.store.get(TOKEN)) === token) {
696 await $.store.delete(TOKEN)
697 }
698}
699
700/**
701 * A line in the debug log (`claude --debug`), led by the plugin's name. Never
702 * the transcript: the mod adds nothing there.
703 */
704function debug($: EngineInterface, text: string): void {
705 try {
706 $.ui.log(text, { to: 'debug' })
707 } catch {
708 // Never in the way.
709 }
710}
711
712function messageOf(error: unknown): string {
713 return error instanceof Error ? error.message : String(error)
714}
715
716/**
717 * Takes the ad down when the next prompt goes in, unless the ad arrived after
718 * the last prompt, in which case it waits for the end of that turn.
719 */
720async function clearSeen($: EngineInterface): Promise<void> {
721 try {
722 const { value: prompts = 0 } = await $.state.get(PROMPTS)
723 const { value: ad } = await $.state.get(AD)
724
725 if (ad && ad.prompt <= prompts) {
726 await $.state.set(AD, null)
727 }
728
729 await $.state.set(PROMPTS, prompts + 1)
730 } catch {
731 // Never in the way.
732 }
733}
734
735/**
736 * What to report as the agent, or undefined where the band isn't drawn.
737 */
738export function agentFor(surfaces: readonly RenderSurface[]): string | undefined {
739 if (surfaces.includes('desktop')) {
740 return 'claude-code-desktop'
741 }
742
743 if (surfaces.includes('terminal')) {
744 return 'claude-code'
745 }
746
747 return undefined
748}
749
750/**
751 * The parts of a serve answer the band needs, cleaned up, or undefined when
752 * there's no whole ad in it. The server answers `{"ad": {...}}`, or
753 * `{"ad": null}` when there's nothing to show. The ad is confirmed with the
754 * token at shown_url, so that has to be on the Clickback server itself.
755 */
756export function adOf(data: unknown, base = baseUrl): Served | undefined {
757 const fields = recordOf(recordOf(data)?.ad)
758
759 if (fields === undefined) {
760 return undefined
761 }
762
763 const advertiser = clean(fields.advertiser, 60)
764 const headline = clean(fields.headline, 140)
765 const host = clean(fields.destination_host, 100)
766 const url = urlOf(fields.tracking_url)
767 const shownUrl = urlOf(fields.shown_url)
768 const impressionId = typeof fields.impression_id === 'number' || typeof fields.impression_id === 'string'
769 ? clean(String(fields.impression_id), 40)
770 : ''
771
772 if (advertiser === '' || headline === '' || host === '' || url === undefined || impressionId === '') {
773 return undefined
774 }
775
776 if (shownUrl === undefined || shownUrl.origin !== urlOf(base)?.origin) {
777 return undefined
778 }
779
780 return { advertiser, headline, host, url: url.href, impressionId, shownUrl: shownUrl.href }
781}
782
783/**
784 * The link from a `POST /api/link` answer, or undefined when it isn't whole.
785 */
786function linkOf(data: unknown, now: number): PendingLink | undefined {
787 const fields = recordOf(data)
788 const expiresIn = Number(fields?.expires_in)
789 const interval = Number(fields?.interval ?? 5)
790
791 if (!(expiresIn > 0)) {
792 return undefined
793 }
794
795 return pendingOf({
796 deviceCode: fields?.device_code,
797 userCode: clean(fields?.user_code, 20),
798 uri: clean(fields?.verification_uri, 200),
799 url: fields?.verification_uri_complete,
800 expiresAt: now + expiresIn * 1000,
801 interval: Number.isFinite(interval) ? Math.min(Math.max(interval, 1), 60) : 5,
802 })
803}
804
805/**
806 * A pending link as kept in $.store, or undefined when it isn't one.
807 */
808function pendingOf(value: unknown): PendingLink | undefined {
809 const link = recordOf(value)
810
811 if (
812 link === undefined
813 || typeof link.deviceCode !== 'string' || link.deviceCode === ''
814 || typeof link.userCode !== 'string' || link.userCode === ''
815 || typeof link.uri !== 'string' || link.uri === ''
816 || urlOf(link.url) === undefined
817 || typeof link.expiresAt !== 'number' || !Number.isFinite(link.expiresAt)
818 || typeof link.interval !== 'number' || !(link.interval >= 1)
819 ) {
820 return undefined
821 }
822
823 return {
824 deviceCode: link.deviceCode,
825 userCode: link.userCode,
826 uri: link.uri,
827 url: String(link.url),
828 expiresAt: link.expiresAt,
829 interval: link.interval,
830 }
831}
832
833function recordOf(value: unknown): Record<string, unknown> | undefined {
834 return typeof value === 'object' && value !== null && !Array.isArray(value)
835 ? (value as Record<string, unknown>)
836 : undefined
837}
838
839/**
840 * One line of plain text: no control or bidi characters, single spaces, at
841 * most `max` characters.
842 */
843export function clean(value: unknown, max: number): string {
844 if (typeof value !== 'string') {
845 return ''
846 }
847
848 const text = value
849 .replace(/[\u0000-\u001f\u007f-\u009f--]/g, ' ')
850 .replace(/\s+/g, ' ')
851 .trim()
852
853 return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
854}
855
856/**
857 * An http or https URL with no credentials, at most MAX_HREF characters once
858 * percent-encoded, or undefined. Every URL the mod links to or sends to.
859 */
860function urlOf(value: unknown): URL | undefined {
861 if (typeof value !== 'string') {
862 return undefined
863 }
864
865 try {
866 const url = new URL(value)
867
868 if (url.protocol !== 'https:' && url.protocol !== 'http:') {
869 return undefined
870 }
871
872 if (url.username !== '' || url.password !== '') {
873 return undefined
874 }
875
876 return url.href.length <= MAX_HREF ? url : undefined
877 } catch {
878 return undefined
879 }
880}
881
882/**
883 * The answer, or why there is none: it failed, or `ms` passed first (`isLate`).
884 */
885function within<T>($: EngineInterface, ms: number, request: Promise<T>): Promise<{ value: T } | { error: string; isLate?: true }> {
886 return new Promise(resolve => {
887 const timer = $.clock.after(ms, () => resolve({ error: `no answer within ${ms}ms`, isLate: true }))
888
889 request.then(
890 value => {
891 timer.cancel()
892 resolve({ value })
893 },
894 error => {
895 timer.cancel()
896 resolve({ error: messageOf(error) })
897 },
898 )
899 })
900}
901types/index.d.ts 42 lines1/**
2 * One served ad, as the mod keeps it for the session in `$.state` until the
3 * person has had a chance to see it.
4 */
5export type ClickbackAd = {
6 advertiser: string
7 headline: string
8 /** The advertiser's host, shown at the end of the line. */
9 host: string
10 /** The Clickback tracking URL the link opens, at most 2048 characters. */
11 url: string
12 /** How many prompts the session had submitted when the ad arrived. */
13 prompt: number
14 /** The impression's id on the server. */
15 impressionId: string
16 /** Where the mod confirms the ad was drawn, on the Clickback server. */
17 shownUrl: string
18}
19
20/**
21 * The line asking the person to connect this computer, while a link waits for
22 * approval. The device code stays in `$.store`, which other plugins can't read.
23 */
24export type ClickbackLink = {
25 /** verification_uri_complete: the approval page with the code filled in, which the link opens. */
26 url: string
27 /** verification_uri, shown in the line, so it can be typed with the code. */
28 label: string
29 /** The code the page shows, for the person to check, e.g. ABCD-EFGH. */
30 code: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 clickback: {
36 ad: ClickbackAd | null
37 prompts: number
38 link: ClickbackLink | null
39 }
40 }
41}
42