@promptai.credit/cli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,17 +6,36 @@ Cursor Agents Window are supported today; Codex CLI is next.
6
6
 
7
7
  ## How it works
8
8
 
9
- 1. `promptai install claude` merges two hooks into `~/.claude/settings.json`:
9
+ 1. `promptai install claude` merges two hooks into `~/.claude/settings.json` and
10
+ installs the Claude Mods plugin into `~/.claude/skills/promptai/`:
10
11
  - **UserPromptSubmit**: baselines the session's transcript watermark and (if opted
11
12
  in, at most every 90s, never headless) opens the hosted rewarded-ad page
12
- (`/watch`) in your browser. The ad plays while your agent works.
13
+ (`/watch`) in your browser — **unless** the Mods plugin is live (heartbeat in
14
+ `~/.promptai/native-plugin.json`), in which case the native AbovePrompt card
15
+ shows the ad instead.
13
16
  - **Stop** (async): reads the session transcript JSONL since the watermark, prices
14
17
  the turn with the shared model price table, and redeems one banked
15
18
  server-verified ad session against it via the API.
16
- 2. The `/watch` page creates the ad session server-side on load, so SSV wall-clock
17
- timing is enforced by the server; the browser countdown is cosmetic.
19
+ - **Mods plugin** (`product/claude-plugin`): on `prompt.submit` fetches an ad
20
+ session and renders a compact sponsored card via `ui.render` / `AbovePrompt`.
21
+ 2. The `/watch` page (and the native card) create the ad session server-side, so SSV
22
+ wall-clock timing is enforced by the server; countdowns are cosmetic.
18
23
  3. `promptai claim` pays out your verified balance as USDC on Base.
19
24
 
25
+ ### Claude Mods (native card)
26
+
27
+ Native AbovePrompt ads need Claude Code function hooks (early access until GA).
28
+ `promptai install claude` sets `env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` in
29
+ `~/.claude/settings.json` (an explicit value you already set is left alone, and
30
+ `promptai uninstall claude` removes only what it added). This also enables any other
31
+ function-hook plugins you use. Where Mods are unavailable, command hooks open `/watch`
32
+ in the browser.
33
+ Dev-load the plugin without installing:
34
+
35
+ ```bash
36
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir product/claude-plugin
37
+ ```
38
+
20
39
  ## Cursor Agents Window (Glass)
21
40
 
22
41
  The Agents Window doesn't run VS Code extensions, so the promptai extension can't
@@ -45,17 +64,17 @@ State lives in `~/.promptai/` (`config.json`, `state.json`, `cli.log`).
45
64
 
46
65
  ```bash
47
66
  npm install -g @promptai.credit/cli
48
- promptai install claude # Claude Code hooks
67
+ promptai install claude # Claude Code hooks + Mods plugin
49
68
  promptai install cursor # Cursor Agents Window / classic IDE hooks
50
69
  ```
51
70
 
52
- `promptai install` then asks for your email and payout wallet, and docks ads on the **right** by default (like the IDE panel). Enter accepts a detected email.
71
+ `promptai install` then asks for your email and payout wallet. Ads dock on the **right** by default. Enter accepts a detected email.
53
72
 
54
73
  ```bash
55
74
  promptai status # device, balance, banked ad watches, recent prompts
56
75
  promptai watch # open a rewarded ad now (banks a credit for later)
57
76
  promptai claim # USDC on Base
58
- promptai set ads off # opt out any time
77
+ promptai off # toggle ads off (`promptai on` to turn them back on)
59
78
  promptai set side left # or `default` for a normal browser tab
60
79
  ```
61
80
 
@@ -77,7 +96,9 @@ Developing from the repo instead: `pnpm --filter @promptai.credit/cli build`, th
77
96
  as the default during `promptai install`. Auto-detected emails link without
78
97
  verification; typing a different address (or `promptai set email`) sends a
79
98
  6-digit code to prove ownership.
99
+ - Ads dock on the right by default. Toggle them anytime with `promptai on` /
100
+ `promptai off`.
80
101
  - Headless environments (CI, ssh without a display) never get browser tabs and
81
102
  simply skip crediting.
82
103
  - Uninstall with `promptai uninstall claude`; other hooks in your settings are
83
- preserved.
104
+ preserved, and the Mods plugin under `~/.claude/skills/promptai/` is removed.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "promptai",
3
+ "version": "0.1.0",
4
+ "description": "Opt-in rewarded ads above the Claude Code prompt. Watch a short sponsored card while the agent works; verified watches fund prompt credits via promptai.credit.",
5
+ "author": {
6
+ "name": "PromptAI",
7
+ "url": "https://promptai.credit"
8
+ },
9
+ "homepage": "https://promptai.credit",
10
+ "keywords": ["ads", "credits", "promptai", "rewarded"]
11
+ }
@@ -0,0 +1,33 @@
1
+ # PromptAI Claude Code plugin
2
+
3
+ Native rewarded-ad card for Claude Code via **Claude Mods** (function hooks).
4
+
5
+ While the agent works, a compact sponsored row renders above the prompt
6
+ (`AbovePrompt`). After the server-verified watch window, the credit is banked
7
+ the same way as the browser `/watch` path. Transcript pricing and redeem still
8
+ run through the `promptai` CLI `Stop` command hook.
9
+
10
+ ## Requirements
11
+
12
+ - Claude Code with function hooks enabled (early access). `promptai install claude`
13
+ sets `env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` in `~/.claude/settings.json`; to enable
14
+ it by hand, use `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude`.
15
+
16
+ - `promptai install claude` (wires command hooks, enables the flag, copies this plugin)
17
+
18
+ ## Dev load (without install)
19
+
20
+ ```bash
21
+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/product/claude-plugin
22
+ ```
23
+
24
+ ## Notes
25
+
26
+ - If Mods are off or the module fails to load, the CLI falls back to opening
27
+ `/watch` in the browser (fail open).
28
+ - The plugin writes `~/.promptai/native-plugin.json` on `session.start` so the
29
+ CLI can skip the browser tab while the native card is live.
30
+ - The brand logo (PNG, favicon or SVG) is drawn as half-block cells, so it works in any
31
+ terminal. It is fetched with `curl` and converted with `sips` (macOS) or ImageMagick;
32
+ SVGs need `qlmanage` (macOS) or `rsvg-convert`. If any step is missing the card simply
33
+ draws without a logo. Results are cached under `~/.promptai/logos/`.
@@ -0,0 +1,557 @@
1
+ /**
2
+ * PromptAI native rewarded-ad card for Claude Code (Mods / function hooks).
3
+ *
4
+ * Lifecycle:
5
+ * 1. session.start — load ~/.promptai config; heartbeat so CLI skips /watch
6
+ * 2. prompt.submit — fire-and-forget POST /ads/session (never await before next)
7
+ * 3. ui.render AbovePrompt — compact sponsored card + countdown
8
+ * 4. clock tick — when minWatchMs elapsed, POST /ads/complete
9
+ * 5. CTA press — beacon click, open destination in OS browser
10
+ *
11
+ * Stop / transcript pricing / credit redeem stay on the command-hook CLI path.
12
+ *
13
+ * Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 until Mods are generally available.
14
+ */
15
+ import type { EngineInterface as Engine, Register, Timer } from "claude-code";
16
+ import { WORK_PX, bitmapToCells, decodeBase64, hashKey, parseBmp, type LogoCells } from "./logo.ts";
17
+
18
+ const SOURCE = "claude-mods";
19
+ /** Logo cell grid: half-blocks, so 12 columns × 6 rows is a square of 12×12 pixels. */
20
+ const LOGO_COLS = 12;
21
+ const LOGO_ROWS = 6;
22
+ const HEARTBEAT_FILE = "native-plugin.json";
23
+ /** How long the CLI treats a heartbeat as "plugin is live" before falling back to /watch. */
24
+ const HEARTBEAT_FRESH_MS = 2 * 60 * 60 * 1000;
25
+
26
+ interface AdCreative {
27
+ id: string;
28
+ brand: string;
29
+ tag: string;
30
+ tagline: string;
31
+ lines: Array<{ cmd: boolean; text: string }>;
32
+ ctaLabel: string;
33
+ ctaUrl: string;
34
+ destinationUrl?: string;
35
+ logoUrl?: string;
36
+ minWatchMs: number;
37
+ }
38
+
39
+ interface PromptaiConfig {
40
+ deviceId: string;
41
+ serverUrl: string;
42
+ adsOptIn: boolean;
43
+ }
44
+
45
+ type AdPhase = "idle" | "loading" | "showing" | "verified" | "failed";
46
+
47
+ interface ActiveAd {
48
+ phase: AdPhase;
49
+ sessionId?: string;
50
+ ad?: AdCreative;
51
+ shownAt?: number;
52
+ error?: string;
53
+ tick?: Timer;
54
+ logo?: LogoCells;
55
+ }
56
+
57
+ const active: ActiveAd = { phase: "idle" };
58
+ let config: PromptaiConfig | null = null;
59
+ let homeDir = "";
60
+ let startingSession = false;
61
+
62
+ function configPath(): string {
63
+ return `${homeDir}/.promptai/config.json`;
64
+ }
65
+
66
+ function statePath(): string {
67
+ return `${homeDir}/.promptai/state.json`;
68
+ }
69
+
70
+ function heartbeatPath(): string {
71
+ return `${homeDir}/.promptai/${HEARTBEAT_FILE}`;
72
+ }
73
+
74
+ async function resolveHome($: Engine): Promise<string> {
75
+ try {
76
+ return ((await $.env.get("HOME")) ?? "").trim();
77
+ } catch {
78
+ return "";
79
+ }
80
+ }
81
+
82
+ async function readJson<T>($: Engine, path: string, fallback: T): Promise<T> {
83
+ try {
84
+ if (!(await $.fs.exists(path))) return fallback;
85
+ const raw = await $.fs.read(path);
86
+ return { ...fallback, ...(JSON.parse(raw) as T) };
87
+ } catch {
88
+ return fallback;
89
+ }
90
+ }
91
+
92
+ async function writeJson($: Engine, path: string, value: unknown): Promise<void> {
93
+ try {
94
+ await $.fs.write(path, `${JSON.stringify(value, null, 2)}\n`);
95
+ } catch {
96
+ // never break the session for local state
97
+ }
98
+ }
99
+
100
+ async function loadConfig($: Engine): Promise<PromptaiConfig | null> {
101
+ if (!homeDir) return null;
102
+ const cfg = await readJson<PromptaiConfig>($, configPath(), {
103
+ deviceId: "",
104
+ serverUrl: "https://api.promptai.credit",
105
+ adsOptIn: true,
106
+ });
107
+ if (!cfg.deviceId) return null;
108
+ if (!cfg.serverUrl) cfg.serverUrl = "https://api.promptai.credit";
109
+ return cfg;
110
+ }
111
+
112
+ async function bumpLastAdOpenedAt($: Engine, at: number): Promise<void> {
113
+ const state = await readJson<Record<string, unknown>>($, statePath(), {});
114
+ state.lastAdOpenedAt = at;
115
+ await writeJson($, statePath(), state);
116
+ }
117
+
118
+ async function writeHeartbeat($: Engine): Promise<void> {
119
+ await writeJson($, heartbeatPath(), {
120
+ active: true,
121
+ at: Date.now(),
122
+ source: SOURCE,
123
+ freshMs: HEARTBEAT_FRESH_MS,
124
+ });
125
+ }
126
+
127
+ // Same palette as the /watch page so the terminal card reads as one brand.
128
+ const C = {
129
+ fg: "#fafafa",
130
+ soft: "#b9b9b9",
131
+ dim: "#8f8f8f",
132
+ faint: "#525252",
133
+ green: "#4ade80",
134
+ red: "#f87171",
135
+ amber: "#fbbf24",
136
+ };
137
+
138
+ function progressBar(elapsed: number, total: number, width = 20): { filled: string; empty: string } {
139
+ const ratio = Math.min(1, Math.max(0, elapsed / Math.max(1, total)));
140
+ const n = Math.round(ratio * width);
141
+ return { filled: "█".repeat(n), empty: "░".repeat(width - n) };
142
+ }
143
+
144
+ function clearTick(): void {
145
+ const tick = active.tick;
146
+ active.tick = undefined;
147
+ try {
148
+ tick?.cancel();
149
+ } catch {
150
+ // ignore
151
+ }
152
+ }
153
+
154
+ function resetAd(phase: AdPhase = "idle"): void {
155
+ clearTick();
156
+ active.phase = phase;
157
+ active.sessionId = undefined;
158
+ active.ad = undefined;
159
+ active.shownAt = undefined;
160
+ active.error = undefined;
161
+ active.logo = undefined;
162
+ }
163
+
164
+ function parseBody<T>(text: string): T {
165
+ try {
166
+ return JSON.parse(text) as T;
167
+ } catch {
168
+ return {} as T;
169
+ }
170
+ }
171
+
172
+ /** True while /ads/complete is in flight, so a slow response is never asked twice. */
173
+ let completing = false;
174
+
175
+ async function completeAd($: Engine): Promise<void> {
176
+ if (completing || !config || !active.sessionId || active.phase !== "showing") return;
177
+ completing = true;
178
+ // Stop the countdown first: with the request in flight the phase is still
179
+ // "showing", and another tick would send a second complete ("already verified").
180
+ clearTick();
181
+ const sessionId = active.sessionId;
182
+ try {
183
+ const res = await $.http.fetch(`${config.serverUrl}/ads/complete`, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify({ sessionId, deviceId: config.deviceId }),
187
+ });
188
+ if (!res.ok) {
189
+ const body = parseBody<{ error?: string }>(res.text);
190
+ active.phase = "failed";
191
+ active.error = body.error ?? `complete ${res.status}`;
192
+ clearTick();
193
+ $.ui.invalidate("ui.render");
194
+ return;
195
+ }
196
+ active.phase = "verified";
197
+ clearTick();
198
+ $.ui.invalidate("ui.render");
199
+ // Auto-collapse the verified strip after a short beat.
200
+ $.clock.after(8_000, () => {
201
+ if (active.phase === "verified") {
202
+ resetAd("idle");
203
+ $.ui.invalidate("ui.render");
204
+ }
205
+ });
206
+ } catch (err) {
207
+ active.phase = "failed";
208
+ active.error = String(err);
209
+ clearTick();
210
+ $.ui.invalidate("ui.render");
211
+ } finally {
212
+ completing = false;
213
+ }
214
+ }
215
+
216
+ function startCountdown($: Engine): void {
217
+ clearTick();
218
+ active.tick = $.clock.every(1_000, () => {
219
+ if (active.phase !== "showing" || !active.ad || active.shownAt == null) return;
220
+ const elapsed = Date.now() - active.shownAt;
221
+ if (elapsed >= active.ad.minWatchMs) {
222
+ void completeAd($);
223
+ return;
224
+ }
225
+ $.ui.invalidate("ui.render");
226
+ });
227
+ }
228
+
229
+ async function openDestination($: Engine, url: string): Promise<void> {
230
+ const candidates: string[][] = [
231
+ ["xdg-open", url],
232
+ ["open", url],
233
+ ["cmd", "/c", "start", "", url],
234
+ ];
235
+ for (const argv of candidates) {
236
+ try {
237
+ const result = await $.process.run(argv, { timeoutMs: 5_000 });
238
+ if (result.exitCode === 0) return;
239
+ } catch {
240
+ // try next opener
241
+ }
242
+ }
243
+ $.ui.toast(`Open in browser: ${url}`);
244
+ }
245
+
246
+ async function onCtaPress($: Engine): Promise<void> {
247
+ if (!config || !active.sessionId || !active.ad) return;
248
+ const ad = active.ad;
249
+ const clickUrl = ad.ctaUrl.includes("/ads/click/")
250
+ ? ad.ctaUrl
251
+ : `${config.serverUrl}/ads/click/${active.sessionId}`;
252
+ try {
253
+ void $.http.fetch(clickUrl, { method: "GET" });
254
+ } catch {
255
+ // click is best-effort
256
+ }
257
+ const target =
258
+ ad.destinationUrl ||
259
+ (ad.ctaUrl.includes("/ads/click/") ? undefined : ad.ctaUrl);
260
+ if (target) await openDestination($, target);
261
+ }
262
+
263
+ // --- logo host pipeline (curl → svg→png → bmp; pure image code is in logo.ts) ---
264
+
265
+ async function tryRun($: Engine, argv: string[], timeoutMs = 8_000): Promise<{ ok: boolean; stdout: string }> {
266
+ try {
267
+ const res = await $.process.run(argv, { timeoutMs });
268
+ return { ok: res.exitCode === 0, stdout: res.stdout };
269
+ } catch {
270
+ return { ok: false, stdout: "" };
271
+ }
272
+ }
273
+
274
+ async function exists($: Engine, path: string): Promise<boolean> {
275
+ try {
276
+ return await $.fs.exists(path);
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ async function svgToPng($: Engine, svg: string, dir: string, png: string): Promise<boolean> {
283
+ // macOS Quick Look thumbnailer: writes <dir>/<file>.png
284
+ const ql = await tryRun($, ["qlmanage", "-t", "-s", String(WORK_PX * 2), "-o", dir, svg]);
285
+ if (ql.ok && (await exists($, `${svg}.png`))) {
286
+ await tryRun($, ["mv", "-f", `${svg}.png`, png]);
287
+ if (await exists($, png)) return true;
288
+ }
289
+ const rsvg = await tryRun($, ["rsvg-convert", "-w", String(WORK_PX), "-h", String(WORK_PX), "-f", "png", "-o", png, svg]);
290
+ return rsvg.ok && (await exists($, png));
291
+ }
292
+
293
+ async function toBmp($: Engine, input: string, bmp: string): Promise<boolean> {
294
+ const attempts: string[][] = [
295
+ ["sips", "-s", "format", "bmp", "-Z", String(WORK_PX), input, "--out", bmp],
296
+ ["magick", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
297
+ ["convert", input, "-background", "none", "-resize", `${WORK_PX}x${WORK_PX}`, `BMP3:${bmp}`],
298
+ ];
299
+ for (const argv of attempts) {
300
+ const res = await tryRun($, argv);
301
+ if (res.ok && (await exists($, bmp))) return true;
302
+ }
303
+ return false;
304
+ }
305
+
306
+ const failedLogos = new Set<string>();
307
+
308
+ /**
309
+ * Load `url` as terminal cells, caching the result under
310
+ * `<home>/.promptai/logos/`. Resolves null when anything is unavailable.
311
+ */
312
+ async function loadLogoCells(
313
+ $: Engine,
314
+ opts: { url: string; home: string; columns: number; rows: number },
315
+ ): Promise<LogoCells | null> {
316
+ const { url, home, columns, rows } = opts;
317
+ if (!home || !/^https?:\/\//i.test(url)) return null;
318
+
319
+ const key = `${hashKey(url)}-${columns}x${rows}`;
320
+ if (failedLogos.has(key)) return null;
321
+
322
+ const dir = `${home}/.promptai/logos`;
323
+ const cacheFile = `${dir}/${key}.cells.json`;
324
+ try {
325
+ if (await exists($, cacheFile)) {
326
+ const cached = JSON.parse(await $.fs.read(cacheFile)) as LogoCells;
327
+ if (cached.cells && cached.columns === columns && cached.rows === rows) return cached;
328
+ }
329
+ } catch {
330
+ // rebuild below
331
+ }
332
+
333
+ const src = `${dir}/${key}.src`;
334
+ const png = `${dir}/${key}.png`;
335
+ const bmp = `${dir}/${key}.bmp`;
336
+ try {
337
+ const dl = await tryRun($, [
338
+ "curl", "-sSL", "--max-time", "5", "--max-filesize", "2000000", "--create-dirs",
339
+ "-o", src, "-w", "%{content_type}", url,
340
+ ]);
341
+ if (!dl.ok || !(await exists($, src))) throw new Error("download");
342
+
343
+ const isSvg = /svg/i.test(dl.stdout) || /\.svg(\?|$)/i.test(url);
344
+ let input = src;
345
+ if (isSvg) {
346
+ const svgFile = `${src}.svg`;
347
+ await tryRun($, ["mv", "-f", src, svgFile]);
348
+ if (!(await svgToPng($, svgFile, dir, png))) throw new Error("svg");
349
+ input = png;
350
+ }
351
+ if (!(await toBmp($, input, bmp))) throw new Error("bmp");
352
+
353
+ const { base64 } = await $.fs.read(bmp, { as: "bytes" });
354
+ const bitmap = parseBmp(decodeBase64(base64));
355
+ if (!bitmap) throw new Error("parse");
356
+
357
+ const logo = bitmapToCells(bitmap, columns, rows);
358
+ await $.fs.write(cacheFile, JSON.stringify(logo));
359
+ return logo;
360
+ } catch {
361
+ failedLogos.add(key);
362
+ return null;
363
+ } finally {
364
+ await tryRun($, ["rm", "-f", src, `${src}.svg`, png, bmp]);
365
+ }
366
+ }
367
+
368
+ /** Fire-and-forget: the card draws at once, the logo joins it when ready. */
369
+ async function loadLogo($: Engine, ad: AdCreative): Promise<void> {
370
+ if (!ad.logoUrl) return;
371
+ const logo = await loadLogoCells($, { url: ad.logoUrl, home: homeDir, columns: LOGO_COLS, rows: LOGO_ROWS });
372
+ if (logo && active.ad?.id === ad.id) {
373
+ active.logo = logo;
374
+ $.ui.invalidate("ui.render");
375
+ }
376
+ }
377
+
378
+ async function beginAdSession($: Engine): Promise<void> {
379
+ if (!config || !config.adsOptIn || startingSession) return;
380
+ if (active.phase === "loading" || active.phase === "showing") return;
381
+
382
+ startingSession = true;
383
+ active.phase = "loading";
384
+ $.ui.invalidate("ui.render");
385
+
386
+ try {
387
+ const res = await $.http.fetch(`${config.serverUrl}/ads/session`, {
388
+ method: "POST",
389
+ headers: { "Content-Type": "application/json" },
390
+ body: JSON.stringify({ deviceId: config.deviceId, source: SOURCE }),
391
+ });
392
+ const body = parseBody<{
393
+ sessionId?: string;
394
+ ad?: AdCreative;
395
+ error?: string;
396
+ }>(res.text);
397
+ if (!res.ok || !body.sessionId || !body.ad) {
398
+ active.phase = "failed";
399
+ active.error = body.error ?? `session ${res.status}`;
400
+ $.ui.invalidate("ui.render");
401
+ $.clock.after(5_000, () => {
402
+ if (active.phase === "failed") {
403
+ resetAd("idle");
404
+ $.ui.invalidate("ui.render");
405
+ }
406
+ });
407
+ return;
408
+ }
409
+
410
+ const now = Date.now();
411
+ active.phase = "showing";
412
+ active.sessionId = body.sessionId;
413
+ active.ad = body.ad;
414
+ active.shownAt = now;
415
+ active.error = undefined;
416
+ await bumpLastAdOpenedAt($, now);
417
+ startCountdown($);
418
+ $.ui.invalidate("ui.render");
419
+ void loadLogo($, body.ad);
420
+ } catch (err) {
421
+ active.phase = "failed";
422
+ active.error = String(err);
423
+ $.ui.invalidate("ui.render");
424
+ $.clock.after(5_000, () => {
425
+ if (active.phase === "failed") {
426
+ resetAd("idle");
427
+ $.ui.invalidate("ui.render");
428
+ }
429
+ });
430
+ } finally {
431
+ startingSession = false;
432
+ }
433
+ }
434
+
435
+ function remainingSeconds($: Engine): number {
436
+ if (!active.ad || active.shownAt == null) return 0;
437
+ const left = active.ad.minWatchMs - (Date.now() - active.shownAt);
438
+ return Math.max(0, Math.ceil(left / 1000));
439
+ }
440
+
441
+ export const register: Register = (on) => {
442
+ on("session.start", async ($, e, next) => {
443
+ homeDir = await resolveHome($);
444
+ config = await loadConfig($);
445
+ if (config?.adsOptIn) {
446
+ await writeHeartbeat($);
447
+ }
448
+ return next(e);
449
+ });
450
+
451
+ // Fire-and-forget: never await the session fetch before next(e).
452
+ on("prompt.submit", ($, e, next) => {
453
+ if (config?.adsOptIn) {
454
+ void writeHeartbeat($);
455
+ void beginAdSession($);
456
+ }
457
+ return next(e);
458
+ });
459
+
460
+ on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
461
+ if (!config?.adsOptIn || active.phase === "idle") {
462
+ return next(e);
463
+ }
464
+
465
+ const t = await $.ui.resolve(e);
466
+
467
+ if (active.phase === "loading") {
468
+ return (
469
+ <t.Box borderStyle="round" borderColor={C.faint} paddingX={1} gap={1}>
470
+ <t.Text color={C.amber}>●</t.Text>
471
+ <t.Text color={C.dim}>promptai · loading sponsored card…</t.Text>
472
+ </t.Box>
473
+ );
474
+ }
475
+
476
+ if (active.phase === "failed") {
477
+ return (
478
+ <t.Box borderStyle="round" borderColor={C.red} paddingX={1} gap={1}>
479
+ <t.Text color={C.red}>✕</t.Text>
480
+ <t.Text color={C.dim}>{`promptai · ad unavailable${active.error ? ` (${active.error})` : ""}`}</t.Text>
481
+ </t.Box>
482
+ );
483
+ }
484
+
485
+ if (active.phase === "verified") {
486
+ return (
487
+ <t.Box borderStyle="round" borderColor={C.green} paddingX={1} gap={1}>
488
+ <t.Text color={C.green} bold>✓ verified</t.Text>
489
+ <t.Text color={C.soft}>credit banked</t.Text>
490
+ <t.Text color={C.faint}>·</t.Text>
491
+ <t.Button
492
+ key="hide"
493
+ label="Hide"
494
+ plain
495
+ onPress={() => {
496
+ resetAd("idle");
497
+ $.ui.invalidate("ui.render");
498
+ }}
499
+ />
500
+ </t.Box>
501
+ );
502
+ }
503
+
504
+ if (active.phase !== "showing" || !active.ad || active.shownAt == null) {
505
+ return next(e);
506
+ }
507
+
508
+ const ad = active.ad;
509
+ const elapsed = Date.now() - active.shownAt;
510
+ const bar = progressBar(elapsed, ad.minWatchMs);
511
+ const secs = remainingSeconds($);
512
+ const shownLines = (ad.lines ?? []).slice(0, 4);
513
+
514
+ const Raster = "Raster" in t ? t.Raster : null;
515
+ const logo = active.logo;
516
+
517
+ const details = (
518
+ <t.Box flexDirection="column" flexGrow={1}>
519
+ <t.Box gap={1}>
520
+ <t.Text color={C.green}>◆</t.Text>
521
+ <t.Text color={C.fg} bold>{ad.brand}</t.Text>
522
+ {ad.tag ? <t.Text color={C.dim}>{`· ${ad.tag}`}</t.Text> : null}
523
+ <t.Text color={C.faint}>· sponsored</t.Text>
524
+ </t.Box>
525
+ <t.Text color={C.soft} wrap="truncate-end">{ad.tagline}</t.Text>
526
+ {shownLines.map((line, i) => (
527
+ <t.Box key={`line-${i}`} gap={1}>
528
+ {line.cmd ? <t.Text color={C.green}>$</t.Text> : <t.Text color={C.faint}>│</t.Text>}
529
+ <t.Text color={line.cmd ? C.fg : C.dim} wrap="truncate-end">{line.text}</t.Text>
530
+ </t.Box>
531
+ ))}
532
+ </t.Box>
533
+ );
534
+
535
+ return (
536
+ <t.Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1}>
537
+ {Raster && logo ? (
538
+ <t.Box gap={2}>
539
+ <Raster key="logo" columns={logo.columns} rows={logo.rows} cells={logo.cells} />
540
+ {details}
541
+ </t.Box>
542
+ ) : (
543
+ details
544
+ )}
545
+ <t.Box gap={1}>
546
+ <t.Box>
547
+ <t.Text color={C.green}>{bar.filled}</t.Text>
548
+ <t.Text color={C.faint}>{bar.empty}</t.Text>
549
+ </t.Box>
550
+ <t.Text color={C.fg} bold>{`${secs}s`}</t.Text>
551
+ <t.Text color={C.faint}>opt-in rewarded ad</t.Text>
552
+ </t.Box>
553
+ <t.Button key="cta" label={`${ad.ctaLabel || "Learn more"} →`} onPress={() => void onCtaPress($)} />
554
+ </t.Box>
555
+ );
556
+ });
557
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "description": "PromptAI native rewarded-ad card (Claude Mods function hooks).",
3
+ "modules": ["./ads.tsx"]
4
+ }