@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.
- package/README.md +414 -43
- package/dist/ads-client.d.ts +146 -0
- package/dist/ads-client.js +310 -0
- package/dist/agent-hook.d.ts +64 -0
- package/dist/agent-hook.js +96 -0
- package/dist/capability.d.ts +31 -0
- package/dist/capability.js +67 -0
- package/dist/claude-settings.d.ts +64 -0
- package/dist/claude-settings.js +126 -0
- package/dist/commands/ads/disable.d.ts +10 -0
- package/dist/commands/ads/disable.js +25 -0
- package/dist/commands/ads/enable.d.ts +11 -0
- package/dist/commands/ads/enable.js +32 -0
- package/dist/commands/ads/hook.d.ts +16 -0
- package/dist/commands/ads/hook.js +90 -0
- package/dist/commands/ads/login.d.ts +21 -0
- package/dist/commands/ads/login.js +108 -0
- package/dist/commands/ads/start.d.ts +20 -0
- package/dist/commands/ads/start.js +175 -0
- package/dist/commands/code.d.ts +12 -0
- package/dist/commands/code.js +84 -0
- package/dist/commands/devices/reconcile.d.ts +10 -0
- package/dist/commands/devices/reconcile.js +94 -0
- package/dist/commands/generated/generated-device-heartbeat-get.d.ts +14 -0
- package/dist/commands/generated/generated-device-heartbeat-get.js +32 -0
- package/dist/commands/generated/generated-federated-get-latest-model-descriptor.d.ts +19 -0
- package/dist/commands/generated/generated-federated-get-latest-model-descriptor.js +47 -0
- package/dist/commands/generated/generated-federated-get-model-weights.d.ts +19 -0
- package/dist/commands/generated/generated-federated-get-model-weights.js +47 -0
- package/dist/commands/generated/generated-federated-gradient-upload.d.ts +13 -0
- package/dist/commands/generated/generated-federated-gradient-upload.js +32 -0
- package/dist/commands/generated/generated-federated-performance-upload.d.ts +13 -0
- package/dist/commands/generated/generated-federated-performance-upload.js +32 -0
- package/dist/commands/generated/generated-federated-taste-upload.d.ts +13 -0
- package/dist/commands/generated/generated-federated-taste-upload.js +32 -0
- package/dist/commands/generated/generated-post-device-heartbeat.d.ts +15 -0
- package/dist/commands/generated/generated-post-device-heartbeat.js +35 -0
- package/dist/commands/listen.js +9 -1
- package/dist/commands/logs/tail.js +10 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +34 -9
- package/dist/dead-time.d.ts +51 -0
- package/dist/dead-time.js +119 -0
- package/dist/format-helpers.d.ts +6 -0
- package/dist/format-helpers.js +10 -0
- package/dist/http.d.ts +8 -1
- package/dist/http.js +6 -1
- package/dist/machine-id.d.ts +12 -0
- package/dist/machine-id.js +22 -0
- package/dist/pty-wrapper.d.ts +46 -0
- package/dist/pty-wrapper.js +245 -0
- package/dist/render-runner.d.ts +48 -0
- package/dist/render-runner.js +151 -0
- package/dist/render.d.ts +82 -0
- package/dist/render.js +124 -0
- package/dist/user-auth.d.ts +57 -0
- package/dist/user-auth.js +110 -0
- package/oclif.manifest.json +1140 -255
- 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;
|