Opt-in ads for Claude Code: a banner above the prompt and a Theater pane while Claude works. Unofficial, not affiliated with Anthropic.

Opt-in ads in Claude Code. Tokenbreak is unofficial and not affiliated with Anthropic.
sponsored label and ✕ (hide for an hour)./ads: status, next, style [pill|rule|card], pause [1h|30m|2h|today], resume, report.It runs in the terminal and in the desktop app's Code tab, where ads are text only. A claude -p run draws nothing, so Tokenbreak stays off there and sends nothing.
Tokenbreak hooks no event that reads or shapes the conversation: no prompt.compose, session.append, tool.call or turn.step. It makes no call that reads files, prompts or the transcript. From turn.start and turn.complete it reads the turn's id and nothing else. tests/privacy.test.ts checks this against the engine's own scan of the module.
Ads never enter the model's context, so they cost you no tokens.
GET {endpoint}/api/v1/ads?formats=banner,theater fetches ads. The request carries no identifiers.POST {endpoint}/api/v1/impressions sends { deviceId, impressions: [{ adId, format, at, turnId }] } in batches of up to 200, once 20 are queued or every 5 minutes.deviceId is a random UUID created on first run./config)| Field | Default | |
|---|---|---|
endpoint | https://tokenbreak.dev | Use http://localhost:3000 while running the site locally |
frequency | normal | chill 90s, normal 40s, max 20s |
theater | on | off never opens the pane |
pictures | on | Banner logos, Theater images and video; off keeps ads text only |
sound | on | A video ad's sound when the Theater opens; off plays it only when you press the sound button |
/plugin marketplace add steven-3/tokenbreak
/plugin install tokenbreak@tokenbreak
Then turn on updates: /plugin → Marketplaces → tokenbreak → Enable auto-update. Claude Code leaves auto-update off for marketplaces outside Anthropic's own, and only the latest version earns.
The ad server tells the mod its newest release and the oldest one that still earns; the mod compares them with its own version on your machine and sends nothing extra. When an update is out, you get one notice. When your version is too old to earn, the banner shows an update notice in place of ads and the mod reports nothing, and the server credits nothing from it either. /ads update prints the steps. Each impression report carries the mod's version.
For development: claude --plugin-dir plugins/tokenbreak.
claude plugin validate plugins/tokenbreak
claude plugin test plugins/tokenbreak
tsc -p plugins/tokenbreak # once Claude Code has loaded it and written .claude-plugin/types/
The wire types in types/index.d.ts mirror the ad server's API schemas. Change both together.
Built against Claude Code 2.1.288. The mod API is early access, so re-run validate and test after every Claude Code update.
hooks/register.tsx 930 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ImageSource, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { Ad, AdSource, AdVideo, BannerStyle, Impression, ModRelease, TheaterShowing, UpdateState } from '../types'
5import {
6 BANNER_STYLES,
7 bannerStyleOf,
8 cacheable,
9 canDockTheater,
10 capQueue,
11 currentBanner,
12 cutsOf,
13 describeWait,
14 FLUSH_AT,
15 FLUSH_EVERY_MS,
16 FORMATS,
17 HOUSE_ADS,
18 impressionKey,
19 isHex,
20 tint,
21 UPDATE_STEPS,
22 updateStateOf,
23 linkable,
24 MOD_VERSION,
25 posterOf,
26 parseBatch,
27 pauseUntil,
28 queueOf,
29 releaseOf,
30 rotationMs,
31 THEATER_DELAY_MS,
32 THEATER_MIN_COLUMNS,
33 THEATER_PANE,
34 THEATER_SNOOZE_MS,
35 theaterArt,
36 theaterLayout,
37 theaterOf,
38 withoutFrames,
39 type Seen,
40} from './core'
41
42// Privacy rule: Tokenbreak never hooks prompt.compose, session.append,
43// tool.call or anything else that reads or shapes the conversation. Of
44// turn.start and turn.complete it reads the turn's id and nothing else.
45// tests/privacy.test.ts holds it to that.
46
47const ads = atom({ plugin: 'tokenbreak', key: 'ads' } as const, [] as Ad[])
48const source = atom({ plugin: 'tokenbreak', key: 'source' } as const, 'built-in' as AdSource)
49const bannerIndex = atom({ plugin: 'tokenbreak', key: 'bannerIndex' } as const, 0)
50const pausedUntil = atom({ plugin: 'tokenbreak', key: 'pausedUntil' } as const, null as number | null)
51const theater = atom({ plugin: 'tokenbreak', key: 'theater' } as const, null as TheaterShowing | null)
52const bannerStyle = atom({ plugin: 'tokenbreak', key: 'bannerStyle' } as const, 'pill' as BannerStyle)
53const theaterSound = atom({ plugin: 'tokenbreak', key: 'theaterSound' } as const, false)
54const updateState = atom({ plugin: 'tokenbreak', key: 'update' } as const, { kind: 'current' } as UpdateState)
55
56const FALLBACK_ACCENT = '#FFE600'
57const AD_YELLOW = '#FFE600'
58
59/** The module's own bookkeeping, set afresh by `register`: a reload starts it over. */
60type Run = {
61 endpoint: string
62 /** Whether `startSession` ran in this load: on a terminal's start, or once a surface attached. */
63 hasStarted: boolean
64 isTheaterOn: boolean
65 /** Whether ads draw pictures (banner logos, Theater images and video): the `pictures` option. */
66 arePicturesOn: boolean
67 /** Whether a video ad's sound plays when the Theater opens: the `sound` option. */
68 isSoundOnByDefault: boolean
69 rotationMs: number
70 deviceId: string
71 fetchedAt: number
72 ttlMs: number
73 isRefreshing: boolean
74 isFlushing: boolean
75 runningTurn?: string
76 theaterTimer?: Timer
77 resumeTimer?: Timer
78 theaterSnoozedUntil: number
79 /** What the band saw of the surface when it last drew. */
80 seen?: Seen
81 /** The banner the band last drew, undefined while it shows nothing. */
82 drawnBanner?: string
83 /** Why the Theater last opened or stayed shut, for `/ads status`. */
84 theaterNote: string
85 /** Each video cut's frames by `frameKey`; `$.state` holds the ads without them. */
86 frames: Map<string, readonly string[]>
87 /** The cut the Theater drew last, which playback steps through. */
88 drawnCut?: { adId: string; index: number }
89 /** The frame on screen; -1 restarts the loop on the next tick. */
90 frame?: number
91 /** Stops the Theater's sound; set while it plays. */
92 sound?: AbortController
93 /** Plays the Theater's video while the pane is open. */
94 videoTimer?: Timer
95 /** The last video's frame swaps, for `/ads status`. */
96 video: { swapped: number; refused: number; reason?: string }
97 timers: Timer[]
98}
99
100/** The ad server's base URL; a host typed without a scheme (`tokenbreak.dev`) gets `https://`. */
101function endpointOf(value: unknown): string {
102 const endpoint = String(value ?? 'https://tokenbreak.dev').trim().replace(/\/+$/, '')
103
104 return /^[a-z][a-z0-9+.-]*:\/\//i.test(endpoint) ? endpoint : `https://${endpoint}`
105}
106
107function runOf(options: PluginOptions): Run {
108 return {
109 endpoint: endpointOf(options.endpoint),
110 hasStarted: false,
111 isTheaterOn: options.theater !== 'off',
112 arePicturesOn: options.pictures !== 'off',
113 isSoundOnByDefault: options.sound !== 'off',
114 rotationMs: rotationMs(options.frequency),
115 deviceId: '',
116 fetchedAt: 0,
117 ttlMs: 600_000,
118 isRefreshing: false,
119 isFlushing: false,
120 theaterSnoozedUntil: 0,
121 theaterNote: 'no turn has run long enough yet',
122 video: { swapped: 0, refused: 0 },
123 frames: new Map(),
124 timers: [],
125 }
126}
127
128let run: Run = runOf({})
129
130/** The status line stays empty; an older build may have left a line there. */
131/** Puts `list` on screen: frames into module memory, the rest into `$.state`. */
132async function showAds($: EngineInterface, list: readonly Ad[]) {
133 run.frames = new Map(
134 list.flatMap(ad =>
135 cutsOf(ad).flatMap((cut, at) => (cut.frames.length === 0 ? [] : [[frameKey(ad.id, at), cut.frames] as const])),
136 ),
137 )
138 await update($, ads, () => withoutFrames(list))
139}
140
141/**
142 * Takes in the server's release info: a mod too old to earn shows an update notice
143 * in place of ads and reports nothing; an update that's merely out gets one toast
144 * per version.
145 */
146async function applyRelease($: EngineInterface, release: ModRelease | undefined) {
147 const state = updateStateOf(release)
148
149 await update($, updateState, () => state)
150
151 if (state.kind !== 'current' && (await $.store.get('toldVersion')) !== state.latest) {
152 await $.store.set('toldVersion', state.latest)
153 $.ui.toast(
154 state.kind === 'required'
155 ? `Tokenbreak ${state.latest} is required to keep earning. /ads update shows how.`
156 : `Tokenbreak ${state.latest} is out. /ads update shows how to get it.`,
157 { timeoutMs: 8000 },
158 )
159 }
160}
161
162const isUpdateRequired = async ($: EngineInterface) => (await read($, updateState)).kind === 'required'
163
164/** The ad server's origin, the only host ad sound may come from. */
165function originOf(endpoint: string): string | undefined {
166 try {
167 return new URL(endpoint).origin
168 } catch {
169 return undefined
170 }
171}
172
173const frameKey = (adId: string, cut: number) => `${adId}#${cut}`
174
175/** How many frames a cut has: files the terminal reads, or PNGs held in memory. */
176const lengthOf = (ad: Ad, cut: number) =>
177 cutsOf(ad)[cut]?.files?.length ?? run.frames.get(frameKey(ad.id, cut))?.length ?? 0
178
179/** One frame of a cut as an Image source, undefined when the cut has none. */
180function frameSource(ad: Ad, cut: number, frame: number): ImageSource | undefined {
181 const file = cutsOf(ad)[cut]?.files?.[frame]
182
183 if (file !== undefined) {
184 return { file, format: 'png' }
185 }
186
187 const png = run.frames.get(frameKey(ad.id, cut))?.[frame]
188
189 return png === undefined ? undefined : { png }
190}
191
192const hasVideo = (ad: Ad) => cutsOf(ad).some((_, at) => lengthOf(ad, at) > 0)
193
194async function showStatus($: EngineInterface) {
195 $.ui.status(undefined)
196}
197
198async function setPause($: EngineInterface, until: number | null) {
199 run.resumeTimer?.cancel()
200 run.resumeTimer = undefined
201 await update($, pausedUntil, () => until)
202
203 if (until === null) {
204 await $.store.delete('pausedUntil')
205 } else {
206 await $.store.set('pausedUntil', until)
207 run.resumeTimer = $.clock.after(Math.max(0, until - (await $.clock.now())), () => void setPause($, null))
208 }
209
210 await showStatus($)
211}
212
213async function hideFor($: EngineInterface, ms: number) {
214 await setPause($, (await $.clock.now()) + ms)
215}
216
217async function refresh($: EngineInterface) {
218 if (run.isRefreshing) {
219 return
220 }
221
222 run.isRefreshing = true
223
224 try {
225 const response = await $.http.fetch(`${run.endpoint}/api/v1/ads?formats=${FORMATS.join(',')}`, {
226 headers: { accept: 'application/json' },
227 })
228 const batch = response.ok ? parseBatch(response.text, originOf(run.endpoint)) : undefined
229
230 if (batch !== undefined && batch.ads.length > 0) {
231 run.fetchedAt = await $.clock.now()
232 run.ttlMs = batch.ttlSeconds * 1000
233 await showAds($, batch.ads)
234 await applyRelease($, batch.mod)
235 await update($, source, () => 'server' as AdSource)
236 await $.store.set('batch', cacheable(batch))
237 await showStatus($)
238 }
239 } catch {
240 // Unreachable or refused: keep what is showing, house ads at worst.
241 } finally {
242 run.isRefreshing = false
243 }
244}
245
246async function flush($: EngineInterface) {
247 if (run.isFlushing) {
248 return
249 }
250
251 run.isFlushing = true
252
253 try {
254 const queued = queueOf(await $.store.get('queue'))
255
256 if (queued.length === 0) {
257 return
258 }
259
260 const sent = queued.slice(0, 200)
261 const response = await $.http.fetch(`${run.endpoint}/api/v1/impressions`, {
262 method: 'POST',
263 headers: { 'content-type': 'application/json' },
264 body: JSON.stringify({ deviceId: run.deviceId, modVersion: MOD_VERSION, impressions: sent }),
265 })
266
267 if (response.status === 426) {
268 // Too old to earn: nothing queued will be credited, so drop it and say so.
269 // The version goes on screen, so it gets the same check as a batch's.
270 const min = (JSON.parse(response.text) as { minVersion?: unknown }).minVersion
271
272 await $.store.set('queue', [])
273 await applyRelease($, releaseOf({ latest: min, min }) ?? { latest: MOD_VERSION, min: MOD_VERSION })
274
275 return
276 }
277
278 if (!response.ok) {
279 return
280 }
281
282 const keys = new Set(sent.map(impressionKey))
283 const left = queueOf(await $.store.get('queue')).filter(impression => !keys.has(impressionKey(impression)))
284
285 await $.store.set('queue', left)
286 } catch {
287 // The queue stays for the next try.
288 } finally {
289 run.isFlushing = false
290 }
291}
292
293async function enqueue($: EngineInterface, impressions: readonly Impression[]) {
294 if (impressions.length === 0) {
295 return
296 }
297
298 const queue = capQueue([...queueOf(await $.store.get('queue')), ...impressions])
299
300 await $.store.set('queue', queue)
301
302 if (queue.length >= FLUSH_AT) {
303 $.clock.after(0, () => void flush($))
304 }
305}
306
307async function tick($: EngineInterface) {
308 if ((await $.clock.now()) - run.fetchedAt > run.ttlMs) {
309 void refresh($)
310 }
311
312 await update($, bannerIndex, index => index + 1)
313}
314
315async function openTheater($: EngineInterface, turnId: string) {
316 const now = await $.clock.now()
317
318 const shut =
319 run.runningTurn !== turnId
320 ? 'the turn ended within 3s'
321 : !canDockTheater(run.seen)
322 ? run.seen === undefined
323 ? "the band hasn't reported the terminal's size"
324 : run.seen.columns < THEATER_MIN_COLUMNS
325 ? `the terminal is ${run.seen.columns} columns, under ${THEATER_MIN_COLUMNS}`
326 : 'not the fullscreen layout'
327 : now < run.theaterSnoozedUntil
328 ? `snoozed for ${describeWait(run.theaterSnoozedUntil - now)} after you closed it`
329 : (await read($, pausedUntil)) !== null
330 ? 'ads are paused'
331 : (await isUpdateRequired($))
332 ? 'this version is too old to earn; /ads update'
333 : undefined
334
335 if (shut !== undefined) {
336 run.theaterNote = `stayed shut: ${shut}`
337
338 return
339 }
340
341 const list = await read($, ads)
342 const ad = theaterOf(list, currentBanner(list, await read($, bannerIndex)))
343
344 await update($, theater, () => ({ adId: ad.id, turnId, isPlaced: false }))
345
346 const opened = await $.ui.open({ id: THEATER_PANE, title: 'Tokenbreak' }).catch((error: unknown) => {
347 run.theaterNote = `stayed shut: opening the pane failed (${String(error).slice(0, 120)})`
348
349 return undefined
350 })
351
352 if (opened === undefined) {
353 await update($, theater, () => null)
354
355 return
356 }
357
358 if (!opened.isPlaced || run.runningTurn !== turnId) {
359 run.theaterNote = opened.isPlaced ? 'stayed shut: the turn ended while it opened' : "stayed shut: Claude Code didn't place the pane"
360 // Never leave a pane waiting undrawn for a wider terminal.
361 await $.ui.close({ id: THEATER_PANE })
362 await update($, theater, () => null)
363
364 return
365 }
366
367 await update($, theater, () => ({ adId: ad.id, turnId, isPlaced: true }))
368 run.theaterNote = `opened ${ad.brand}${
369 !hasVideo(ad)
370 ? ''
371 : arePicturesOn()
372 ? ` (video, ${cutsOf(ad).length} cut${cutsOf(ad).length === 1 ? '' : 's'}, ${lengthOf(ad, 0)} frames)`
373 : ' (text only: pictures are off in /config)'
374 }`
375 playVideo($, ad)
376
377 // Sound starts with the picture unless the person muted it once (`muted` in the store,
378 // kept across sessions) or set `sound` to off.
379 if (ad.audio !== undefined && run.isSoundOnByDefault && arePicturesOn() && (await $.store.get('muted')) !== true) {
380 await startSound($, ad)
381 }
382}
383
384/** Steps the Theater's Image through the cut it drew, until `stopVideo`. */
385function playVideo($: EngineInterface, ad: Ad) {
386 stopVideo()
387
388 const fps = Math.max(0, ...cutsOf(ad).map((cut: AdVideo) => cut.fps))
389
390 if (!hasVideo(ad) || fps === 0 || !arePicturesOn()) {
391 return
392 }
393
394 run.video = { swapped: 0, refused: 0 }
395 run.frame = undefined
396 run.videoTimer = $.clock.every(Math.round(1000 / fps), () => {
397 const drawn = run.drawnCut
398 const length = drawn?.adId === ad.id ? lengthOf(ad, drawn.index) : 0
399
400 if (drawn === undefined || length < 2) {
401 return
402 }
403
404 run.frame = ((run.frame ?? posterOf(length)) + 1) % length
405
406 const source = frameSource(ad, drawn.index, run.frame)
407
408 if (source === undefined) {
409 return
410 }
411
412 void $.ui
413 .blit({ requestId: THEATER_PANE, key: 'theater-image', source })
414 .then(result => {
415 if (result.deny === undefined) {
416 run.video.swapped += 1
417 } else {
418 run.video.refused += 1
419 run.video.reason = result.deny
420 }
421 })
422 .catch((error: unknown) => {
423 run.video.refused += 1
424 run.video.reason = String(error).slice(0, 160)
425 })
426 })
427}
428
429const arePicturesOn = () => run.arePicturesOn
430
431function stopVideo() {
432 run.videoTimer?.cancel()
433 run.videoTimer = undefined
434}
435
436/** The sound button: mutes (and remembers it), or unmutes and plays from the top of the loop. */
437async function toggleSound($: EngineInterface, ad: Ad) {
438 if (run.sound !== undefined) {
439 await stopSound($)
440 await $.store.set('muted', true)
441
442 return
443 }
444
445 await $.store.delete('muted')
446 await startSound($, ad)
447}
448
449/** Plays the ad's sound, looping with the picture, until `stopSound`. */
450async function startSound($: EngineInterface, ad: Ad) {
451 const audio = ad.audio
452
453 if (audio === undefined) {
454 return
455 }
456
457 const controller = new AbortController()
458
459 run.sound = controller
460 run.frame = -1
461 await update($, theaterSound, () => true)
462 void $.audio
463 .play('url' in audio ? { url: audio.url } : { asset: audio.asset }, { shouldLoop: true, signal: controller.signal })
464 .catch((error: unknown) => {
465 run.theaterNote = `${run.theaterNote}; sound failed: ${String(error).slice(0, 120)}`
466 })
467 .finally(() => {
468 if (run.sound === controller) {
469 run.sound = undefined
470 void update($, theaterSound, () => false)
471 }
472 })
473}
474
475async function stopSound($: EngineInterface) {
476 run.sound?.abort()
477 run.sound = undefined
478 await update($, theaterSound, () => false)
479}
480
481async function startSession($: EngineInterface) {
482 run.hasStarted = true
483
484 for (const timer of run.timers.splice(0)) {
485 timer.cancel()
486 }
487
488 stopVideo()
489 await stopSound($)
490
491 await $.command
492 .register({
493 name: 'ads',
494 description: 'Tokenbreak ads: status, update, next, style, pause [1h|30m|today], resume, report',
495 argumentHint: '[status|update|next|style pill|pause 1h|resume|report]',
496 immediate: true,
497 })
498 .catch(() => undefined)
499
500 const storedId = await $.store.get('deviceId')
501
502 run.deviceId = typeof storedId === 'string' && storedId.length >= 8 ? storedId : crypto.randomUUID()
503
504 if (storedId !== run.deviceId) {
505 await $.store.set('deviceId', run.deviceId)
506 }
507
508 const cached = parseBatch(JSON.stringify((await $.store.get('batch')) ?? null), originOf(run.endpoint))
509 const storedStyle = await $.store.get('bannerStyle')
510
511 if (cached !== undefined && cached.ads.length > 0) {
512 await showAds($, cached.ads)
513 await applyRelease($, cached.mod)
514 await update($, source, () => 'cache' as AdSource)
515 } else {
516 await showAds($, HOUSE_ADS)
517 await update($, source, () => 'built-in' as AdSource)
518 }
519
520 await update($, bannerStyle, () => bannerStyleOf(storedStyle))
521
522 const storedPause = await $.store.get('pausedUntil')
523 const now = await $.clock.now()
524
525 await setPause($, typeof storedPause === 'number' && storedPause > now ? storedPause : null)
526
527 run.timers.push(
528 $.clock.every(run.rotationMs, () => void tick($)),
529 $.clock.every(FLUSH_EVERY_MS, () => void flush($)),
530 $.clock.after(0, () => void refresh($)),
531 )
532}
533
534async function adsCommand($: EngineInterface, args: string): Promise<string> {
535 const [verb = 'status', ...rest] = args.trim().split(/\s+/).filter(Boolean)
536 const now = await $.clock.now()
537
538 if (verb === 'pause') {
539 const until = pauseUntil(rest.join(' '), now)
540
541 if (until === undefined) {
542 return 'Usage: /ads pause [1h|30m|2h|today]'
543 }
544
545 await setPause($, until)
546
547 return `Tokenbreak paused for ${describeWait(until - now)}. /ads resume brings it back.`
548 }
549
550 if (verb === 'update') {
551 const state = await read($, updateState)
552 const where =
553 state.kind === 'current'
554 ? `You're on ${MOD_VERSION}, the latest.`
555 : `You're on ${MOD_VERSION}; ${state.latest} is out${state.kind === 'required' ? ' and needed to keep earning' : ''}.`
556
557 return `${where}\n${UPDATE_STEPS}`
558 }
559
560 if (verb === 'next') {
561 await update($, bannerIndex, index => index + 1)
562
563 const ad = currentBanner(await read($, ads), await read($, bannerIndex))
564
565 return ad === undefined ? 'No banners to show.' : `Showing ad ${ad.id}.`
566 }
567
568 if (verb === 'style') {
569 const wanted = rest[0]
570
571 if (!BANNER_STYLES.includes(wanted as BannerStyle)) {
572 return `Usage: /ads style [${BANNER_STYLES.join('|')}] (now ${await read($, bannerStyle)})`
573 }
574
575 await update($, bannerStyle, () => wanted as BannerStyle)
576 await $.store.set('bannerStyle', wanted)
577
578 return `Banner style: ${wanted}.`
579 }
580
581 if (verb === 'resume') {
582 await setPause($, null)
583
584 return 'Tokenbreak resumed.'
585 }
586
587 if (verb === 'report') {
588 const ad = currentBanner(await read($, ads), await read($, bannerIndex))
589
590 if (ad === undefined) {
591 return 'No ad is showing right now.'
592 }
593
594 const reports = await $.store.get('reports')
595 const kept = Array.isArray(reports) ? reports.slice(-49) : []
596
597 await $.store.set('reports', [...kept, { adId: ad.id, at: now }])
598
599 return `Reported ad ${ad.id}. Thanks: reports get a human review once accounts launch.`
600 }
601
602 if (verb !== 'status') {
603 return 'Usage: /ads [status|update|next|style pill|pause 1h|pause today|resume|report]'
604 }
605
606 const until = await read($, pausedUntil)
607 const from = await read($, source)
608 const queued = queueOf(await $.store.get('queue')).length
609 const ad = currentBanner(await read($, ads), await read($, bannerIndex))
610 const origin =
611 from === 'server'
612 ? run.endpoint
613 : from === 'cache'
614 ? `${run.endpoint} (cached)`
615 : 'built-in house ads (ad server unreachable)'
616
617 return [
618 `Tokenbreak · ${until === null ? 'on' : `paused, ${describeWait(until - now)} left`}`,
619 `Showing: ${ad === undefined ? 'nothing' : `ad ${ad.id}`}`,
620 `Ads from: ${origin}`,
621 `Version: ${MOD_VERSION}${(await read($, updateState)).kind === 'current' ? '' : ' (update out: /ads update)'}`,
622 `Theater: ${run.isTheaterOn ? `on (fullscreen layout, 144+ columns, turns over 3s); last turn ${run.theaterNote}` : 'off'}`,
623 ...(run.video.swapped + run.video.refused > 0
624 ? [`Video: ${run.video.swapped} frames swapped, ${run.video.refused} refused${run.video.reason === undefined ? '' : ` (${run.video.reason})`}`]
625 : []),
626 `Impressions queued: ${queued}`,
627 `Device: ${run.deviceId.slice(0, 8)}… (anonymous; accounts and earnings arrive later)`,
628 ].join('\n')
629}
630
631async function closeTurn($: EngineInterface, turnId: string) {
632 stopVideo()
633 await stopSound($)
634 run.runningTurn = undefined
635 run.theaterTimer?.cancel()
636 run.theaterTimer = undefined
637
638 const showing = await read($, theater)
639
640 if (showing !== null) {
641 await $.ui.close({ id: THEATER_PANE }).catch(() => undefined)
642 await update($, theater, () => null)
643 }
644
645 // Only ads the ad server served count; built-in house ads are never reported, and
646 // a mod too old to earn reports nothing.
647 if ((await read($, source)) === 'built-in' || (await read($, pausedUntil)) !== null || (await isUpdateRequired($))) {
648 return
649 }
650
651 const at = await $.clock.now()
652 const impressions: Impression[] = []
653
654 if (run.drawnBanner !== undefined) {
655 impressions.push({ adId: run.drawnBanner, format: 'banner', at, turnId })
656 }
657
658 if (showing?.isPlaced === true && showing.turnId === turnId) {
659 impressions.push({ adId: showing.adId, format: 'theater', at, turnId })
660 }
661
662 await enqueue($, impressions)
663}
664
665async function snoozeTheater($: EngineInterface) {
666 run.theaterSnoozedUntil = (await $.clock.now()) + THEATER_SNOOZE_MS
667 await update($, theater, () => null)
668}
669
670export const register: Register = (on, options) => {
671 run = runOf(options)
672
673 // A terminal session starts at once. The desktop's Code tab runs through the SDK, which
674 // starts with no surface and isInteractive false, like `claude -p`: there ads start when
675 // the app attaches, or at once when it already has (a reload). A plain -p run never draws
676 // and stays dormant.
677 on('session.start', async ($, e, next) => {
678 if (e.isInteractive || (await $.session.surfaces()).length > 0) {
679 await startSession($)
680 }
681
682 return next(e)
683 })
684
685 on('session.attach', async ($, e, next) => {
686 if (!run.hasStarted) {
687 await startSession($)
688 }
689
690 return next(e)
691 })
692
693 on('command.run', { command: 'ads' }, async ($, e) => ({ text: await adsCommand($, e.args) }))
694
695 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
696 if (e.viewport !== undefined) {
697 run.seen = {
698 columns: e.viewport.columns,
699 rows: e.viewport.rows,
700 isFullscreen: e.viewport.isFullscreen,
701 maxRows: e.props.maxRows,
702 }
703 }
704
705 const until = await read($, pausedUntil)
706 const ad = currentBanner(await read($, ads), await read($, bannerIndex))
707
708 const state = await read($, updateState)
709
710 if (e.props.hasSurvey || until !== null || (ad === undefined && state.kind !== 'required')) {
711 run.drawnBanner = undefined
712
713 return next(e)
714 }
715
716 if (state.kind === 'required' || ad === undefined) {
717 // Too old to earn: the update notice takes the ad's place, and nothing is counted.
718 const { Box, Text } = $.ui.resolve(e)
719
720 run.drawnBanner = undefined
721
722 return (
723 <Box width={e.props.bodyColumns} marginTop={1} flexDirection="row" gap={1}>
724 <Text color={AD_YELLOW}>⬆</Text>
725 <Box flexGrow={1} flexShrink={1}>
726 <Text wrap="truncate-end">
727 <Text bold>Update Tokenbreak to keep earning.</Text>
728 {state.kind === 'required' ? ` Version ${state.latest} is out; you have ${MOD_VERSION}.` : ''}
729 </Text>
730 </Box>
731 <Text backgroundColor={tint(AD_YELLOW, 0.22)} color={AD_YELLOW} bold>
732 {' /ads update '}
733 </Text>
734 </Box>
735 )
736 }
737
738 run.drawnBanner = ad.id
739
740 const { Box, Text, Link, Button } = $.ui.resolve(e)
741 const Image = e.surface === 'terminal' && arePicturesOn() ? $.ui.resolve(e).Image : undefined
742 const style = await read($, bannerStyle)
743 const href = linkable(ad.clickUrl)
744 const accent = isHex(ad.accent) ? ad.accent : FALLBACK_ACCENT
745 const cta = ad.cta ?? 'Learn more'
746 // A logo only within LOGO_MAX_BYTES (parseBatch drops bigger ones): the band redraws
747 // constantly, and a small picture goes in one short piece that a redraw can't cut.
748 const mark =
749 Image !== undefined && ad.logo !== undefined ? (
750 <Image key="banner-logo" source={{ png: ad.logo.png }} columns={2} rows={1} alt={ad.glyph ?? ' '} />
751 ) : (
752 <Text color={accent}>{ad.glyph ?? '●'}</Text>
753 )
754 // Brand and headline are one link, so the whole line opens the ad.
755 const words = (
756 <Text wrap="truncate-end">
757 <Text color={accent} bold>
758 {ad.brand}
759 </Text>
760 {' '}
761 {ad.headline}
762 </Text>
763 )
764 const action =
765 href === undefined ? undefined : style === 'pill' ? (
766 // The brand color on a dark tint of itself: reads as a button without a solid block of color.
767 <Link href={href}>
768 <Text backgroundColor={tint(accent, 0.22)} color={accent} bold>
769 {` ${cta} ↗ `}
770 </Text>
771 </Link>
772 ) : (
773 <Link href={href}>
774 <Text color={accent} bold underline>
775 {`${cta} →`}
776 </Text>
777 </Link>
778 )
779 const row = (
780 <Box flexDirection="row" gap={1} flexGrow={1}>
781 {style === 'rule' && <Text color={accent}>▎</Text>}
782 {mark}
783 <Box flexGrow={1} flexShrink={1}>
784 {href === undefined ? words : <Link href={href}>{words}</Link>}
785 </Box>
786 {action}
787 <Box flexDirection="row" gap={1} marginLeft={1}>
788 <Text dimColor>sponsored</Text>
789 <Text dimColor>·</Text>
790 <Button key="hide" label="✕" plain dimColor onPress={() => void hideFor($, 3_600_000)} />
791 </Box>
792 </Box>
793 )
794
795 // marginTop keeps a blank row between the banner and the spinner or transcript above it.
796 return style === 'card' ? (
797 <Box width={e.props.bodyColumns} borderStyle="round" borderColor={accent} paddingX={1}>
798 {row}
799 </Box>
800 ) : (
801 <Box width={e.props.bodyColumns} marginTop={1}>
802 {row}
803 </Box>
804 )
805 })
806
807 on('ui.render', { component: 'Pane', requestId: 'tokenbreak-theater' }, async ($, e) => {
808 const showing = await read($, theater)
809 const list = await read($, ads)
810 const isSoundOn = await read($, theaterSound)
811 const ad = list.find(one => one.id === showing?.adId) ?? theaterOf(list)
812 const href = linkable(ad.clickUrl)
813 const accent = isHex(ad.accent) ? ad.accent : FALLBACK_ACCENT
814 const { Box, Text, Link, Button } = $.ui.resolve(e)
815 const isTextOnly = e.surface !== 'terminal' || !arePicturesOn()
816 // The same parts as the banner: brand in its color, a dim label, a button in the brand's color.
817 const card = (
818 <Box flexDirection="column" gap={1}>
819 <Box flexDirection="row" gap={1}>
820 <Text color={accent} bold>
821 {isTextOnly ? `${ad.glyph ?? '●'} ${ad.brand}` : ad.brand}
822 </Text>
823 <Text dimColor>sponsored</Text>
824 </Box>
825 <Text>{ad.headline}</Text>
826 <Box flexDirection="row" gap={2}>
827 {href !== undefined && (
828 <Link href={href}>
829 <Text backgroundColor={tint(accent, 0.22)} color={accent} bold>
830 {` ${ad.cta ?? 'Learn more'} ↗ `}
831 </Text>
832 </Link>
833 )}
834 {ad.audio !== undefined && (
835 // Mutes or unmutes; a mute is remembered. Sound always stops when the pane closes.
836 <Button
837 key="sound"
838 label={isSoundOn ? '🔊 mute' : '🔇 sound'}
839 plain
840 dimColor={!isSoundOn}
841 onPress={() => void toggleSound($, ad)}
842 />
843 )}
844 </Box>
845 <Text dimColor>Closes when Claude finishes · /ads pause hides ads</Text>
846 </Box>
847 )
848
849 if (isTextOnly || e.surface !== 'terminal') {
850 run.drawnCut = undefined
851
852 return card
853 }
854
855 const { Image } = $.ui.resolve(e)
856 const cuts = hasVideo(ad) ? cutsOf(ad) : []
857 const art = cuts.length === 0 && ad.image === undefined ? theaterArt() : undefined
858 const shapes =
859 cuts.length > 0
860 ? cuts.map(cut => ({ width: cut.width, height: cut.height }))
861 : [{ width: ad.image?.width ?? art?.width ?? 16, height: ad.image?.height ?? art?.height ?? 9 }]
862 // The cut and arrangement that show the biggest picture in this pane, tall or wide.
863 const layout = theaterLayout(e.props.bodyColumns, e.props.scroll.bodyRows, shapes)
864 const length = cuts.length > 0 ? lengthOf(ad, layout.shape) : 0
865
866 run.drawnCut = cuts.length > 0 ? { adId: ad.id, index: layout.shape } : undefined
867
868 // A video starts on its poster frame, or where playback is; playVideo swaps the rest in.
869 const source: ImageSource =
870 (length > 0 ? frameSource(ad, layout.shape, Math.max(0, run.frame ?? posterOf(length)) % length) : undefined) ??
871 (ad.image !== undefined ? { png: ad.image.png } : { rgba: art?.rgba ?? '', width: shapes[0]?.width ?? 16, height: shapes[0]?.height ?? 9 })
872 const picture = (
873 <Image key="theater-image" source={source} columns={layout.columns} rows={layout.rows} alt={`${ad.brand}: ${ad.headline}`} />
874 )
875
876 return layout.mode === 'side' ? (
877 <Box flexDirection="row" gap={2}>
878 {picture}
879 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
880 {card}
881 </Box>
882 </Box>
883 ) : (
884 <Box flexDirection="column" gap={1}>
885 <Box flexDirection="row" justifyContent="center">
886 {picture}
887 </Box>
888 {card}
889 </Box>
890 )
891 })
892
893 on('turn.start', async ($, e, next) => {
894 // Only the turn's id is read, never its text.
895 const turnId = e.turnId
896
897 run.runningTurn = turnId
898 run.theaterTimer?.cancel()
899 run.theaterTimer = run.isTheaterOn ? $.clock.after(THEATER_DELAY_MS, () => void openTheater($, turnId)) : undefined
900
901 return next(e)
902 })
903
904 on('turn.complete', async ($, e, next) => {
905 // A subagent's turn is not the person's. Only the turn's id is read.
906 if (e.agentId === undefined) {
907 await closeTurn($, e.turnId)
908 }
909
910 return next(e)
911 })
912
913 on('ui.close', { id: 'tokenbreak-theater' }, async ($, e, next) => {
914 stopVideo()
915 await stopSound($)
916
917 if (e.origin.kind === 'person') {
918 await snoozeTheater($)
919 }
920
921 return next(e)
922 })
923
924 on('session.end', async ($, e, next) => {
925 void flush($)
926
927 return next(e)
928 })
929}
930hooks/core.ts 592 lines1import type { Ad, AdAudio, AdBatch, AdFormat, AdVideo, BannerStyle, Impression, ModRelease, UpdateState } from '../types'
2
3/**
4 * This build's version. A hooks module can't read its own plugin.json without
5 * reading a file, so it's written here too; tools/sync-public-mod.sh refuses to
6 * publish unless it equals plugin.json's and the server's MOD_LATEST_VERSION.
7 */
8export const MOD_VERSION = '0.3.3'
9
10export const THEATER_PANE = 'tokenbreak-theater'
11export const QUEUE_CAP = 200
12export const FLUSH_AT = 20
13export const FLUSH_EVERY_MS = 5 * 60_000
14/** Short turns never get a Theater: it opens once a turn has run this long. */
15export const THEATER_DELAY_MS = 3_000
16/** Below this the engine leaves an unasked pane undrawn, so Tokenbreak never asks. */
17export const THEATER_MIN_COLUMNS = 144
18/** Closing the Theater by hand keeps it away this long. */
19export const THEATER_SNOOZE_MS = 30 * 60_000
20export const FORMATS: readonly AdFormat[] = ['banner', 'theater']
21
22export const ROTATION_MS = { chill: 90_000, normal: 40_000, max: 20_000 } as const
23
24export function rotationMs(frequency: unknown): number {
25 return ROTATION_MS[frequency as keyof typeof ROTATION_MS] ?? ROTATION_MS.normal
26}
27
28/** Shown when the ad server can't be reached: Tokenbreak's own promos, never a third party's. */
29export const HOUSE_ADS: readonly Ad[] = [
30 {
31 id: 'house-rent',
32 format: 'banner',
33 brand: 'Tokenbreak',
34 glyph: '▚',
35 headline: 'This space for rent. Buy a break for your brand.',
36 accent: '#FFE600',
37 clickUrl: 'https://tokenbreak.dev/advertise',
38 isHouse: true,
39 },
40 {
41 id: 'house-earn',
42 format: 'banner',
43 brand: 'Tokenbreak',
44 glyph: '◆',
45 headline: 'Earn toward your subscription while you wait. Opening soon.',
46 accent: '#8C91FF',
47 clickUrl: 'https://tokenbreak.dev/earn',
48 isHouse: true,
49 },
50 {
51 id: 'house-ui',
52 format: 'banner',
53 brand: 'Tokenbreak UI',
54 glyph: '▣',
55 headline: 'Free terminal-style shadcn components. One npx away.',
56 accent: '#F1EFE8',
57 clickUrl: 'https://tokenbreak.dev/ui',
58 isHouse: true,
59 },
60 {
61 id: 'house-theater',
62 format: 'theater',
63 brand: 'Tokenbreak',
64 glyph: '▚',
65 headline: 'Your brand here, while Claude works. Founding slots open.',
66 accent: '#FFE600',
67 clickUrl: 'https://tokenbreak.dev/formats',
68 isHouse: true,
69 },
70]
71
72const HEX = /^#[0-9a-fA-F]{6}$/
73
74/** The most a banner logo's PNG may weigh; a bigger one draws the glyph instead. */
75export const LOGO_MAX_BYTES = 768
76
77export const isHex = (value: unknown): value is string => typeof value === 'string' && HEX.test(value)
78
79/**
80 * The href a `Link` may carry, or undefined: https, or http on localhost,
81 * printable ASCII with no `@` or space, spelled as `new URL(href).href`.
82 */
83export function linkable(href: string): string | undefined {
84 try {
85 const url = new URL(href)
86 const isLocal = url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')
87
88 if (url.protocol !== 'https:' && !isLocal) {
89 return undefined
90 }
91
92 const spelled = url.href
93
94 return spelled.length <= 2048 && /^[\x21-\x7e]+$/.test(spelled) && !spelled.includes('@') ? spelled : undefined
95 } catch {
96 return undefined
97 }
98}
99
100/**
101 * Server text made safe to draw: control characters (which could carry
102 * terminal escape sequences) become spaces and bidi overrides are dropped,
103 * then the result is cut to `max` characters.
104 */
105export const plain = (value: string, max: number): string =>
106 value
107 .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
108 .replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
109 .slice(0, max)
110
111/** A `{ png, width, height }` whose PNG decodes to at most `maxBytes`. */
112const isImage = (value: Record<string, unknown> | undefined, maxBytes: number): value is Record<string, unknown> =>
113 typeof value === 'object' &&
114 value !== null &&
115 typeof value.png === 'string' &&
116 value.png.length <= Math.ceil(maxBytes / 3) * 4 &&
117 typeof value.width === 'number' &&
118 typeof value.height === 'number'
119
120/** A loop of at most 240 frames and 8 MiB of PNG, its rate held to 1-30 fps; undefined when it isn't one. */
121function videoOf(raw: unknown): AdVideo | undefined {
122 if (typeof raw !== 'object' || raw === null) {
123 return undefined
124 }
125
126 const video = raw as Record<string, unknown>
127 const frames = video.frames
128
129 if (
130 !Array.isArray(frames) ||
131 frames.length === 0 ||
132 frames.length > 240 ||
133 !frames.every(frame => typeof frame === 'string') ||
134 frames.reduce((sum: number, frame: string) => sum + frame.length, 0) > Math.ceil((8 * 1024 * 1024) / 3) * 4 ||
135 typeof video.fps !== 'number' ||
136 typeof video.width !== 'number' ||
137 typeof video.height !== 'number'
138 ) {
139 return undefined
140 }
141
142 return { frames: frames as string[], fps: Math.min(30, Math.max(1, video.fps)), width: video.width, height: video.height }
143}
144
145/**
146 * A server ad's sound: a URL on the ad server itself (`mediaOrigin`), never a
147 * third party's. The engine fetches it from the person's machine, so any other
148 * host would see their IP each time an ad played (a tracking pixel) or could be
149 * a host on their local network. A server may not name the plugin's files either.
150 */
151function audioOf(raw: unknown, mediaOrigin: string | undefined): AdAudio | undefined {
152 const url = (raw as { url?: unknown } | undefined)?.url
153
154 if (mediaOrigin === undefined || typeof url !== 'string' || url.length > 2048) {
155 return undefined
156 }
157
158 try {
159 return new URL(url).origin === mediaOrigin ? { url } : undefined
160 } catch {
161 return undefined
162 }
163}
164
165const VERSION = /^\d+\.\d+\.\d+$/
166
167/** `a` compared with `b`, part by part: negative when older, 0 when equal. */
168export function compareVersions(a: string, b: string): number {
169 const [x, y] = [a, b].map(version => version.split('.').map(part => Number.parseInt(part, 10) || 0))
170
171 for (let at = 0; at < 3; at++) {
172 const diff = (x?.[at] ?? 0) - (y?.[at] ?? 0)
173
174 if (diff !== 0) {
175 return diff
176 }
177 }
178
179 return 0
180}
181
182/** The server's release info, when it sent a well-formed one: versions drawn on screen are digits and dots only. */
183export function releaseOf(raw: unknown): ModRelease | undefined {
184 const mod = raw as { latest?: unknown; min?: unknown } | undefined
185
186 return typeof mod?.latest === 'string' && typeof mod.min === 'string' && VERSION.test(mod.latest) && VERSION.test(mod.min)
187 ? { latest: mod.latest, min: mod.min }
188 : undefined
189}
190
191/** Where `version` stands against the server's releases; current when the server didn't say. */
192export function updateStateOf(release: ModRelease | undefined, version = MOD_VERSION): UpdateState {
193 if (release === undefined || compareVersions(version, release.latest) >= 0) {
194 return { kind: 'current' }
195 }
196
197 return compareVersions(version, release.min) < 0
198 ? { kind: 'required', latest: release.latest }
199 : { kind: 'available', latest: release.latest }
200}
201
202/** What `/ads update` prints: how to update now, and how never to fall behind again. */
203export const UPDATE_STEPS = [
204 'Update Tokenbreak:',
205 ' /plugin marketplace update tokenbreak',
206 ' /plugin update tokenbreak@tokenbreak',
207 ' /reload-plugins',
208 'Get updates automatically: /plugin → Marketplaces → tokenbreak → Enable auto-update.',
209].join('\n')
210
211const isFormat = (value: unknown): value is AdFormat => value === 'banner' || value === 'theater'
212
213function adOf(raw: unknown, mediaOrigin: string | undefined): Ad | undefined {
214 if (typeof raw !== 'object' || raw === null) {
215 return undefined
216 }
217
218 const ad = raw as Record<string, unknown>
219 const image = ad.image as Record<string, unknown> | undefined
220 const hasImage = isImage(image, 2 * 1024 * 1024)
221 const video = videoOf(ad.video)
222 const videos = Array.isArray(ad.videos)
223 ? ad.videos.slice(0, 3).flatMap(one => {
224 const cut = videoOf(one)
225
226 return cut === undefined ? [] : [cut]
227 })
228 : []
229 const audio = audioOf(ad.audio, mediaOrigin)
230 const logo = ad.logo as Record<string, unknown> | undefined
231 const hasLogo = isImage(logo, LOGO_MAX_BYTES)
232
233 if (
234 typeof ad.id !== 'string' ||
235 !isFormat(ad.format) ||
236 typeof ad.brand !== 'string' ||
237 typeof ad.headline !== 'string' ||
238 typeof ad.clickUrl !== 'string'
239 ) {
240 return undefined
241 }
242
243 return {
244 id: plain(ad.id, 64),
245 format: ad.format,
246 brand: plain(ad.brand, 24),
247 ...(typeof ad.glyph === 'string' && ad.glyph.length > 0 && { glyph: plain(ad.glyph, 2) }),
248 headline: plain(ad.headline, 80),
249 ...(typeof ad.cta === 'string' && ad.cta.trim().length > 0 && { cta: plain(ad.cta.trim(), 24) }),
250 accent: isHex(ad.accent) ? ad.accent : '#FFE600',
251 clickUrl: ad.clickUrl,
252 ...(hasImage && {
253 image: { png: image.png as string, width: image.width as number, height: image.height as number },
254 }),
255 ...(video !== undefined && { video }),
256 ...(videos.length > 0 && { videos }),
257 ...(audio !== undefined && { audio }),
258 ...(hasLogo && { logo: { png: logo.png as string, width: logo.width as number, height: logo.height as number } }),
259 isHouse: ad.isHouse === true,
260 }
261}
262
263/**
264 * An `AdBatch` from the API's JSON, its malformed ads dropped; undefined when it
265 * isn't one. Ad sound is kept only when it's served from `mediaOrigin`, the ad
266 * server's own origin; without one, no sound is kept.
267 */
268export function parseBatch(text: string, mediaOrigin?: string): AdBatch | undefined {
269 try {
270 const raw = JSON.parse(text) as Record<string, unknown>
271
272 if (!Array.isArray(raw.ads)) {
273 return undefined
274 }
275
276 const ads = raw.ads.map(one => adOf(one, mediaOrigin)).filter((ad): ad is Ad => ad !== undefined)
277 const ttl = typeof raw.ttlSeconds === 'number' && raw.ttlSeconds > 0 ? raw.ttlSeconds : 600
278
279 const mod = releaseOf(raw.mod)
280
281 return {
282 ads,
283 ttlSeconds: Math.min(ttl, 24 * 3600),
284 servedAt: typeof raw.servedAt === 'string' ? raw.servedAt : '',
285 ...(mod !== undefined && { mod }),
286 }
287 } catch {
288 return undefined
289 }
290}
291
292/** The batch as it may sit in `$.store` (4 MiB in all): images dropped once it gets big. */
293export function cacheable(batch: AdBatch): AdBatch {
294 return JSON.stringify(batch).length <= 1_500_000
295 ? batch
296 : { ...batch, ads: batch.ads.map(({ image: _image, video: _video, ...ad }) => ad) }
297}
298
299export const bannersOf = (ads: readonly Ad[]) => ads.filter(ad => ad.format === 'banner')
300
301/**
302 * The ads as `$.state` holds them: each video's frames left out, its size and
303 * rate kept. A session's state takes 4 MiB of JSON; one video's frames can fill that.
304 */
305export const withoutFrames = (ads: readonly Ad[]): Ad[] =>
306 ads.map(ad => ({
307 ...ad,
308 ...(ad.video !== undefined && { video: { ...ad.video, frames: [] } }),
309 ...(ad.videos !== undefined && { videos: ad.videos.map(cut => ({ ...cut, frames: [] })) }),
310 }))
311
312/** Every cut of an ad's video: `videos` first, then `video`. */
313export const cutsOf = (ad: Ad): AdVideo[] => [...(ad.videos ?? []), ...(ad.video === undefined ? [] : [ad.video])]
314
315/** A terminal cell is about twice as tall as it is wide. */
316export const CELL_ASPECT = 2
317
318/** Rows the Theater card takes under a picture, and columns beside one. */
319export const CARD_ROWS = 9
320export const CARD_COLUMNS = 36
321
322export type TheaterLayout = {
323 /** `stack`: picture above the card; `side`: picture left of it. */
324 mode: 'stack' | 'side'
325 /** Which of the shapes it shows. */
326 shape: number
327 columns: number
328 rows: number
329}
330
331/**
332 * The layout that shows the biggest picture in a `columns` × `rows` pane: every
333 * shape, stacked over the card or beside it, fitted without distortion.
334 */
335export function theaterLayout(
336 columns: number,
337 rows: number,
338 shapes: readonly { width: number; height: number }[],
339): TheaterLayout {
340 const fit = (mode: TheaterLayout['mode'], shape: number, roomColumns: number, roomRows: number): TheaterLayout => {
341 const { width, height } = shapes[shape] ?? { width: 16, height: 9 }
342 // Cells across per cell down that keep the picture's own proportions.
343 const ratio = (width / height) * CELL_ASPECT
344 const across = Math.max(1, Math.min(255, roomColumns, Math.floor(Math.max(1, roomRows) * ratio)))
345
346 return { mode, shape, columns: across, rows: Math.max(1, Math.min(255, Math.round(across / ratio))) }
347 }
348 const options = (shapes.length === 0 ? [0] : shapes.map((_, at) => at)).flatMap(shape => [
349 fit('stack', shape, columns, rows - CARD_ROWS),
350 ...(columns - CARD_COLUMNS >= 20 ? [fit('side', shape, columns - CARD_COLUMNS - 2, rows - 1)] : []),
351 ])
352
353 return options.reduce((best, one) => (one.columns * one.rows > best.columns * best.rows ? one : best))
354}
355
356/** The frame a video shows before it plays: three quarters in, past any fade-in. */
357export const posterOf = (frameCount: number) => Math.floor(frameCount * 0.75)
358
359export const BANNER_STYLES = ['pill', 'rule', 'card'] as const
360
361export const bannerStyleOf = (value: unknown): BannerStyle =>
362 BANNER_STYLES.includes(value as BannerStyle) ? (value as BannerStyle) : 'pill'
363
364/** `hex` mixed into `base` by `amount` (0 to 1): a dark tint of a brand color for a button's ground. */
365export function tint(hex: string, amount: number, base = '#141414'): string {
366 const channel = (color: string, at: number) => parseInt(color.slice(at, at + 2), 16)
367
368 return `#${[1, 3, 5]
369 .map(at => Math.round(channel(base, at) + (channel(hex, at) - channel(base, at)) * amount))
370 .map(value => value.toString(16).padStart(2, '0'))
371 .join('')}`
372}
373
374/** Ink that reads on `hex`: near-black on light colors, white on dark ones. */
375export function inkOn(hex: string): string {
376 const [r, g, b] = [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16) / 255)
377 const linear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
378 const luminance = 0.2126 * linear(r ?? 0) + 0.7152 * linear(g ?? 0) + 0.0722 * linear(b ?? 0)
379
380 return luminance > 0.4 ? '#141414' : '#FFFFFF'
381}
382
383/** The Theater ad: the banner brand's own when it has one, so one brand holds both slots; else the batch's first. */
384export const theaterOf = (ads: readonly Ad[], banner?: Ad): Ad =>
385 ads.find(ad => ad.format === 'theater' && ad.brand === banner?.brand) ??
386 ads.find(ad => ad.format === 'theater') ??
387 (HOUSE_ADS.find(ad => ad.format === 'theater') as Ad)
388
389export function currentBanner(ads: readonly Ad[], index: number): Ad | undefined {
390 const banners = bannersOf(ads)
391
392 return banners.length === 0 ? undefined : banners[((index % banners.length) + banners.length) % banners.length]
393}
394
395/** The newest `cap` impressions. */
396export const capQueue = (queue: readonly Impression[], cap = QUEUE_CAP): Impression[] =>
397 queue.length <= cap ? [...queue] : queue.slice(queue.length - cap)
398
399export const impressionKey = (impression: Impression) =>
400 `${impression.adId}|${impression.format}|${impression.at}|${impression.turnId ?? ''}`
401
402export function queueOf(raw: unknown): Impression[] {
403 if (!Array.isArray(raw)) {
404 return []
405 }
406
407 return raw.filter(
408 (item): item is Impression =>
409 typeof item === 'object' &&
410 item !== null &&
411 typeof (item as Impression).adId === 'string' &&
412 isFormat((item as Impression).format) &&
413 typeof (item as Impression).at === 'number',
414 )
415}
416
417/** What the band saw of the surface the last time it drew. */
418export type Seen = {
419 columns: number
420 rows: number
421 isFullscreen?: boolean
422 maxRows: number
423}
424
425/**
426 * Whether a pane opened unasked would dock beside the transcript: the
427 * fullscreen layout at 144 columns or more. A build that doesn't say
428 * `isFullscreen` docks when the band's slot is shorter than the screen.
429 */
430export function canDockTheater(seen: Seen | undefined): boolean {
431 if (seen === undefined || seen.columns < THEATER_MIN_COLUMNS) {
432 return false
433 }
434
435 return seen.isFullscreen === true || (seen.isFullscreen === undefined && seen.maxRows < seen.rows)
436}
437
438/** When a pause asked for with `arg` ends: `1h` (the default), `30m`, `2h`, or `today` (local midnight). */
439export function pauseUntil(arg: string, now: number): number | undefined {
440 const spoken = arg.trim().toLowerCase()
441
442 if (spoken === '' || spoken === '1h') {
443 return now + 3_600_000
444 }
445
446 if (spoken === 'today') {
447 const midnight = new Date(now)
448
449 midnight.setHours(24, 0, 0, 0)
450
451 return midnight.getTime()
452 }
453
454 const match = /^(\d{1,3})\s*(m|min|h|hr)$/.exec(spoken)
455
456 if (match === null) {
457 return undefined
458 }
459
460 const amount = Number(match[1])
461 const ms = match[2]?.startsWith('h') ? amount * 3_600_000 : amount * 60_000
462
463 return amount > 0 ? now + Math.min(ms, 7 * 24 * 3_600_000) : undefined
464}
465
466export function describeWait(ms: number): string {
467 const minutes = Math.round(ms / 60_000)
468
469 if (minutes < 60) {
470 return `${minutes} min`
471 }
472
473 const hours = Math.floor(minutes / 60)
474 const rest = minutes % 60
475
476 return rest === 0 ? `${hours}h` : `${hours}h ${rest}min`
477}
478
479// The Theater's built-in picture: a yellow "THIS SPACE FOR RENT" slot on
480// Klein blue with crop marks, drawn as pixel art and scaled up 4x so the
481// terminal's scaling keeps it crisp.
482
483const GLYPHS: Record<string, readonly string[]> = {
484 A: ['010', '101', '111', '101', '101'],
485 B: ['110', '101', '110', '101', '110'],
486 C: ['011', '100', '100', '100', '011'],
487 E: ['111', '100', '110', '100', '111'],
488 F: ['111', '100', '110', '100', '100'],
489 H: ['101', '101', '111', '101', '101'],
490 I: ['111', '010', '010', '010', '111'],
491 K: ['101', '101', '110', '101', '101'],
492 N: ['101', '111', '111', '111', '101'],
493 O: ['010', '101', '101', '101', '010'],
494 P: ['110', '101', '110', '100', '100'],
495 R: ['110', '101', '110', '101', '101'],
496 S: ['011', '100', '010', '001', '110'],
497 T: ['111', '010', '010', '010', '010'],
498 ' ': ['000', '000', '000', '000', '000'],
499}
500
501type Rgb = readonly [number, number, number]
502
503const COBALT: Rgb = [26, 31, 208]
504const YELLOW: Rgb = [255, 230, 0]
505const CHARCOAL: Rgb = [20, 20, 20]
506const CREAM: Rgb = [241, 239, 232]
507
508const ART_W = 64
509const ART_H = 36
510const SCALE = 4
511
512let art: { rgba: string; width: number; height: number } | undefined
513
514function toBase64(bytes: Uint8Array): string {
515 let binary = ''
516
517 for (let i = 0; i < bytes.length; i += 0x8000) {
518 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
519 }
520
521 return btoa(binary)
522}
523
524/** The fallback Theater picture as RGBA bytes (base64), built once. */
525export function theaterArt(): { rgba: string; width: number; height: number } {
526 if (art !== undefined) {
527 return art
528 }
529
530 const grid: Rgb[] = Array.from({ length: ART_W * ART_H }, () => COBALT)
531 const put = (x: number, y: number, color: Rgb) => {
532 if (x >= 0 && x < ART_W && y >= 0 && y < ART_H) {
533 grid[y * ART_W + x] = color
534 }
535 }
536 const text = (word: string, y: number, color: Rgb) => {
537 const x0 = Math.floor((ART_W - (word.length * 4 - 1)) / 2)
538
539 ;[...word].forEach((char, i) => {
540 ;(GLYPHS[char] ?? GLYPHS[' '])?.forEach((row, dy) => {
541 ;[...row].forEach((bit, dx) => bit === '1' && put(x0 + i * 4 + dx, y + dy, color))
542 })
543 })
544 }
545
546 // The slot, with a dashed charcoal edge.
547 for (let y = 4; y <= 23; y += 1) {
548 for (let x = 6; x <= 57; x += 1) {
549 const isEdge = x === 6 || x === 57 || y === 4 || y === 23
550
551 put(x, y, isEdge && (x + y) % 3 !== 0 ? CHARCOAL : YELLOW)
552 }
553 }
554
555 // Crop marks at the slot's corners.
556 for (const [cx, cy, sx, sy] of [
557 [6, 4, -1, -1],
558 [57, 4, 1, -1],
559 [6, 23, -1, 1],
560 [57, 23, 1, 1],
561 ] as const) {
562 for (let d = 2; d <= 4; d += 1) {
563 put(cx + sx * d, cy, CREAM)
564 put(cx, cy + sy * d, CREAM)
565 }
566 }
567
568 text('THIS SPACE', 8, CHARCOAL)
569 text('FOR RENT', 15, CHARCOAL)
570 text('TOKENBREAK', 28, CREAM)
571
572 const width = ART_W * SCALE
573 const height = ART_H * SCALE
574 const bytes = new Uint8Array(width * height * 4)
575
576 for (let y = 0; y < height; y += 1) {
577 for (let x = 0; x < width; x += 1) {
578 const [r, g, b] = grid[Math.floor(y / SCALE) * ART_W + Math.floor(x / SCALE)] ?? COBALT
579 const at = (y * width + x) * 4
580
581 bytes[at] = r
582 bytes[at + 1] = g
583 bytes[at + 2] = b
584 bytes[at + 3] = 255
585 }
586 }
587
588 art = { rgba: toBase64(bytes), width, height }
589
590 return art
591}
592types/index.d.ts 124 lines1// Tokenbreak's type contract.
2//
3// The wire types below mirror packages/shared/src/index.ts in the Tokenbreak
4// monorepo (the API's zod schemas). A hooks module cannot import npm
5// packages, so they are copied by hand: change both together.
6
7export type AdFormat = 'banner' | 'theater'
8
9export type AdImage = {
10 /** A whole PNG, base64; at most 2 MiB decoded (the Image element's limit). */
11 png: string
12 width: number
13 height: number
14}
15
16/** A short loop, as PNG frames played in order. */
17export type AdVideo = {
18 /** Whole PNGs, base64, all the same size. */
19 frames: string[]
20 /**
21 * Built-in ads only: absolute paths of PNG files the terminal reads itself, in
22 * place of `frames`. Never taken from the ad server, which may not name files.
23 */
24 files?: string[]
25 /** Frames a second, 1 to 30. */
26 fps: number
27 width: number
28 height: number
29}
30
31/** An ad's sound: a URL the engine fetches, or (built-in ads only) a file the plugin ships. */
32export type AdAudio = { url: string } | { asset: string }
33
34export type Ad = {
35 id: string
36 format: AdFormat
37 brand: string
38 glyph?: string
39 /**
40 * Banner only: the brand's mark as a tiny PNG (36x36, at most 768 bytes), drawn in
41 * two cells. Kept tiny on purpose: a picture's data can be cut off mid-send while
42 * the screen redraws and spill out as text, and a small one goes in one short piece.
43 */
44 logo?: AdImage
45 headline: string
46 /** Banner only: the button's words (`Start free`); `Learn more` when absent. */
47 cta?: string
48 /** `#rrggbb` */
49 accent: string
50 /** Tracked click-through (`/c/<token>`), which logs and redirects. */
51 clickUrl: string
52 /** Theater only. */
53 image?: AdImage
54 /** Theater only: plays in place of `image` where the terminal shows pictures. */
55 video?: AdVideo
56 /** Theater only: cuts of the video in other shapes (9:16, 16:9, 1:1); the Theater plays the one that fills its pane best. */
57 videos?: AdVideo[]
58 /** Theater only: the video's sound, played only when the person presses the sound button. */
59 audio?: AdAudio
60 isHouse: boolean
61}
62
63/** The mod's newest release, and the oldest that still earns. */
64export type ModRelease = {
65 latest: string
66 min: string
67}
68
69export type AdBatch = {
70 ads: Ad[]
71 ttlSeconds: number
72 servedAt: string
73 mod?: ModRelease
74}
75
76/** Where this build stands: current, an update out, or too old to earn. */
77export type UpdateState =
78 | { kind: 'current' }
79 | { kind: 'available'; latest: string }
80 | { kind: 'required'; latest: string }
81
82export type Impression = {
83 adId: string
84 format: AdFormat
85 /** Epoch milliseconds. */
86 at: number
87 turnId?: string
88}
89
90export type ImpressionBatch = {
91 deviceId: string
92 /** The reporting mod's version; the server credits nothing from one under its minimum. */
93 modVersion?: string
94 impressions: Impression[]
95}
96
97/** How the banner is drawn: `pill` a colored call-to-action, `rule` an accent bar and an underlined link, `card` a bordered box. */
98export type BannerStyle = 'pill' | 'rule' | 'card'
99
100/** Where the ads on screen came from: the API, the store's last batch, or the module itself. */
101export type AdSource = 'server' | 'cache' | 'built-in'
102
103/** The Theater ad showing in the pane, for the turn it belongs to. */
104export type TheaterShowing = {
105 adId: string
106 turnId: string
107 isPlaced: boolean
108}
109
110declare module 'claude-code' {
111 interface PluginState {
112 tokenbreak: {
113 ads: Ad[]
114 source: AdSource
115 bannerIndex: number
116 pausedUntil: number | null
117 theater: TheaterShowing | null
118 bannerStyle: BannerStyle
119 theaterSound: boolean
120 update: UpdateState
121 }
122 }
123}
124