SLOPSHOPPER

tokenbreak

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

newpanebandcommandtoaststatus
v0.3.3MITupdated 2026-10-06steven-3/tokenbreak/plugins/tokenbreak
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokenbreak
│ ┃ tokenbreak-theater ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Tokenbreak: Your brand here, while Claude │ ┃ ⏺ Read(src/auth.ts) │ ┃ Tokenbreak sponsored ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Your brand here, while Claude works. ⎿ Added 2 lines, removed 1 line │ ┃ Founding slots open. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ https://tokenbreak.dev/formats │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Closes when Claude finishes · /ads pause │ ┃ hides ads ✻ Worked for 42s · done 4:20 PM │ │ › /ads │ ⎿ tokenbreak: Tokenbreak · on │ ⎿ tokenbreak: Showing: ad house-rent │ ⎿ tokenbreak: Ads from: built-in house ads (ad server unreachable) │ ⎿ tokenbreak: Version: 0.3.3 │ ⎿ tokenbreak: Theater: on (fullscreen layout, 144+ columns, turns │ ⎿ tokenbreak: Impressions queued: 0 │ │ ▚ https://tokenbreak.dev/advertise https://tokenbreak.dev/advertise sponsored · ✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▚ https://tokenbreak.dev/advertise https://tokenbreak.dev/advertise sponsored · ✕
Pane · tokenbreak-theater
▣ Tokenbreak: Your brand here, while Claude works. Founding Tokenbreak sponsored Your brand here, while Claude works. Founding slots open. https://tokenbreak.dev/formats Closes when Claude finishes · /ads pause hides ads
README

Tokenbreak for Claude Code

Opt-in ads in Claude Code. Tokenbreak is unofficial and not affiliated with Anthropic.

  • Prompt Banner: a one-line ad above the prompt, a blank row above it, that rotates every 20, 40 or 90 seconds. The brand's logo (a tiny 36×36 picture, or a colored character where pictures are off) and its name and headline open the ad, beside a call-to-action button in the brand's color, a dim sponsored label and ✕ (hide for an hour).
  • Theater: a pane beside the transcript while Claude works. It only opens in the fullscreen layout, at 144+ columns, once a turn has run for 3 seconds, and it closes when the turn ends. If you close it yourself, it stays away for 30 minutes. It shows the current banner brand's Theater ad when there is one. A video ad can come in several cuts (vertical, landscape); the Theater plays the one that fills the pane best, stacked over the text or beside it. A video ad's sound plays with it (macOS) unless you've muted it: the mute button silences ads and stays muted across sessions until you unmute. Sound always stops when the pane closes.
  • /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.

What it never does

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.

What it sends

  • 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.
  • An impression is counted at most once per Claude turn, for the banner that was on screen and the Theater if it was shown.
  • Built-in house ads, which are shown when the server can't be reached, are never reported.

Settings (/config)

FieldDefault
endpointhttps://tokenbreak.devUse http://localhost:3000 while running the site locally
frequencynormalchill 90s, normal 40s, max 20s
theateronoff never opens the pane
picturesonBanner logos, Theater images and video; off keeps ads text only
soundonA video ad's sound when the Theater opens; off plays it only when you press the sound button

Install

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

Updates

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.

Develop

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.

Source 3 files
hooks/register.tsx 930 lines
1import { 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}
930
hooks/core.ts 592 lines
1import 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}
592
types/index.d.ts 124 lines
1// 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