@trillboards/cli 0.1.0 → 0.1.1

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.
Files changed (59) hide show
  1. package/README.md +414 -43
  2. package/dist/ads-client.d.ts +146 -0
  3. package/dist/ads-client.js +310 -0
  4. package/dist/agent-hook.d.ts +64 -0
  5. package/dist/agent-hook.js +96 -0
  6. package/dist/capability.d.ts +31 -0
  7. package/dist/capability.js +67 -0
  8. package/dist/claude-settings.d.ts +64 -0
  9. package/dist/claude-settings.js +126 -0
  10. package/dist/commands/ads/disable.d.ts +10 -0
  11. package/dist/commands/ads/disable.js +25 -0
  12. package/dist/commands/ads/enable.d.ts +11 -0
  13. package/dist/commands/ads/enable.js +32 -0
  14. package/dist/commands/ads/hook.d.ts +16 -0
  15. package/dist/commands/ads/hook.js +90 -0
  16. package/dist/commands/ads/login.d.ts +21 -0
  17. package/dist/commands/ads/login.js +108 -0
  18. package/dist/commands/ads/start.d.ts +20 -0
  19. package/dist/commands/ads/start.js +175 -0
  20. package/dist/commands/code.d.ts +12 -0
  21. package/dist/commands/code.js +84 -0
  22. package/dist/commands/devices/reconcile.d.ts +10 -0
  23. package/dist/commands/devices/reconcile.js +94 -0
  24. package/dist/commands/generated/generated-device-heartbeat-get.d.ts +14 -0
  25. package/dist/commands/generated/generated-device-heartbeat-get.js +32 -0
  26. package/dist/commands/generated/generated-federated-get-latest-model-descriptor.d.ts +19 -0
  27. package/dist/commands/generated/generated-federated-get-latest-model-descriptor.js +47 -0
  28. package/dist/commands/generated/generated-federated-get-model-weights.d.ts +19 -0
  29. package/dist/commands/generated/generated-federated-get-model-weights.js +47 -0
  30. package/dist/commands/generated/generated-federated-gradient-upload.d.ts +13 -0
  31. package/dist/commands/generated/generated-federated-gradient-upload.js +32 -0
  32. package/dist/commands/generated/generated-federated-performance-upload.d.ts +13 -0
  33. package/dist/commands/generated/generated-federated-performance-upload.js +32 -0
  34. package/dist/commands/generated/generated-federated-taste-upload.d.ts +13 -0
  35. package/dist/commands/generated/generated-federated-taste-upload.js +32 -0
  36. package/dist/commands/generated/generated-post-device-heartbeat.d.ts +15 -0
  37. package/dist/commands/generated/generated-post-device-heartbeat.js +35 -0
  38. package/dist/commands/listen.js +9 -1
  39. package/dist/commands/logs/tail.js +10 -1
  40. package/dist/config.d.ts +21 -0
  41. package/dist/config.js +34 -9
  42. package/dist/dead-time.d.ts +51 -0
  43. package/dist/dead-time.js +119 -0
  44. package/dist/format-helpers.d.ts +6 -0
  45. package/dist/format-helpers.js +10 -0
  46. package/dist/http.d.ts +8 -1
  47. package/dist/http.js +6 -1
  48. package/dist/machine-id.d.ts +12 -0
  49. package/dist/machine-id.js +22 -0
  50. package/dist/pty-wrapper.d.ts +46 -0
  51. package/dist/pty-wrapper.js +245 -0
  52. package/dist/render-runner.d.ts +48 -0
  53. package/dist/render-runner.js +151 -0
  54. package/dist/render.d.ts +82 -0
  55. package/dist/render.js +124 -0
  56. package/dist/user-auth.d.ts +57 -0
  57. package/dist/user-auth.js +110 -0
  58. package/oclif.manifest.json +1140 -255
  59. package/package.json +9 -2
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Virtual-screen ad client for the agent-ads surface.
3
+ *
4
+ * A "virtual screen" is a developer-owned screen (no partner_id) that rents an
5
+ * AI-coding-agent's dead-time. The backend serves it through the EARNER + OpenRTB
6
+ * routes — NOT the partner-device routes the @trillboards/ads-sdk ApiClient is
7
+ * hardwired to. So this module owns the earner/openrtb calls and reuses the SDK
8
+ * only where the SDK's method targets a path that works for a partner-less screen
9
+ * (reportImpression → /v1/partner/impression records cleanly with partner_id=null).
10
+ *
11
+ * Endpoints (all grounded in trillboard-api):
12
+ * - register POST /v2/earner/virtual-screen/register (Bearer user JWT)
13
+ * - heartbeat POST /v2/earner/heartbeat/:screenId (public)
14
+ * - ads GET /v2/earner/alot-advertisement-list/:screen_id (public)
15
+ * - player GET /openrtb/v1/ima-player/:screenId?tag&did (billable)
16
+ * - event POST /openrtb/v1/event?adid&event&screenId&deviceId (public)
17
+ */
18
+ export interface RegisterResult {
19
+ screenId: string;
20
+ deviceId: string;
21
+ isReRegistration: boolean;
22
+ }
23
+ /**
24
+ * Register (or idempotently re-register) this install as a virtual screen.
25
+ * Requires the user JWT. Body: {machineId, name?}. The server keys idempotency on
26
+ * md5(userMongoId:machineId), so the same machineId+user returns the same screen.
27
+ */
28
+ export declare function registerVirtualScreen(baseUrl: string, token: string, machineId: string, name: string): Promise<RegisterResult>;
29
+ /**
30
+ * Keep the virtual screen "online" so GIVT screen-liveness (≤90s) lets the
31
+ * agent_direct completion bill. Uses the generic OpenRTB heartbeat — the earner
32
+ * heartbeat (/v2/earner/heartbeat/:id) 404s for virtual screens because the
33
+ * heartbeatMonitor only tracks physical/registered screens, whereas
34
+ * /openrtb/v1/heartbeat records liveness for ANY screenId. Public endpoint;
35
+ * best-effort (never throws), so a transient blip doesn't kill the loop.
36
+ */
37
+ export declare function sendVirtualScreenHeartbeat(baseUrl: string, screenId: string, deviceId?: string | null): Promise<boolean>;
38
+ export interface VirtualScreenAd {
39
+ /** Winning/primary web VAST tag URL (data.header_bidding_settings.vast_tag_url). */
40
+ vastTagUrl: string | null;
41
+ /** Partner alias for the winning source (variant_name), for telemetry. */
42
+ variantName: string | null;
43
+ /** All waterfall fallback tag URLs in priority order (sources[].vast_url). */
44
+ sources: string[];
45
+ }
46
+ /**
47
+ * GET /v2/earner/alot-advertisement-list/:screen_id and extract the web VAST tag.
48
+ *
49
+ * Response envelope: { status, code, message, data }. The programmatic block lives
50
+ * at `data.header_bidding_settings` with `vast_tag_url` (the winning tag) and
51
+ * `vast_waterfall.sources[].vast_url` (fallbacks). Returns nulls when no fill.
52
+ */
53
+ export declare function fetchVirtualScreenAds(baseUrl: string, screenId: string): Promise<VirtualScreenAd>;
54
+ export interface VirtualScreenTextAd {
55
+ /** Advertisement mongo id (overlay_advertisements[0].content.id), for telemetry. */
56
+ adId: string | null;
57
+ /** Advertiser headline (content.name) — the sponsor line's lede. */
58
+ name: string | null;
59
+ /** Optional supporting copy (content.description). */
60
+ description: string | null;
61
+ /** Click destination (content.campaign_link) — the CTA URL. */
62
+ campaignLink: string | null;
63
+ /** Winning programmatic source alias, for event telemetry. */
64
+ variantName: string | null;
65
+ }
66
+ /**
67
+ * GET /v2/earner/alot-advertisement-list/:screen_id and extract the TEXT creative
68
+ * from the first overlay advertisement. This is the no-browser, in-place surface:
69
+ * the agent-ads house/paid ad always carries name + campaign_link, so a sponsor
70
+ * LINE can render right in the agent's dead-time with zero video player.
71
+ *
72
+ * Returns null on no-fill (no overlay ad, or no usable headline). Tight default
73
+ * timeout — the Claude Code hook must stay fast and never block the prompt.
74
+ */
75
+ export declare function fetchVirtualScreenTextAd(baseUrl: string, screenId: string, timeoutMs?: number): Promise<VirtualScreenTextAd | null>;
76
+ export interface VirtualScreenCreative {
77
+ /** Chosen format. Server stamps `content.mediaType` (selectCreativeFormat); we fall back to inference. */
78
+ mediaType: 'text' | 'image' | 'video';
79
+ adId: string | null;
80
+ name: string | null;
81
+ description: string | null;
82
+ campaignLink: string | null;
83
+ variantName: string | null;
84
+ /** Video or image URL (null for text-only). */
85
+ mediaUrl: string | null;
86
+ durationSeconds: number | null;
87
+ /** AdX/programmatic VAST tag (Leg A) when present — opened in a webview/browser. */
88
+ vastTagUrl: string | null;
89
+ /**
90
+ * Hosted IMA-player URL for the programmatic VAST tag (Leg A), pre-built when the tag
91
+ * is playable (mirrors how the screen app + ads-sdk ProgrammaticPlayer feed the tag to
92
+ * the Google IMA SDK). The wrapper opens this during dead-time; the wrap's signed pixels
93
+ * bill via `vast_event`. null when there's no playable programmatic fill.
94
+ */
95
+ imaPlayerUrl: string | null;
96
+ /**
97
+ * Server-signed agent_direct completion beacon (relative URL). Fire this VERBATIM
98
+ * (resolved against baseUrl) on play completion — the server minted the `&sig=`,
99
+ * so it passes VAST_ATTRIBUTION_REQUIRE_SIG where a client-built complete cannot.
100
+ * null when the server didn't attach one (e.g. an old server, or no-fill).
101
+ */
102
+ completeUrl: string | null;
103
+ }
104
+ /**
105
+ * GET the manifest declaring `?surface_capabilities=…` and return the chosen creative
106
+ * for whatever the host terminal can render. Forward-compatible: an old server ignores
107
+ * the param and returns its video/image creative; the new server's `selectCreativeFormat`
108
+ * honors it and may return text. Returns null on no-fill.
109
+ */
110
+ export declare function fetchVirtualScreenCreative(baseUrl: string, screenId: string, surfaceCapabilities: string, deviceId?: string | null, timeoutMs?: number): Promise<VirtualScreenCreative | null>;
111
+ /**
112
+ * Fire the SERVER-SIGNED agent_direct completion beacon on play completion. The
113
+ * URL (with its `&sig=`) was minted by the serve path, so this passes
114
+ * VAST_ATTRIBUTION_REQUIRE_SIG — the wrapper never builds or signs the complete
115
+ * itself. Best-effort, fire-and-forget: never throws, returns true on a 2xx.
116
+ */
117
+ export declare function reportVirtualScreenCompletion(baseUrl: string, completeUrl: string | null): Promise<boolean>;
118
+ /**
119
+ * Build the URL-loadable, billable IMA player link. Opening this in a browser
120
+ * loads the hosted ima-player.js which fires the VAST impression/quartile/complete
121
+ * pixels (that is the money path — the TTY can't render video).
122
+ *
123
+ * The server-side `isAllowedTagUrl` SSRF allowlist gates the `tag` param to known
124
+ * ad-server hosts (GAM/doubleclick/adipolo/adtelligent/...). A non-allowlisted tag
125
+ * yields a 400 page, so we surface a clear hint when the tag won't be playable.
126
+ */
127
+ export declare function buildImaPlayerUrl(baseUrl: string, screenId: string, vastTagUrl: string, deviceId: string | null, variantName?: string | null): string;
128
+ /** Mirror of the server's allowlist so we can warn before printing an unplayable link. */
129
+ export declare function isPlayableTagUrl(vastTagUrl: string): boolean;
130
+ /**
131
+ * Report a text-ad / programmatic lifecycle event for the virtual screen via the
132
+ * public OpenRTB event endpoint (the partner programmatic-event route 403s for a
133
+ * partner-less screen). Best-effort, fire-and-forget; never throws.
134
+ *
135
+ * `adid` is required by the endpoint; for synthetic lifecycle events (request,
136
+ * no_fill) we pass a deterministic synthetic id.
137
+ */
138
+ export declare function reportTextAdEvent(baseUrl: string, params: {
139
+ screenId: string;
140
+ deviceId: string | null;
141
+ event: string;
142
+ adid: string;
143
+ variantName?: string | null;
144
+ }, timeoutMs?: number): Promise<boolean>;
145
+ /** Generate a synthetic per-impression id for text-ad telemetry. */
146
+ export declare function newImpressionId(): string;
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Virtual-screen ad client for the agent-ads surface.
3
+ *
4
+ * A "virtual screen" is a developer-owned screen (no partner_id) that rents an
5
+ * AI-coding-agent's dead-time. The backend serves it through the EARNER + OpenRTB
6
+ * routes — NOT the partner-device routes the @trillboards/ads-sdk ApiClient is
7
+ * hardwired to. So this module owns the earner/openrtb calls and reuses the SDK
8
+ * only where the SDK's method targets a path that works for a partner-less screen
9
+ * (reportImpression → /v1/partner/impression records cleanly with partner_id=null).
10
+ *
11
+ * Endpoints (all grounded in trillboard-api):
12
+ * - register POST /v2/earner/virtual-screen/register (Bearer user JWT)
13
+ * - heartbeat POST /v2/earner/heartbeat/:screenId (public)
14
+ * - ads GET /v2/earner/alot-advertisement-list/:screen_id (public)
15
+ * - player GET /openrtb/v1/ima-player/:screenId?tag&did (billable)
16
+ * - event POST /openrtb/v1/event?adid&event&screenId&deviceId (public)
17
+ */
18
+ import crypto from 'node:crypto';
19
+ import { apiRequest } from './http.js';
20
+ /**
21
+ * Register (or idempotently re-register) this install as a virtual screen.
22
+ * Requires the user JWT. Body: {machineId, name?}. The server keys idempotency on
23
+ * md5(userMongoId:machineId), so the same machineId+user returns the same screen.
24
+ */
25
+ export async function registerVirtualScreen(baseUrl, token, machineId, name) {
26
+ const res = (await apiRequest('POST', '/v2/earner/virtual-screen/register', {
27
+ baseUrl,
28
+ token,
29
+ body: { machineId, name },
30
+ }));
31
+ const screenId = typeof res.screenId === 'string' ? res.screenId : '';
32
+ const deviceId = typeof res.deviceId === 'string' ? res.deviceId : '';
33
+ if (!screenId || !deviceId) {
34
+ throw new Error('Register response missing screenId/deviceId');
35
+ }
36
+ return { screenId, deviceId, isReRegistration: res.isReRegistration === true };
37
+ }
38
+ // ── Heartbeat ────────────────────────────────────────────────
39
+ /**
40
+ * Keep the virtual screen "online" so GIVT screen-liveness (≤90s) lets the
41
+ * agent_direct completion bill. Uses the generic OpenRTB heartbeat — the earner
42
+ * heartbeat (/v2/earner/heartbeat/:id) 404s for virtual screens because the
43
+ * heartbeatMonitor only tracks physical/registered screens, whereas
44
+ * /openrtb/v1/heartbeat records liveness for ANY screenId. Public endpoint;
45
+ * best-effort (never throws), so a transient blip doesn't kill the loop.
46
+ */
47
+ export async function sendVirtualScreenHeartbeat(baseUrl, screenId, deviceId = null) {
48
+ try {
49
+ const res = await fetch(new URL('/openrtb/v1/heartbeat', baseUrl).toString(), {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ screenId, deviceId: deviceId ?? undefined }),
53
+ signal: AbortSignal.timeout(5000),
54
+ });
55
+ return res.ok;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ /**
62
+ * GET /v2/earner/alot-advertisement-list/:screen_id and extract the web VAST tag.
63
+ *
64
+ * Response envelope: { status, code, message, data }. The programmatic block lives
65
+ * at `data.header_bidding_settings` with `vast_tag_url` (the winning tag) and
66
+ * `vast_waterfall.sources[].vast_url` (fallbacks). Returns nulls when no fill.
67
+ */
68
+ export async function fetchVirtualScreenAds(baseUrl, screenId) {
69
+ const res = await fetch(new URL(`/v2/earner/alot-advertisement-list/${encodeURIComponent(screenId)}`, baseUrl).toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) });
70
+ if (!res.ok) {
71
+ throw new Error(`Ad fetch returned ${res.status}`);
72
+ }
73
+ const json = (await res.json());
74
+ const hbs = (json.data?.header_bidding_settings ?? null);
75
+ const vastTagUrl = hbs && typeof hbs.vast_tag_url === 'string' ? hbs.vast_tag_url : null;
76
+ const variantName = hbs && typeof hbs.variant_name === 'string' ? hbs.variant_name : null;
77
+ const sources = [];
78
+ const waterfall = hbs?.vast_waterfall;
79
+ if (waterfall && Array.isArray(waterfall.sources)) {
80
+ for (const s of waterfall.sources) {
81
+ if (s && typeof s.vast_url === 'string')
82
+ sources.push(s.vast_url);
83
+ }
84
+ }
85
+ return { vastTagUrl, variantName, sources };
86
+ }
87
+ /**
88
+ * GET /v2/earner/alot-advertisement-list/:screen_id and extract the TEXT creative
89
+ * from the first overlay advertisement. This is the no-browser, in-place surface:
90
+ * the agent-ads house/paid ad always carries name + campaign_link, so a sponsor
91
+ * LINE can render right in the agent's dead-time with zero video player.
92
+ *
93
+ * Returns null on no-fill (no overlay ad, or no usable headline). Tight default
94
+ * timeout — the Claude Code hook must stay fast and never block the prompt.
95
+ */
96
+ export async function fetchVirtualScreenTextAd(baseUrl, screenId, timeoutMs = 2500) {
97
+ const res = await fetch(new URL(`/v2/earner/alot-advertisement-list/${encodeURIComponent(screenId)}`, baseUrl).toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
98
+ if (!res.ok)
99
+ throw new Error(`Ad fetch returned ${res.status}`);
100
+ const json = (await res.json());
101
+ const overlays = json.data?.overlay_advertisements;
102
+ if (!Array.isArray(overlays) || overlays.length === 0)
103
+ return null;
104
+ const content = overlays[0]?.content;
105
+ if (!content || typeof content !== 'object')
106
+ return null;
107
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
108
+ const hbs = (json.data?.header_bidding_settings ?? null);
109
+ const name = str(content.name);
110
+ // No usable headline ⇒ nothing to render as a sponsor line.
111
+ if (!name)
112
+ return null;
113
+ return {
114
+ adId: str(content.id),
115
+ name,
116
+ description: str(content.description),
117
+ campaignLink: str(content.campaign_link),
118
+ variantName: hbs && typeof hbs.variant_name === 'string' ? hbs.variant_name : null,
119
+ };
120
+ }
121
+ /**
122
+ * GET the manifest declaring `?surface_capabilities=…` and return the chosen creative
123
+ * for whatever the host terminal can render. Forward-compatible: an old server ignores
124
+ * the param and returns its video/image creative; the new server's `selectCreativeFormat`
125
+ * honors it and may return text. Returns null on no-fill.
126
+ */
127
+ export async function fetchVirtualScreenCreative(baseUrl, screenId, surfaceCapabilities, deviceId = null, timeoutMs = 4000) {
128
+ const url = new URL(`/v2/earner/alot-advertisement-list/${encodeURIComponent(screenId)}`, baseUrl);
129
+ if (surfaceCapabilities)
130
+ url.searchParams.set('surface_capabilities', surfaceCapabilities);
131
+ const res = await fetch(url.toString(), { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
132
+ if (!res.ok)
133
+ throw new Error(`Ad fetch returned ${res.status}`);
134
+ const json = (await res.json());
135
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
136
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
137
+ const hbs = (json.data?.header_bidding_settings ?? null);
138
+ // Leg A: a playable programmatic VAST tag → the hosted IMA player URL. Built exactly
139
+ // like the screen app / ads-sdk ProgrammaticPlayer hand the tag to the IMA SDK; the
140
+ // wrap's signed pixels bill via `vast_event` when the player completes the ad.
141
+ const vastTagUrl = str(hbs?.vast_tag_url);
142
+ const variantName = str(hbs?.variant_name);
143
+ const imaPlayerUrl = vastTagUrl && isPlayableTagUrl(vastTagUrl)
144
+ ? buildImaPlayerUrl(baseUrl, screenId, vastTagUrl, deviceId, variantName)
145
+ : null;
146
+ const overlays = json.data?.overlay_advertisements;
147
+ const content = Array.isArray(overlays) && overlays.length > 0
148
+ ? overlays[0]?.content
149
+ : null;
150
+ // Leg-A-only fill: a programmatic tag with no direct/house overlay creative. Serve it
151
+ // anyway — the IMA player renders the VAST; there's no terminal media to show.
152
+ if (!content || typeof content !== 'object') {
153
+ if (!imaPlayerUrl)
154
+ return null;
155
+ return {
156
+ mediaType: 'video',
157
+ adId: null,
158
+ name: null,
159
+ description: null,
160
+ campaignLink: null,
161
+ variantName,
162
+ mediaUrl: null,
163
+ durationSeconds: null,
164
+ vastTagUrl,
165
+ imaPlayerUrl,
166
+ completeUrl: null,
167
+ };
168
+ }
169
+ const video = str(content.video);
170
+ const images = Array.isArray(content.image) ? content.image.map((x) => str(x)).filter(Boolean) : [];
171
+ const name = str(content.name);
172
+ // Trust the server's chosen mediaType; otherwise infer (video → image → text).
173
+ let mediaType = str(content.mediaType);
174
+ if (mediaType !== 'text' && mediaType !== 'image' && mediaType !== 'video') {
175
+ mediaType = video ? 'video' : images.length > 0 ? 'image' : 'text';
176
+ }
177
+ const mediaUrl = mediaType === 'video' ? video : mediaType === 'image' ? images[0] ?? null : null;
178
+ // Leg B needs renderable terminal media; a Leg-A (imaPlayerUrl) fill does not, so a
179
+ // missing direct creative is only a no-fill when there's also no programmatic tag.
180
+ if (!imaPlayerUrl) {
181
+ if (mediaType !== 'text' && !mediaUrl)
182
+ return null;
183
+ if (mediaType === 'text' && !name)
184
+ return null;
185
+ }
186
+ return {
187
+ mediaType,
188
+ adId: str(content.id),
189
+ name,
190
+ description: str(content.description),
191
+ campaignLink: str(content.campaign_link),
192
+ variantName,
193
+ mediaUrl,
194
+ durationSeconds: num(content.duration_seconds),
195
+ vastTagUrl,
196
+ imaPlayerUrl,
197
+ completeUrl: str(content.complete_url),
198
+ };
199
+ }
200
+ /**
201
+ * Fire the SERVER-SIGNED agent_direct completion beacon on play completion. The
202
+ * URL (with its `&sig=`) was minted by the serve path, so this passes
203
+ * VAST_ATTRIBUTION_REQUIRE_SIG — the wrapper never builds or signs the complete
204
+ * itself. Best-effort, fire-and-forget: never throws, returns true on a 2xx.
205
+ */
206
+ export async function reportVirtualScreenCompletion(baseUrl, completeUrl) {
207
+ if (!completeUrl)
208
+ return false;
209
+ try {
210
+ const res = await fetch(new URL(completeUrl, baseUrl).toString(), {
211
+ method: 'POST',
212
+ signal: AbortSignal.timeout(5000),
213
+ });
214
+ return res.ok;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
220
+ // ── Billable player URL ──────────────────────────────────────
221
+ /**
222
+ * Build the URL-loadable, billable IMA player link. Opening this in a browser
223
+ * loads the hosted ima-player.js which fires the VAST impression/quartile/complete
224
+ * pixels (that is the money path — the TTY can't render video).
225
+ *
226
+ * The server-side `isAllowedTagUrl` SSRF allowlist gates the `tag` param to known
227
+ * ad-server hosts (GAM/doubleclick/adipolo/adtelligent/...). A non-allowlisted tag
228
+ * yields a 400 page, so we surface a clear hint when the tag won't be playable.
229
+ */
230
+ export function buildImaPlayerUrl(baseUrl, screenId, vastTagUrl, deviceId, variantName) {
231
+ const url = new URL(`/openrtb/v1/ima-player/${encodeURIComponent(screenId)}`, baseUrl);
232
+ url.searchParams.set('tag', vastTagUrl);
233
+ if (deviceId)
234
+ url.searchParams.set('did', deviceId);
235
+ if (variantName)
236
+ url.searchParams.set('var', variantName);
237
+ return url.toString();
238
+ }
239
+ /** Hosts the server-side ima-player SSRF allowlist accepts for the `tag` param. */
240
+ const ALLOWED_TAG_HOSTS = new Set([
241
+ 'ad.vidverto.io',
242
+ 'pubads.adipolo.co',
243
+ 'pubads.g.doubleclick.net',
244
+ 'video.incrementxserv.com',
245
+ 'pagead2.googlesyndication.com',
246
+ 'securepubads.g.doubleclick.net',
247
+ 'googleads.g.doubleclick.net',
248
+ 'www.googletagservices.com',
249
+ 'ad.doubleclick.net',
250
+ 'a.adtelligent.com',
251
+ ]);
252
+ const ALLOWED_TAG_HOST_SUFFIXES = ['.adtelligent.com'];
253
+ /**
254
+ * First-party vast-wrap proxy — the canonical BILLABLE tag (mirrors the server's
255
+ * `isFirstPartyVastProxyUrl` in routes/openrtb/ima-player.js). Our own /vast-wrap, /vast
256
+ * and /src forwarders inject the signed vast_event pixels, so the hosted player renders
257
+ * them; host- + path-restricted so it is not a blanket allow of our origin.
258
+ */
259
+ const FIRST_PARTY_VAST_PROXY_PATH = /^\/openrtb\/v\d+\/(?:vast-wrap|vast|src)\//;
260
+ function isFirstPartyVastProxyUrl(parsed) {
261
+ const host = parsed.hostname.toLowerCase();
262
+ const firstParty = host === 'trillboards.com' || host.endsWith('.trillboards.com');
263
+ return firstParty && FIRST_PARTY_VAST_PROXY_PATH.test(parsed.pathname);
264
+ }
265
+ /** Mirror of the server's allowlist so we can warn before printing an unplayable link. */
266
+ export function isPlayableTagUrl(vastTagUrl) {
267
+ try {
268
+ const parsed = new URL(vastTagUrl);
269
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
270
+ return false;
271
+ const host = parsed.hostname.toLowerCase();
272
+ if (ALLOWED_TAG_HOSTS.has(host) || ALLOWED_TAG_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
273
+ return true;
274
+ }
275
+ return isFirstPartyVastProxyUrl(parsed);
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }
281
+ // ── Event reporting ──────────────────────────────────────────
282
+ /**
283
+ * Report a text-ad / programmatic lifecycle event for the virtual screen via the
284
+ * public OpenRTB event endpoint (the partner programmatic-event route 403s for a
285
+ * partner-less screen). Best-effort, fire-and-forget; never throws.
286
+ *
287
+ * `adid` is required by the endpoint; for synthetic lifecycle events (request,
288
+ * no_fill) we pass a deterministic synthetic id.
289
+ */
290
+ export async function reportTextAdEvent(baseUrl, params, timeoutMs = 5000) {
291
+ try {
292
+ const url = new URL('/openrtb/v1/event', baseUrl);
293
+ url.searchParams.set('adid', params.adid);
294
+ url.searchParams.set('event', params.event);
295
+ url.searchParams.set('screenId', params.screenId);
296
+ if (params.deviceId)
297
+ url.searchParams.set('deviceId', params.deviceId);
298
+ if (params.variantName)
299
+ url.searchParams.set('variant_name', params.variantName);
300
+ const res = await fetch(url.toString(), { method: 'POST', signal: AbortSignal.timeout(timeoutMs) });
301
+ return res.ok;
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ /** Generate a synthetic per-impression id for text-ad telemetry. */
308
+ export function newImpressionId() {
309
+ return 'agentcli_' + crypto.randomBytes(8).toString('hex');
310
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure decision logic for the Claude Code dead-time hook (`ads hook`).
3
+ *
4
+ * The hook fires on every prompt submit (the moment the agent starts thinking).
5
+ * We render an in-place TEXT sponsor line — no browser, no video player — but only
6
+ * (a) when this install is a registered virtual screen, (b) at most once per
7
+ * interval (frequency cap), and (c) when there is a fillable text creative.
8
+ *
9
+ * Everything here is injectable + side-effect-free so it is fully unit-tested; the
10
+ * command wrapper supplies real config/network/clock. runAgentHook NEVER throws —
11
+ * any failure resolves to {} so the developer's prompt always proceeds.
12
+ */
13
+ import type { VirtualScreenTextAd } from './ads-client.js';
14
+ /** Default minimum gap between in-place sponsor lines (3 min). */
15
+ export declare const AGENT_AD_MIN_INTERVAL_MS = 180000;
16
+ /**
17
+ * Effective interval. A developer may tune their own cadence with
18
+ * TRILLBOARDS_AGENT_AD_INTERVAL_SECONDS (floored at 10s to prevent spam); otherwise
19
+ * the sane default. This is the dev's own machine preference, not a server flag.
20
+ */
21
+ export declare function agentAdIntervalMs(): number;
22
+ export declare function shouldShowAd(lastShownAt: number | undefined, now: number, intervalMs: number): boolean;
23
+ /**
24
+ * Build the one-line sponsor message rendered to the developer (via the hook's
25
+ * `systemMessage`). Plain text — Claude Code styles it; the bare URL stays
26
+ * click-/copy-able in the terminal. Returns null when there is no usable headline.
27
+ */
28
+ export declare function buildSponsorMessage(ad: VirtualScreenTextAd): string | null;
29
+ export interface AgentHookDeps {
30
+ now: number;
31
+ screenId: string | null;
32
+ deviceId: string | null;
33
+ baseUrl: string;
34
+ lastShownAt: number | undefined;
35
+ intervalMs: number;
36
+ fetchTextAd: (baseUrl: string, screenId: string) => Promise<VirtualScreenTextAd | null>;
37
+ markShown: (now: number) => void;
38
+ }
39
+ /** Non-billable 'request' telemetry the COMMAND fires AFTER writing the render. */
40
+ export interface AgentHookReport {
41
+ screenId: string;
42
+ deviceId: string | null;
43
+ event: string;
44
+ adid: string;
45
+ variantName: string | null;
46
+ }
47
+ export interface AgentHookResult {
48
+ /** Shown to the developer by Claude Code (never injected into the model context). */
49
+ systemMessage?: string;
50
+ /** Telemetry to fire after rendering; absent when nothing is shown or no creative id. */
51
+ report?: AgentHookReport;
52
+ }
53
+ /**
54
+ * Decide what to show. Returns {systemMessage, report?} ONLY when an ad is actually
55
+ * shown. Guards: registered? → interval elapsed? → filled? → renderable? Marks
56
+ * "shown" only on a real render (a no-fill retries next prompt).
57
+ *
58
+ * Telemetry is intentionally NOT fired here: the command writes the render to stdout
59
+ * FIRST and reports afterward, so a slow event endpoint can never hold the sponsor
60
+ * line past Claude Code's hook timeout. The 'request' event keys on the served
61
+ * creative id (an ObjectId the event endpoint accepts) — synthetic agentcli_ ids
62
+ * would be rejected as invalid_adid_format.
63
+ */
64
+ export declare function runAgentHook(deps: AgentHookDeps): Promise<AgentHookResult>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure decision logic for the Claude Code dead-time hook (`ads hook`).
3
+ *
4
+ * The hook fires on every prompt submit (the moment the agent starts thinking).
5
+ * We render an in-place TEXT sponsor line — no browser, no video player — but only
6
+ * (a) when this install is a registered virtual screen, (b) at most once per
7
+ * interval (frequency cap), and (c) when there is a fillable text creative.
8
+ *
9
+ * Everything here is injectable + side-effect-free so it is fully unit-tested; the
10
+ * command wrapper supplies real config/network/clock. runAgentHook NEVER throws —
11
+ * any failure resolves to {} so the developer's prompt always proceeds.
12
+ */
13
+ /** Default minimum gap between in-place sponsor lines (3 min). */
14
+ export const AGENT_AD_MIN_INTERVAL_MS = 180_000;
15
+ /** Longest sponsor line we render before truncating (keeps it to one tasteful line). */
16
+ const MAX_SPONSOR_LINE = 160;
17
+ /**
18
+ * Effective interval. A developer may tune their own cadence with
19
+ * TRILLBOARDS_AGENT_AD_INTERVAL_SECONDS (floored at 10s to prevent spam); otherwise
20
+ * the sane default. This is the dev's own machine preference, not a server flag.
21
+ */
22
+ export function agentAdIntervalMs() {
23
+ const raw = process.env.TRILLBOARDS_AGENT_AD_INTERVAL_SECONDS;
24
+ const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
25
+ if (Number.isFinite(n) && n >= 10)
26
+ return n * 1000;
27
+ return AGENT_AD_MIN_INTERVAL_MS;
28
+ }
29
+ export function shouldShowAd(lastShownAt, now, intervalMs) {
30
+ if (!lastShownAt || !Number.isFinite(lastShownAt))
31
+ return true;
32
+ return now - lastShownAt >= intervalMs;
33
+ }
34
+ /**
35
+ * Build the one-line sponsor message rendered to the developer (via the hook's
36
+ * `systemMessage`). Plain text — Claude Code styles it; the bare URL stays
37
+ * click-/copy-able in the terminal. Returns null when there is no usable headline.
38
+ */
39
+ export function buildSponsorMessage(ad) {
40
+ if (!ad.name)
41
+ return null;
42
+ let line = `◆ Sponsored · ${ad.name}`;
43
+ if (ad.description)
44
+ line += ` — ${ad.description}`;
45
+ if (line.length > MAX_SPONSOR_LINE)
46
+ line = line.slice(0, MAX_SPONSOR_LINE - 1).trimEnd() + '…';
47
+ if (ad.campaignLink)
48
+ line += ` ${ad.campaignLink}`;
49
+ return line;
50
+ }
51
+ /**
52
+ * Decide what to show. Returns {systemMessage, report?} ONLY when an ad is actually
53
+ * shown. Guards: registered? → interval elapsed? → filled? → renderable? Marks
54
+ * "shown" only on a real render (a no-fill retries next prompt).
55
+ *
56
+ * Telemetry is intentionally NOT fired here: the command writes the render to stdout
57
+ * FIRST and reports afterward, so a slow event endpoint can never hold the sponsor
58
+ * line past Claude Code's hook timeout. The 'request' event keys on the served
59
+ * creative id (an ObjectId the event endpoint accepts) — synthetic agentcli_ ids
60
+ * would be rejected as invalid_adid_format.
61
+ */
62
+ export async function runAgentHook(deps) {
63
+ try {
64
+ if (!deps.screenId)
65
+ return {};
66
+ if (!shouldShowAd(deps.lastShownAt, deps.now, deps.intervalMs))
67
+ return {};
68
+ let ad = null;
69
+ try {
70
+ ad = await deps.fetchTextAd(deps.baseUrl, deps.screenId);
71
+ }
72
+ catch {
73
+ return {}; // fail open — never block the prompt on a fetch error
74
+ }
75
+ if (!ad)
76
+ return {};
77
+ const message = buildSponsorMessage(ad);
78
+ if (!message)
79
+ return {};
80
+ deps.markShown(deps.now);
81
+ const result = { systemMessage: message };
82
+ if (ad.adId) {
83
+ result.report = {
84
+ screenId: deps.screenId,
85
+ deviceId: deps.deviceId,
86
+ event: 'request',
87
+ adid: ad.adId,
88
+ variantName: ad.variantName,
89
+ };
90
+ }
91
+ return result;
92
+ }
93
+ catch {
94
+ return {};
95
+ }
96
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Terminal surface-capability detection for the `trillboards code` wrapper.
3
+ *
4
+ * The render surface available during the agent's dead-time depends on the host
5
+ * terminal. We map the environment to four capabilities and hand them to the
6
+ * server as `?surface_capabilities=…` so it returns the richest creative the
7
+ * surface can actually show (the server's `selectCreativeFormat`).
8
+ *
9
+ * text — every TTY (an OSC-8 sponsor line)
10
+ * image — inline graphics (Kitty / iTerm2 / Sixel; incl. VS Code's terminal for STATIC images)
11
+ * video — real in-terminal video via `mpv --vo=kitty` (Kitty/Ghostty/iTerm2/WezTerm) — NOT VS Code/Cursor
12
+ * webview — we can open an external browser/PiP for an IMA/AdX video (the Leg-A fallback)
13
+ *
14
+ * Pure + env-injectable so it is fully unit-tested.
15
+ */
16
+ export interface SurfaceCapabilities {
17
+ text: boolean;
18
+ image: boolean;
19
+ /** Real in-terminal video (mpv --vo=kitty quality). VS Code/Cursor terminals = false. */
20
+ video: boolean;
21
+ /** Can open an external browser/PiP for an IMA/AdX video (the Leg-A fallback). */
22
+ webview: boolean;
23
+ }
24
+ /**
25
+ * Detect what the host terminal can render, from the environment alone (no
26
+ * round-trip probe — that lives in the wrapper which can do the Kitty `a=q` query
27
+ * for a runtime confirm). `env` is injected so tests cover the matrix.
28
+ */
29
+ export declare function detectSurfaceCapabilities(env?: NodeJS.ProcessEnv): SurfaceCapabilities;
30
+ /** Serialize for the `?surface_capabilities=` query param the server's selector reads. */
31
+ export declare function capabilitiesToParam(caps: SurfaceCapabilities): string;