Shows a sponsored Koah ad under each answer, served through the Sidequest site

███████╗██╗██████╗ ███████╗ ██████╗ ██╗ ██╗███████╗███████╗████████╗
██╔════╝██║██╔══██╗██╔════╝██╔═══██╗██║ ██║██╔════╝██╔════╝╚══██╔══╝
███████╗██║██║ ██║█████╗ ██║ ██║██║ ██║█████╗ ███████╗ ██║
╚════██║██║██║ ██║██╔══╝ ██║▄▄ ██║██║ ██║██╔══╝ ╚════██║ ██║
███████║██║██████╔╝███████╗╚██████╔╝╚██████╔╝███████╗███████║ ██║
╚══════╝╚═╝╚═════╝ ╚══════╝ ╚══▀▀═╝ ╚═════╝ ╚══════╝╚══════╝ ╚═╝
A short detour under every Claude Code answer.
One clearly labeled sponsored card, right where the answer ends.
Install · Use · Privacy · Development
Claude answers as usual. When the turn ends, Sidequest adds a divider and one ad under the last message of that answer:
⏺ Use a middleware file at the project root and match only the routes you
need, so static assets skip it.
────────────────────────────────────────────────────────────────────────
[▣] Patagonia Sponsored
Built to last, repaired for free. Patagonia's Ironclad Guarantee means...
╭────────────╮
│ Learn More │
╰────────────╯
Open getsidequest.dev/account and sign in. The setup page shows how to install and connect Sidequest in the terminal or in Claude Desktop.
| Action | How |
|---|---|
| Show whether ads are on | /sidequest |
| Turn ads on or off | /sidequest on or /sidequest off |
| Count an impression | Automatic, once per ad, when the ad is drawn |
Each request goes to getsidequest.dev. It carries the last question and answer (up to 4,000 characters each), the session ID, and a random per-machine device key. The site forwards the request to Koah with your account. The mod does no IP lookup: the site sees your IP as any web request does. It never sends an email address or phone number.
claude plugin install --marketplace first shipped in 2.1.292./status in a local Code session to see that version. It can differ from claude --version.curl. Icons that are not PNG also need sips, which macOS includes. Ads show without an icon when it fails to load./sidequest-connect, or the status line shows sidequest: not connected. Run /sidequest-connect./reload-plugins.claude: command not found. Install the CLI with the official setup guide.Run these commands in a terminal. After an update, start a new session.
claude plugin update sidequest@sidequest
claude plugin uninstall sidequest@sidequest
claude plugin marketplace remove sidequest
The last command is optional. It removes the marketplace.
bun install. It also installs the git hooks that lint, format and typecheck staged files. claude --plugin-dir plugin
To use a local Sidequest site, add --settings '{"env":{"SIDEQUEST_URL":"http://localhost:3001"}}'. The plugin accepts this override only for http://localhost or http://127.0.0.1, and only from your own user settings or the --settings flag.
plugin/.claude-plugin/types. bun run typecheck needs them.bun run uninstall-plugin runs the uninstall and the marketplace remove.
claude plugin validate plugin
bun run test
bun run typecheck
bun run lint
claude plugin marketplace add "$PWD"
claude plugin install sidequest@sidequest
--plugin-dir plugin more strictly, so a good drawing can fail there.Box that holds the engine's reply. Desktop then drops the drawing and shows the plain reply.hooks/register.tsx 755 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { AdSlot, KoahAd } from "../types";
5import { BAND_ADS } from "./features";
6
7const MAX_TEXT = 4000;
8const MAX_SLOTS = 50;
9const SITE = "https://getsidequest.dev";
10const POLL_MS = 1000;
11const NEVER_ENTER = "Only enter a code that Claude showed you.";
12const connectUrl = (base: string, userCode: string) => `${base}/connect?code=${userCode}`;
13const codeNotice = (base: string, userCode: string) =>
14 `copy ${connectUrl(base, userCode)} into your browser. ${NEVER_ENTER}`;
15
16const slots = atom({ plugin: "sidequest", key: "slots" } as const, [] as AdSlot[]);
17const band = atom({ plugin: "sidequest", key: "band" } as const, null as KoahAd | null);
18
19type Settings = { inline: boolean; band: boolean };
20
21const USAGE = BAND_ADS
22 ? "Usage: /sidequest on | off | inline on | inline off | band on | band off"
23 : "Usage: /sidequest on | off";
24
25const COMMANDS = new Map<string, Partial<Settings>>([
26 ["on", { inline: true, band: BAND_ADS }],
27 ["off", { inline: false, band: false }],
28 ...(BAND_ADS
29 ? ([
30 ["inline on", { inline: true }],
31 ["inline off", { inline: false }],
32 ["band on", { band: true }],
33 ["band off", { band: false }],
34 ] as const)
35 : []),
36]);
37
38type Pending = { key: string; expiresAt: number; userCode: string };
39
40let siteOnce: Promise<string> | undefined;
41let osOnce: Promise<Os | undefined> | undefined;
42let clientOnce: Promise<string | undefined> | undefined;
43let pluginVersionOnce: Promise<string | undefined> | undefined;
44let claudeVersionOnce: Promise<string | undefined> | undefined;
45let terminalProgramOnce: Promise<string | undefined> | undefined;
46let fullscreen: boolean | undefined;
47// Whether this terminal draws an Image's pixels; unknown until a blit answers.
48let imagesDraw: boolean | undefined;
49let imagesProbe: Promise<void> | undefined;
50let settingsOnce: Promise<Settings> | undefined;
51let question = "";
52let bandTurnId: string | undefined;
53let bandColumns: number | undefined;
54
55const BAND_HOVER_SCOPE = "sidequest-band";
56const BAND_FIRST_TEXT_LENGTH = 60;
57const BAND_MIN_TEXT_LENGTH = 40;
58const BAND_MAX_TEXT_LENGTH = 300;
59// Logo 2, "Ad" 2, four gaps, and about 25 for the brand and the call to action, which are unknown until the ad arrives.
60const BAND_LINE_OVERHEAD = 2 + 2 + 4 + 25;
61
62// Impression ids already reported this load; Koah dedupes impressions per served ad.
63const reported = new Set<string>();
64const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
65
66async function storedDeviceKey($: EngineInterface): Promise<string | undefined> {
67 const saved = await $.store.get("deviceKey");
68 return typeof saved === "string" ? saved : undefined;
69}
70
71async function storedPending($: EngineInterface): Promise<Pending | undefined> {
72 const saved = (await $.store.get("pending")) as Partial<Pending> | undefined;
73 return typeof saved?.key === "string" &&
74 typeof saved.expiresAt === "number" &&
75 typeof saved.userCode === "string"
76 ? (saved as Pending)
77 : undefined;
78}
79
80function readSettings($: EngineInterface): Promise<Settings> {
81 return (settingsOnce ??= $.store.get("settings").then((saved) => {
82 const stored = (saved ?? {}) as Partial<Settings>;
83 return { inline: stored.inline !== false, band: BAND_ADS && stored.band !== false };
84 }));
85}
86
87const settingsText = (s: Settings) =>
88 BAND_ADS
89 ? `Inline ads: ${s.inline ? "on" : "off"}. Band ads: ${s.band ? "on" : "off"}.`
90 : `Ads: ${s.inline ? "on" : "off"}.`;
91
92function site($: EngineInterface): Promise<string> {
93 return (siteOnce ??= resolveSite($));
94}
95
96// The person launching Claude Code sets user settings and --settings; a repo cannot. Only a loopback URL is accepted, and --settings wins.
97async function resolveSite($: EngineInterface): Promise<string> {
98 for (const source of ["flag", "user"] as const) {
99 try {
100 const { env } = await $.settings.read({ source });
101 const url = new URL((env as Record<string, string> | undefined)?.SIDEQUEST_URL ?? "");
102 if (url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))
103 return url.origin;
104 } catch {}
105 }
106 return SITE;
107}
108
109const OPENERS = {
110 macos: ["open"],
111 linux: ["xdg-open"],
112 windows: ["rundll32", "url.dll,FileProtocolHandler"],
113};
114type Os = keyof typeof OPENERS;
115
116// With an unknown OS it tries every opener in turn. The reply always shows the URL, so every failure is ignored.
117async function openBrowser($: EngineInterface, url: string, os: Os | undefined) {
118 for (const opener of os ? [OPENERS[os]] : Object.values(OPENERS)) {
119 try {
120 if ((await $.process.run([...opener, url])).exitCode === 0) return;
121 } catch {}
122 }
123}
124
125const joined = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" · ");
126
127async function statusLines($: EngineInterface, current: Settings) {
128 const device = await deviceMetadata($);
129 const os = await currentOs($);
130 const base = await site($);
131 const app = joined(
132 device.client,
133 device.terminalProgram,
134 device.fullscreen !== undefined && (device.fullscreen ? "fullscreen" : "not fullscreen"),
135 );
136 const account = (await storedDeviceKey($))
137 ? "connected"
138 : "not connected — run /sidequest-connect";
139 return [
140 settingsText(current),
141 joined(
142 device.pluginVersion && `Sidequest v${device.pluginVersion}`,
143 device.claudeVersion && `Claude Code ${device.claudeVersion}`,
144 ),
145 app && `App: ${app}`,
146 joined(os && `OS: ${os}`, `Account: ${account}`),
147 base !== SITE && `Site: ${base}`,
148 ].filter(Boolean);
149}
150
151function newDeviceKey(): string {
152 const bytes = crypto.getRandomValues(new Uint8Array(32));
153 const b64 = btoa(String.fromCharCode(...bytes));
154 return `sq_${b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
155}
156
157// Only the OS name goes to the site, never a hostname. Windows sets OS and has no uname.
158async function detectOs($: EngineInterface): Promise<Os | undefined> {
159 if ((await $.env.get("OS")) === "Windows_NT") return "windows";
160 try {
161 const { exitCode, stdout } = await $.process.run(["uname", "-s"]);
162 const names: Record<string, Os | undefined> = { Darwin: "macos", Linux: "linux" };
163 return exitCode === 0 ? names[stdout.trim()] : undefined;
164 } catch {
165 return undefined;
166 }
167}
168
169function currentOs($: EngineInterface): Promise<Os | undefined> {
170 return (osOnce ??= detectOs($));
171}
172
173// Claude Code sets the entrypoint. The site turns the two known clients into the Koah slot it
174// reports by; any other entrypoint (the IDE extension, the SDK) is sent as it is.
175const CLIENTS = new Map([
176 ["claude-desktop", "desktop"],
177 ["cli", "terminal"],
178]);
179function currentClient($: EngineInterface): Promise<string | undefined> {
180 return (clientOnce ??= $.env
181 .get("CLAUDE_CODE_ENTRYPOINT")
182 .then((entrypoint) => (entrypoint ? (CLIENTS.get(entrypoint) ?? entrypoint) : undefined)));
183}
184
185// `bun install` writes the gitignored .local file, so only a copy from a local checkout has it.
186function pluginVersion($: EngineInterface): Promise<string | undefined> {
187 return (pluginVersionOnce ??= Promise.all([
188 $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
189 $.fs.exists(`${$.plugin.root}/.local`),
190 ])
191 .then(([manifest, local]) => `${JSON.parse(manifest).version}${local ? "+local" : ""}`)
192 .catch(() => undefined));
193}
194
195function claudeVersion($: EngineInterface): Promise<string | undefined> {
196 return (claudeVersionOnce ??= $.session
197 .version()
198 .then(({ version }) => version)
199 .catch(() => undefined));
200}
201
202// Only Claude in a terminal has one. TERM_PROGRAM is inherited (kitty sets none, so a
203// kitty opened from Ghostty says "ghostty"); the bundle id is the app's own when macOS
204// launched it. Inside tmux it is the app that started the tmux server.
205function terminalProgram($: EngineInterface): Promise<string | undefined> {
206 return (terminalProgramOnce ??= currentClient($)
207 .then(async (client) =>
208 client === "terminal"
209 ? ((await $.env.get("__CFBundleIdentifier")) ?? (await $.env.get("TERM_PROGRAM")))
210 : undefined,
211 )
212 .catch(() => undefined));
213}
214
215// What the site shows for this computer; sent with the ad request and when connecting.
216async function deviceMetadata($: EngineInterface) {
217 return {
218 client: await currentClient($),
219 pluginVersion: await pluginVersion($),
220 claudeVersion: await claudeVersion($),
221 terminalProgram: await terminalProgram($),
222 fullscreen,
223 };
224}
225
226// Polls until the site confirms the pending key, refuses it, or it expires.
227// A newer /sidequest-connect replaces the stored key, which ends this poll.
228// The code stays on the status line until the poll ends, also after a restart.
229async function pollConnection($: EngineInterface, { key, expiresAt, userCode }: Pending) {
230 const base = await site($);
231 $.ui.status(codeNotice(base, userCode));
232 const finish = async (toast: string) => {
233 await $.store.delete("pending");
234 $.ui.status(undefined);
235 $.ui.toast(toast);
236 };
237
238 const stillPending = async () => {
239 const current = await storedPending($);
240 if (current?.key === key) return true;
241 if (!current) $.ui.status(undefined);
242 return false;
243 };
244
245 while ((await $.clock.now()) < expiresAt) {
246 await $.clock.sleep(POLL_MS);
247 const res = await $.http
248 .fetch(`${base}/api/device`, { headers: { Authorization: `Bearer ${key}` } })
249 .catch(() => undefined);
250 if (!(await stillPending())) return;
251 if (res?.status === 200) {
252 const { email } = JSON.parse(res.text) as { email: string };
253 await $.store.set("deviceKey", key);
254 return finish(`Sidequest connected as ${email}.`);
255 }
256 if (res?.status === 403) return finish("Sidequest: this account is suspended.");
257 if (res?.status === 404) break;
258 }
259 if (await stillPending()) {
260 await finish("Sidequest: the code expired. Run /sidequest-connect again.");
261 }
262}
263
264// $.http.fetch returns text only, so curl fetches the icon and base64 encodes
265// it. Advertisers serve ICOs (Baseten) and large PNGs (Koah monograms), so sips
266// turns every icon into a PNG of at most 64px. It also shrinks the icon to 8x8
267// and prints the BMP's 64 ARGB words on a "px" line, for brandColor to pick from.
268const FETCH_ICON = `
269t=$(mktemp -d) || exit 1
270trap 'rm -rf "$t"' EXIT
271curl -sfL --max-time 5 "$1" -o "$t/icon" || exit 1
272sips -s format png -Z 64 "$t/icon" --out "$t/icon.png" >/dev/null 2>&1 || exit 1
273base64 < "$t/icon.png"
274sips -s format bmp -z 8 8 "$t/icon.png" --out "$t/px.bmp" >/dev/null 2>&1 || exit 0
275at=$(od -An -tu4 -j10 -N4 "$t/px.bmp" | tr -d ' ')
276echo px "$(od -v -An -tx4 -j"$at" -N256 "$t/px.bmp" | tr -s " \\n" " ")"
277`;
278
279// The most saturated opaque pixel, so a logo's color wins over its background
280// and its transparent corners; a grey logo gets the average of its pixels.
281function brandColor(words: string[]): string | undefined {
282 const pixels = words
283 .map((word) => parseInt(word, 16))
284 .filter((argb) => argb >>> 24 >= 0x80)
285 .map((argb) => [(argb >> 16) & 0xff, (argb >> 8) & 0xff, argb & 0xff]);
286 if (pixels.length === 0) return undefined;
287 const saturation = (rgb: number[]) => Math.max(...rgb) - Math.min(...rgb);
288 const vivid = pixels.reduce((best, rgb) => (saturation(rgb) > saturation(best) ? rgb : best));
289 const rgb =
290 saturation(vivid) >= 48
291 ? vivid
292 : [0, 1, 2].map((i) => Math.round(pixels.reduce((sum, p) => sum + p[i]!, 0) / pixels.length));
293 return `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
294}
295
296type Icon = Pick<KoahAd, "iconPng" | "iconColor">;
297const icons = new Map<string, Icon>();
298async function favicon($: EngineInterface, advertiser: KoahAd["advertiser"]): Promise<Icon> {
299 const url =
300 advertiser.iconUrl ||
301 (advertiser.rootDomain ? `https://${advertiser.rootDomain}/favicon.ico` : null);
302 if (!url) return {};
303 const cached = icons.get(url);
304 if (cached) return cached;
305 const { exitCode, stdout } = await $.process.run(["/bin/sh", "-c", FETCH_ICON, "sh", url]);
306 const words =
307 stdout
308 .match(/^px (.*)$/m)?.[1]
309 ?.trim()
310 .split(/\s+/) ?? [];
311 const iconColor = brandColor(words);
312 const png = stdout.replace(/^px .*$/m, "").replace(/\s/g, "");
313 // Keep PNGs only: a bad image makes the engine refuse the whole tree.
314 const icon = {
315 iconPng: exitCode === 0 && png.startsWith("iVBORw0KGgo") ? png : undefined,
316 iconColor,
317 };
318 icons.set(url, icon);
319 return icon;
320}
321
322async function reportImpression(
323 $: EngineInterface,
324 base: string,
325 impressionId: string,
326 key: string,
327) {
328 if (reported.has(impressionId)) return;
329 reported.add(impressionId);
330 const url = `${base}/api/impression/${impressionId}`;
331 for (const wait of [0, 1000, 4000, 15000]) {
332 if (wait) await $.clock.sleep(wait);
333 const res = await $.http.fetch(url, {
334 method: "POST",
335 headers: { Authorization: `Bearer ${key}` },
336 });
337 if (res.ok) return;
338 // Permanent errors stop; 429 and 5xx back off and reuse the same URL.
339 if (res.status !== 429 && res.status < 500) return;
340 }
341}
342
343// $.state outlives a reload, so a slot can hold an ad from an older shape.
344// The mod builds the impression URL from the id, so the device key goes only to this site.
345function isAd(ad: unknown): ad is KoahAd {
346 const a = ad as Partial<KoahAd> | null;
347 return (
348 typeof a?.adText === "string" &&
349 typeof a.adCta === "string" &&
350 typeof a.adLink === "string" &&
351 typeof a.impressionId === "string" &&
352 UUID.test(a.impressionId) &&
353 typeof a.advertiser?.name === "string"
354 );
355}
356
357function bandTextLength() {
358 if (bandColumns === undefined) return BAND_FIRST_TEXT_LENGTH;
359 return Math.min(
360 Math.max(bandColumns - BAND_LINE_OVERHEAD, BAND_MIN_TEXT_LENGTH),
361 BAND_MAX_TEXT_LENGTH,
362 );
363}
364
365async function requestAd(
366 $: EngineInterface,
367 key: string,
368 placement: "inline" | "band",
369 answer?: string,
370): Promise<KoahAd | undefined> {
371 const res = await $.http.fetch(`${await site($)}/api/ad`, {
372 method: "POST",
373 headers: {
374 Authorization: `Bearer ${key}`,
375 "Content-Type": "application/json",
376 },
377 body: JSON.stringify({
378 placement,
379 ...(await deviceMetadata($)),
380 question: question.slice(0, MAX_TEXT),
381 answer: answer?.slice(0, MAX_TEXT),
382 textLength: placement === "band" ? bandTextLength() : undefined,
383 conversationId: (await $.session.id()).slice(0, 64),
384 }),
385 });
386
387 // A served or empty answer means the site works again, so an old problem leaves the status line.
388 if (res.status === 200 || res.status === 204) $.ui.status(undefined);
389 if (res.status === 200) {
390 const served = JSON.parse(res.text) as KoahAd;
391 Object.assign(served, await favicon($, served.advertiser));
392 return served;
393 }
394 if (res.status === 401) {
395 $.ui.status("not connected — run /sidequest-connect");
396 } else if (res.status === 403) {
397 $.ui.status("account suspended");
398 } else if (res.status !== 204) {
399 $.ui.status("ads unavailable — trying again next prompt");
400 }
401 return undefined;
402}
403
404// The ad is on screen now: that is the CLI impression event.
405async function reportOnScreen($: EngineInterface, ad: KoahAd) {
406 if (reported.has(ad.impressionId)) return;
407 const key = await storedDeviceKey($);
408 if (key) void reportImpression($, await site($), ad.impressionId, key);
409}
410
411async function refreshBand($: EngineInterface, turnId: string) {
412 const key = await storedDeviceKey($);
413 if (!key || !question.trim() || !(await readSettings($)).band) return;
414 try {
415 const ad = await requestAd($, key, "band");
416 if (ad && bandTurnId === turnId && (await readSettings($)).band) {
417 await update($, band, () => ad);
418 }
419 } catch {}
420}
421
422type RenderEvent = Parameters<EngineInterface["ui"]["resolve"]>[0];
423
424// A blit to an Image is denied where the terminal draws its alt instead of the
425// pixels (Terminal.app). The first drawn ad asks once; a no switches to the badge,
426// and no answer at all keeps the Image.
427async function probeImages($: EngineInterface, requestId: string, png: string) {
428 for (const wait of [100, 500, 2000]) {
429 await $.clock.sleep(wait);
430 const { deny } = await $.ui.blit({ requestId, key: "logo", source: { png } });
431 if (!deny) break;
432 if (deny.includes("alt")) {
433 imagesDraw = false;
434 return $.ui.invalidate("ui.render");
435 }
436 }
437 imagesDraw = true;
438}
439
440function openAd($: EngineInterface, ad: KoahAd) {
441 void currentOs($).then((os) => openBrowser($, ad.adLink, os));
442}
443
444function luma(rgb: number) {
445 return 0.299 * (rgb >> 16) + 0.587 * ((rgb >> 8) & 0xff) + 0.114 * (rgb & 0xff);
446}
447
448// The brand color for hovered text, unless it is so dark or so light that a dark or a
449// light theme would hide it (a black Nike icon); then the theme's own color.
450function brandHover(ad: KoahAd) {
451 if (!ad.iconColor) return undefined;
452 const brightness = luma(parseInt(ad.iconColor.slice(1), 16));
453 return brightness > 50 && brightness < 205 ? { color: ad.iconColor } : undefined;
454}
455
456function adIcon($: EngineInterface, e: RenderEvent, ad: KoahAd) {
457 if (e.surface === "terminal") {
458 const { Image, Text } = $.ui.resolve(e);
459 if (ad.iconPng && imagesDraw !== false) {
460 return <Image key="logo" source={{ png: ad.iconPng }} columns={2} rows={1} alt=" " />;
461 }
462 if (!ad.iconColor) return null;
463 // The advertiser's initial on its brand color, where the terminal draws no image.
464 const dark = luma(parseInt(ad.iconColor.slice(1), 16)) > 150;
465 return (
466 <Text color={dark ? "#000000" : "#ffffff"} backgroundColor={ad.iconColor}>
467 {` ${ad.advertiser.name.trim().charAt(0).toUpperCase()} `}
468 </Text>
469 );
470 }
471 if (ad.iconPng && (e.surface === "desktop" || e.surface === "vscode")) {
472 // The PNG inside an Svg, in a box of fixed size (2 cells by 1): in a row an
473 // Svg with no sized box collapses. Desktop and the IDE panel draw nothing
474 // for an Svg whose alt is blank, so it carries the advertiser's name.
475 const { Box, Svg } = $.ui.resolve(e);
476 const source =
477 '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">' +
478 `<image href="data:image/png;base64,${ad.iconPng}" width="64" height="64"/></svg>`;
479 return (
480 <Box width={2} height={1} flexShrink={0}>
481 <Svg source={source} width={16} height={16} alt={`${ad.advertiser.name} logo`} />
482 </Box>
483 );
484 }
485 return null;
486}
487
488export const register: Register = (on) => {
489 siteOnce = undefined;
490 osOnce = undefined;
491 clientOnce = undefined;
492 pluginVersionOnce = undefined;
493 claudeVersionOnce = undefined;
494 terminalProgramOnce = undefined;
495 fullscreen = undefined;
496 imagesDraw = undefined;
497 imagesProbe = undefined;
498 settingsOnce = undefined;
499 bandColumns = undefined;
500
501 on("session.start", async ($, e, next) => {
502 await $.command.register({
503 name: "sidequest",
504 description: BAND_ADS
505 ? "Turn Sidequest ads on or off, or choose inline or band ads"
506 : "Turn Sidequest ads on or off",
507 });
508 await $.command.register({
509 name: "sidequest-connect",
510 description: "Link Sidequest to your account in the browser",
511 });
512 const pending = await storedPending($);
513 if (pending) void pollConnection($, pending).catch(() => {});
514 else if (!(await storedDeviceKey($))) {
515 $.ui.toast("sidequest: run /sidequest-connect to link your account");
516 }
517 return next(e);
518 });
519
520 on("command.run", { command: "sidequest" }, async ($, e) => {
521 const args = e.args.trim().toLowerCase().split(/\s+/).join(" ");
522 const change = COMMANDS.get(args);
523 const current = await readSettings($);
524 // The output is for the person. Without this, Claude brings it up in later answers.
525 const reply = (text: string) => ({
526 text,
527 context: ["The user ran /sidequest. Its output is for them only. Do not mention it."],
528 });
529 if (!args) return reply(`${(await statusLines($, current)).join("\n")}\n${USAGE}`);
530 if (!change) return reply(USAGE);
531
532 const next = { ...current, ...change };
533 await $.store.set("settings", next);
534 settingsOnce = Promise.resolve(next);
535 if (!next.band) await update($, band, () => null);
536 $.ui.invalidate("ui.render");
537 return reply(settingsText(next));
538 });
539
540 on("command.run", { command: "sidequest-connect" }, async ($) => {
541 const base = await site($);
542 const key = newDeviceKey();
543 const os = await currentOs($);
544 const res = await $.http
545 .fetch(`${base}/api/device`, {
546 method: "POST",
547 headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
548 body: JSON.stringify({ os, ...(await deviceMetadata($)) }),
549 })
550 .catch(() => undefined);
551 if (res?.status === 403) return { text: "only available in the US for now." };
552 if (res?.status === 429)
553 return { text: "Too many connection attempts from this network. Try again in 10 minutes." };
554 if (res?.status !== 201)
555 return { text: "Sidequest could not start a connection. Try again later." };
556
557 const { userCode, expiresAt } = JSON.parse(res.text) as { userCode: string; expiresAt: string };
558 const pending = { key, expiresAt: Date.parse(expiresAt), userCode };
559 const url = connectUrl(base, userCode);
560 await $.store.set("pending", pending);
561 await openBrowser($, url, os);
562 void pollConnection($, pending).catch(() => {});
563 // Code blocks, not links: Claude Desktop opens links in its own browser, where the user is signed out.
564 return {
565 text: [
566 "**Connect Sidequest**",
567 "",
568 "1. Copy this link into your browser:",
569 "```",
570 url,
571 "```",
572 "2. If the page asks for a code, enter:",
573 "```",
574 userCode,
575 "```",
576 "",
577 NEVER_ENTER,
578 ].join("\n"),
579 // The output is for the person. Without this, Claude brings the connection up in later answers.
580 context: ["The user ran /sidequest-connect. Its output is for them only. Do not mention it."],
581 };
582 });
583
584 on("turn.start", async ($, e, next) => {
585 if (e.agentId === undefined) {
586 if (e.text.trim()) question = e.text;
587 bandTurnId = e.turnId;
588 // The band and inline requests each use one of the site's daily ad requests.
589 void refreshBand($, e.turnId);
590 }
591 return next(e);
592 });
593
594 on("turn.complete", async ($, e, next) => {
595 const result = await next(e);
596 if (e.agentId !== undefined || e.reason !== "answer" || !e.answer.trim()) return result;
597 if (!(await readSettings($)).inline) return result;
598 const key = await storedDeviceKey($);
599 if (!key || !question.trim()) return result;
600
601 const served = await requestAd($, key, "inline", e.answer);
602 if (served) {
603 const slot: AdSlot = { answer: e.answer.trim(), ad: served };
604 await update($, slots, (list) => [...(list ?? []), slot].slice(-MAX_SLOTS));
605 }
606 return result;
607 });
608
609 // The ad sits under the last message of the reply it was served for.
610 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
611 if (e.surface === "terminal") fullscreen = e.viewport?.isFullscreen ?? fullscreen;
612 if (!(await readSettings($)).inline) return next(e);
613 const text = e.props.text.trim();
614 const list = await read($, slots);
615 const slot = text === "" ? undefined : list.find((s) => s.answer.endsWith(text));
616 if (slot === undefined || !isAd(slot.ad)) return next(e);
617
618 const { ad } = slot;
619 const { Box, Text, Link, Markdown, Button } = $.ui.resolve(e);
620
621 await reportOnScreen($, ad);
622
623 const icon = adIcon($, e, ad);
624 if (e.surface === "terminal" && ad.iconPng) {
625 imagesProbe ??= probeImages($, e.requestId, ad.iconPng).catch(() => {});
626 }
627
628 // Box has no top-only border. The terminal gets a rule sized to fit (a cut
629 // Text ends in an ellipsis); Desktop draws a markdown rule. The IDE panel
630 // frames the drawing itself, so it gets none.
631 const divider =
632 e.surface === "vscode" ? null : (
633 <Box flexDirection="column" width="100%" marginTop={1}>
634 {e.surface === "terminal" ? (
635 <Text dimColor>{"─".repeat(Math.max((e.viewport?.columns ?? 64) - 4, 8))}</Text>
636 ) : (
637 <Markdown text="---" />
638 )}
639 </Box>
640 );
641
642 // Only the fullscreen terminal reports clicks to Claude Code, so there a
643 // Button opens the ad; Terminal.app draws a Link as its text and a dim URL.
644 // The IDE panel draws a Button as its own button. Elsewhere the Link is the
645 // terminal's or the desktop's own.
646 const hover = brandHover(ad);
647 const label = `${ad.adCta} →`;
648 const cta =
649 e.surface === "vscode" ? (
650 <Box flexDirection="row">
651 <Button key="cta" onPress={() => openAd($, ad)}>
652 {label}
653 </Button>
654 </Box>
655 ) : (
656 <Box key="cta-box" alignSelf="flex-start" borderStyle="round" paddingX={1}>
657 {e.surface === "terminal" && e.viewport?.isFullscreen ? (
658 <Button key="cta" plain onPress={() => openAd($, ad)}>
659 <Text bold hover={hover}>
660 {label}
661 </Text>
662 </Button>
663 ) : (
664 <Text bold hover={hover}>
665 <Link href={ad.adLink}>{label}</Link>
666 </Text>
667 )}
668 </Box>
669 );
670
671 const reply = await next(e);
672
673 // The engine's own reply, a divider, then the ad: advertiser line, the ad
674 // text wrapped, and the CTA. The Box that holds the reply takes no width:
675 // Claude Desktop drops the whole tree when it has one.
676 return (
677 <Box flexDirection="column">
678 {reply}
679 {divider}
680 <Box flexDirection="column" width="100%" gap={1} marginTop={divider ? 1 : 0}>
681 <Box flexDirection="row" gap={1}>
682 {icon}
683 <Text bold>{ad.advertiser.name}</Text>
684 <Text dimColor>Sponsored</Text>
685 </Box>
686 <Text wrap="wrap">{ad.adText}</Text>
687 {cta}
688 </Box>
689 </Box>
690 );
691 });
692
693 on("ui.message", async ($, e, next) => {
694 if (e.component === "AbovePrompt" && e.module.endsWith("seen.tsx")) {
695 const seen = (e.data as { seen?: unknown } | null)?.seen;
696 const ad = await read($, band);
697 if (typeof seen === "string" && isAd(ad) && ad.impressionId === seen) {
698 await reportOnScreen($, ad);
699 }
700 }
701 return next(e);
702 });
703
704 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
705 bandColumns = e.props.bodyColumns;
706 if (e.surface === "terminal") fullscreen = e.viewport?.isFullscreen ?? fullscreen;
707 if (!BAND_ADS) return next(e);
708 if (e.surface !== "terminal" && e.surface !== "desktop") return next(e);
709 if (e.props.hasSurvey) return next(e);
710 const ad = await read($, band);
711 if (!isAd(ad)) return next(e);
712
713 const { Box, Text, Link, Button, Client } = $.ui.resolve(e);
714 // The hook also runs while the band is collapsed, so the impression comes from the Client's mount.
715 // Only the ad text shrinks, so a narrow terminal cuts it and never the call to action.
716 // Hovering the row lights it; the colors change, the size does not.
717 return (
718 <Box
719 key="band"
720 flexDirection="row"
721 gap={1}
722 width="100%"
723 hover={{ scope: BAND_HOVER_SCOPE, backgroundColor: "subtle" }}
724 >
725 {adIcon($, e, ad)}
726 <Box flexShrink={0}>
727 <Client key="seen" module="./seen.tsx" props={{ impressionId: ad.impressionId }} />
728 <Text bold>
729 <Link href={ad.adLink}>{ad.advertiser.name}</Link>
730 </Text>
731 </Box>
732 <Box flexShrink={0}>
733 <Text dimColor>Ad</Text>
734 </Box>
735 <Box flexGrow={1} flexShrink={1}>
736 <Text wrap="truncate-end">
737 <Link href={ad.adLink}>{ad.adText}</Link>
738 </Text>
739 </Box>
740 <Box flexShrink={0}>
741 <Button
742 key="open"
743 plain
744 hotkey="o"
745 hover={{ scope: BAND_HOVER_SCOPE, underline: true }}
746 onPress={() => openAd($, ad)}
747 >
748 {ad.adCta}
749 </Button>
750 </Box>
751 </Box>
752 );
753 });
754};
755hooks/features.ts 4 lines1// Band ads (the one-line ad above the prompt) are built and tested, but stay off until launch.
2// Setting this to true turns them on and brings back the band options of /sidequest.
3export const BAND_ADS = false;
4hooks/seen.tsx 13 lines1import type { ClientModule } from "claude-code";
2
3// Draws nothing. It runs only while the band tree is drawn, and posts once per ad.
4const Seen: ClientModule<{ impressionId: string }, string> = (props, surface) => {
5 if (surface.state !== props.impressionId) {
6 surface.setState(props.impressionId);
7 surface.post({ seen: props.impressionId });
8 }
9 return surface.elements.Text({ children: "" });
10};
11
12export default Seen;
13types/index.d.ts 22 lines1export type KoahAd = {
2 adText: string;
3 adCta: string;
4 adLink: string;
5 advertiser: { name: string; rootDomain: string; iconUrl: string | null };
6 /** UUID of the served ad; the mod builds the impression URL from it. */
7 impressionId: string;
8 /** The advertiser favicon as base64 PNG, fetched by the mod. */
9 iconPng?: string;
10 /** The favicon's average color as #rrggbb, for a badge where the terminal draws no image. */
11 iconColor?: string;
12};
13
14/** One ad per answered turn, matched to its last message by the answer text. */
15export type AdSlot = { answer: string; ad: KoahAd };
16
17declare module "claude-code" {
18 interface PluginState {
19 sidequest: { slots: AdSlot[]; band: KoahAd | null };
20 }
21}
22