@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,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `trillboards code` PTY wrapper core.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the real coding agent under a pseudo-terminal we own, and passes bytes
|
|
5
|
+
* through verbatim in both directions — so the agent (Claude Code / Codex / …) runs
|
|
6
|
+
* byte-for-byte normally (alt-screen, raw mode, resize, paste all flow through). We
|
|
7
|
+
* only TEE the agent's output into the dead-time detector; when the agent starts
|
|
8
|
+
* "thinking", we play an ad on a side surface, and tear it down when it resumes.
|
|
9
|
+
*
|
|
10
|
+
* This is the I/O glue — the decision logic (dead-time, capability, render commands,
|
|
11
|
+
* fetch) all lives in unit-tested pure modules. Never lets ad logic break the agent.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn as childSpawn } from 'node:child_process';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { DeadTimeDetector } from './dead-time.js';
|
|
16
|
+
import { renderCreative } from './render-runner.js';
|
|
17
|
+
// node-pty is an OPTIONAL native dependency: it ships prebuilds for darwin/win32 only —
|
|
18
|
+
// no Linux prebuild — so a plain `npm i -g @trillboards/cli` would FAIL the C++ rebuild on
|
|
19
|
+
// Linux/Docker/Codespaces/WSL without python3+make+g++. It now lives in
|
|
20
|
+
// `optionalDependencies`, so a failed build is non-fatal (the package still installs and
|
|
21
|
+
// every other command works), and we LAZY-require it here (ESM → createRequire) so merely
|
|
22
|
+
// importing this module never throws. `trillboards code` checks isPtyAvailable() and
|
|
23
|
+
// degrades to a transparent passthrough (runAgentPassthrough) when it's absent.
|
|
24
|
+
const _require = createRequire(import.meta.url);
|
|
25
|
+
let _ptySpawn;
|
|
26
|
+
function loadPtySpawn() {
|
|
27
|
+
if (_ptySpawn !== undefined)
|
|
28
|
+
return _ptySpawn;
|
|
29
|
+
try {
|
|
30
|
+
_ptySpawn = _require('node-pty').spawn;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
_ptySpawn = null;
|
|
34
|
+
}
|
|
35
|
+
return _ptySpawn;
|
|
36
|
+
}
|
|
37
|
+
/** True iff the optional node-pty native module loaded — i.e. `code` can ad-wrap. */
|
|
38
|
+
export function isPtyAvailable() {
|
|
39
|
+
return loadPtySpawn() !== null;
|
|
40
|
+
}
|
|
41
|
+
const HEARTBEAT_MS = 30_000;
|
|
42
|
+
const TICK_MS = 250;
|
|
43
|
+
/** Minimum on-screen time before a play-and-count completion is reported as billable. */
|
|
44
|
+
const MIN_PLAY_MS = 1000;
|
|
45
|
+
/**
|
|
46
|
+
* Run the agent under a PTY and serve ads into its dead-time. Resolves with the
|
|
47
|
+
* agent's exit code. Restores the terminal + tears down all timers/renders on exit.
|
|
48
|
+
*/
|
|
49
|
+
export function runWrappedAgent(agent, agentArgs, deps) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const renderCtx = { caps: deps.caps, inTmux: Boolean(process.env.TMUX), platform: process.platform };
|
|
52
|
+
let lastShownAt = 0;
|
|
53
|
+
let currentRender = null;
|
|
54
|
+
let currentCreative = null;
|
|
55
|
+
// When the ad actually became visible (render assigned) — NOT the dead-time
|
|
56
|
+
// entry. Creative fetch + render take non-trivial time; measuring play time
|
|
57
|
+
// from here avoids reporting a completion for an ad shown < MIN_PLAY_MS (codex).
|
|
58
|
+
let renderStartedAt = 0;
|
|
59
|
+
const closeRender = () => {
|
|
60
|
+
// Capture before clearing so the completion fires for the ad that was just shown.
|
|
61
|
+
const shownCreative = currentRender ? currentCreative : null;
|
|
62
|
+
const shownMs = currentRender ? Date.now() - renderStartedAt : 0;
|
|
63
|
+
if (currentRender) {
|
|
64
|
+
try {
|
|
65
|
+
currentRender.close();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
currentRender = null;
|
|
71
|
+
}
|
|
72
|
+
currentCreative = null;
|
|
73
|
+
// Play-and-count: the ad was displayed through this dead-time window and is
|
|
74
|
+
// now torn down. Fire the server-signed completion beacon (the billable
|
|
75
|
+
// agent_direct signal) iff it was on screen long enough to count as a play.
|
|
76
|
+
if (shownCreative && shownMs >= MIN_PLAY_MS) {
|
|
77
|
+
void deps.reportCompletion(shownCreative).catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const onDeadTimeEnter = () => {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
if (now - lastShownAt < deps.intervalMs)
|
|
83
|
+
return; // frequency cap
|
|
84
|
+
lastShownAt = now;
|
|
85
|
+
// Fire-and-forget: fetch + render + report must never block the agent.
|
|
86
|
+
void (async () => {
|
|
87
|
+
let creative = null;
|
|
88
|
+
try {
|
|
89
|
+
creative = await deps.fetchCreative(deps.capabilitiesParam);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!creative)
|
|
95
|
+
return;
|
|
96
|
+
let handle = null;
|
|
97
|
+
try {
|
|
98
|
+
handle = renderCreative(creative, renderCtx);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Nothing rendered (no surface for this creative) → never report an unshown ad.
|
|
104
|
+
if (!handle)
|
|
105
|
+
return;
|
|
106
|
+
currentRender = handle;
|
|
107
|
+
currentCreative = creative;
|
|
108
|
+
renderStartedAt = Date.now(); // ad is now visible — billable play clock starts here
|
|
109
|
+
try {
|
|
110
|
+
await deps.reportImpression(creative);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
};
|
|
117
|
+
const detector = new DeadTimeDetector({ onEnter: onDeadTimeEnter, onExit: closeRender }, { spinnerStartMs: 300, spinnerStopMs: 1500 });
|
|
118
|
+
const ptySpawn = loadPtySpawn();
|
|
119
|
+
if (!ptySpawn) {
|
|
120
|
+
// node-pty unavailable (e.g. a Linux install with no compiler) — run the agent
|
|
121
|
+
// verbatim, un-wrapped, so the developer is NEVER blocked. (code.ts gates on
|
|
122
|
+
// isPtyAvailable() to print guidance first; this is the belt-and-suspenders.)
|
|
123
|
+
runAgentPassthrough(agent, agentArgs).then(resolve, () => resolve(1));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const cols = process.stdout.columns || 80;
|
|
127
|
+
const rows = process.stdout.rows || 24;
|
|
128
|
+
const term = ptySpawn(agent, agentArgs, {
|
|
129
|
+
name: process.env.TERM || 'xterm-256color',
|
|
130
|
+
cols,
|
|
131
|
+
rows,
|
|
132
|
+
cwd: process.cwd(),
|
|
133
|
+
env: process.env,
|
|
134
|
+
});
|
|
135
|
+
// ── agent output → our stdout + the dead-time detector ──
|
|
136
|
+
term.onData((data) => {
|
|
137
|
+
try {
|
|
138
|
+
process.stdout.write(data);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
detector.feed(data);
|
|
144
|
+
});
|
|
145
|
+
// ── our stdin (raw) → the agent; a keystroke also ends dead-time ──
|
|
146
|
+
const stdin = process.stdin;
|
|
147
|
+
if (stdin.isTTY) {
|
|
148
|
+
try {
|
|
149
|
+
stdin.setRawMode(true);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* ignore */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
stdin.resume();
|
|
156
|
+
const onStdin = (data) => {
|
|
157
|
+
try {
|
|
158
|
+
term.write(data.toString('utf8'));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
detector.onUserInput();
|
|
164
|
+
};
|
|
165
|
+
stdin.on('data', onStdin);
|
|
166
|
+
// ── resize passthrough (the kernel won't propagate it to the pty) ──
|
|
167
|
+
const onResize = () => {
|
|
168
|
+
try {
|
|
169
|
+
term.resize(process.stdout.columns || cols, process.stdout.rows || rows);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* ignore */
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
process.stdout.on('resize', onResize);
|
|
176
|
+
const tick = setInterval(() => detector.tick(), TICK_MS);
|
|
177
|
+
const beat = setInterval(() => {
|
|
178
|
+
void deps.heartbeat().catch(() => { });
|
|
179
|
+
}, HEARTBEAT_MS);
|
|
180
|
+
void deps.heartbeat().catch(() => { }); // immediate first heartbeat → online for serving
|
|
181
|
+
let settled = false;
|
|
182
|
+
const cleanup = () => {
|
|
183
|
+
if (settled)
|
|
184
|
+
return;
|
|
185
|
+
settled = true;
|
|
186
|
+
clearInterval(tick);
|
|
187
|
+
clearInterval(beat);
|
|
188
|
+
closeRender();
|
|
189
|
+
stdin.removeListener('data', onStdin);
|
|
190
|
+
process.stdout.removeListener('resize', onResize);
|
|
191
|
+
try {
|
|
192
|
+
if (stdin.isTTY)
|
|
193
|
+
stdin.setRawMode(false);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* ignore */
|
|
197
|
+
}
|
|
198
|
+
stdin.pause();
|
|
199
|
+
};
|
|
200
|
+
term.onExit(({ exitCode }) => {
|
|
201
|
+
cleanup();
|
|
202
|
+
resolve(exitCode ?? 0);
|
|
203
|
+
});
|
|
204
|
+
// If the parent is asked to quit, take the agent down with us.
|
|
205
|
+
const onSig = () => {
|
|
206
|
+
try {
|
|
207
|
+
term.kill();
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* ignore */
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
process.on('SIGINT', onSig);
|
|
214
|
+
process.on('SIGTERM', onSig);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Run the agent verbatim with inherited stdio (NO ad-wrapping). The graceful fallback
|
|
219
|
+
* when node-pty is unavailable — the developer's coding agent works exactly as normal;
|
|
220
|
+
* they just don't earn from `code` here (they can run `trillboards ads enable`, the text
|
|
221
|
+
* hook, which needs no native deps and works in every terminal). Resolves with the
|
|
222
|
+
* agent's exit code.
|
|
223
|
+
*/
|
|
224
|
+
export function runAgentPassthrough(agent, agentArgs) {
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
const child = childSpawn(agent, agentArgs, { stdio: 'inherit', env: process.env });
|
|
227
|
+
const onSig = () => {
|
|
228
|
+
try {
|
|
229
|
+
child.kill();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* ignore */
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
process.on('SIGINT', onSig);
|
|
236
|
+
process.on('SIGTERM', onSig);
|
|
237
|
+
const settle = (code) => {
|
|
238
|
+
process.removeListener('SIGINT', onSig);
|
|
239
|
+
process.removeListener('SIGTERM', onSig);
|
|
240
|
+
resolve(code);
|
|
241
|
+
};
|
|
242
|
+
child.on('exit', (code) => settle(code ?? 0));
|
|
243
|
+
child.on('error', () => settle(127)); // command-not-found etc.
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-runner: the I/O half of the render adapters. Given a creative + the host
|
|
3
|
+
* context, it spawns the actual player (mpv in a tmux split pane, timg for an image,
|
|
4
|
+
* a text line in a tmux split, or a spawned mpv window) and returns a handle to tear
|
|
5
|
+
* it down when dead-time ends. The command *construction* lives in render.ts (pure,
|
|
6
|
+
* unit-tested); this file only does process spawning, so it stays thin + best-effort.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: returns `null` when nothing was actually rendered (no surface for this
|
|
9
|
+
* creative). The caller MUST skip impression reporting on null — never report an ad
|
|
10
|
+
* that wasn't shown.
|
|
11
|
+
*/
|
|
12
|
+
import type { VirtualScreenCreative } from './ads-client.js';
|
|
13
|
+
import type { SurfaceCapabilities } from './capability.js';
|
|
14
|
+
export interface RenderHandle {
|
|
15
|
+
/** Tear down the render (kill the tmux pane / mpv window) if it's still up. */
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
18
|
+
export interface RenderContext {
|
|
19
|
+
caps: SurfaceCapabilities;
|
|
20
|
+
inTmux: boolean;
|
|
21
|
+
platform: NodeJS.Platform;
|
|
22
|
+
}
|
|
23
|
+
/** Play a video creative; returns a handle, or null if it couldn't render. */
|
|
24
|
+
export declare function renderVideo(url: string, ctx: RenderContext): RenderHandle | null;
|
|
25
|
+
/** Show an image creative inline (tmux split running timg). Null when no tmux surface. */
|
|
26
|
+
export declare function renderImage(url: string, ctx: RenderContext): RenderHandle | null;
|
|
27
|
+
/** Show a one-line text creative in a tmux split. Null when no tmux surface. */
|
|
28
|
+
export declare function renderText(creative: VirtualScreenCreative, ctx: RenderContext): RenderHandle | null;
|
|
29
|
+
/**
|
|
30
|
+
* Surface a programmatic (Leg A) fill. In tmux: a clickable OSC-8 link to the hosted IMA
|
|
31
|
+
* player in a non-modal split pane (the dev clicks; the runner kills the pane when dead-time
|
|
32
|
+
* ends). Outside tmux (VS Code / Cursor / plain): open the player in the default browser —
|
|
33
|
+
* the webview IS the Leg A surface. Either way the player runs the served VAST through the
|
|
34
|
+
* Google IMA SDK (the exact path the screen app uses) and bills via the wrap's signed
|
|
35
|
+
* vast_event pixels, independent of this handle's lifetime. Null only when the creative
|
|
36
|
+
* carries no playable IMA player URL.
|
|
37
|
+
*/
|
|
38
|
+
export declare function renderProgrammatic(creative: VirtualScreenCreative, ctx: RenderContext): RenderHandle | null;
|
|
39
|
+
/**
|
|
40
|
+
* Dispatch a creative to the right renderer. Returns a handle, or NULL when nothing
|
|
41
|
+
* was rendered (the caller must then skip the impression report).
|
|
42
|
+
*
|
|
43
|
+
* Leg A (programmatic) takes precedence: a real-CPM VAST fill outranks the direct/house
|
|
44
|
+
* creative the same fetch may carry. A creative with an `imaPlayerUrl` is Leg A or nothing
|
|
45
|
+
* — we never fall through to a Leg B render for it, so the completion path stays
|
|
46
|
+
* unambiguous (Leg A bills via vast_event, Leg B via the agent_direct beacon).
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderCreative(creative: VirtualScreenCreative, ctx: RenderContext): RenderHandle | null;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-runner: the I/O half of the render adapters. Given a creative + the host
|
|
3
|
+
* context, it spawns the actual player (mpv in a tmux split pane, timg for an image,
|
|
4
|
+
* a text line in a tmux split, or a spawned mpv window) and returns a handle to tear
|
|
5
|
+
* it down when dead-time ends. The command *construction* lives in render.ts (pure,
|
|
6
|
+
* unit-tested); this file only does process spawning, so it stays thin + best-effort.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: returns `null` when nothing was actually rendered (no surface for this
|
|
9
|
+
* creative). The caller MUST skip impression reporting on null — never report an ad
|
|
10
|
+
* that wasn't shown.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { buildImagePlayback, planProgrammaticPlayback, planVideoPlayback, renderTextLine, shellQuote } from './render.js';
|
|
14
|
+
function killTmuxPane(paneId) {
|
|
15
|
+
const id = paneId.trim();
|
|
16
|
+
if (!id)
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
spawn('tmux', ['kill-pane', '-t', id], { stdio: 'ignore' }).on('error', () => { });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* ignore */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Spawn a tmux split pane running `innerCmd`; returns a handle that kills the pane. */
|
|
26
|
+
function tmuxSplitPane(innerCmd, sizePct) {
|
|
27
|
+
let paneId = '';
|
|
28
|
+
const child = spawn('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-l', `${sizePct}%`, innerCmd], {
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
});
|
|
31
|
+
child.stdout?.on('data', (d) => {
|
|
32
|
+
paneId += d.toString();
|
|
33
|
+
});
|
|
34
|
+
child.on('error', () => { });
|
|
35
|
+
return { close: () => killTmuxPane(paneId) };
|
|
36
|
+
}
|
|
37
|
+
/** Play a video creative; returns a handle, or null if it couldn't render. */
|
|
38
|
+
export function renderVideo(url, ctx) {
|
|
39
|
+
try {
|
|
40
|
+
const plan = planVideoPlayback(url, { inTmux: ctx.inTmux, videoCapable: ctx.caps.video, platform: ctx.platform });
|
|
41
|
+
if (plan.strategy === 'tmux-pane') {
|
|
42
|
+
let paneId = '';
|
|
43
|
+
const child = spawn(plan.spec.bin, plan.spec.args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
44
|
+
child.stdout?.on('data', (d) => {
|
|
45
|
+
paneId += d.toString();
|
|
46
|
+
});
|
|
47
|
+
child.on('error', () => { });
|
|
48
|
+
return { close: () => killTmuxPane(paneId) };
|
|
49
|
+
}
|
|
50
|
+
// spawn-window: detached mpv window (VS Code / Cursor / no-tmux).
|
|
51
|
+
const child = spawn(plan.spec.bin, plan.spec.args, { detached: true, stdio: 'ignore' });
|
|
52
|
+
child.on('error', () => { });
|
|
53
|
+
child.unref();
|
|
54
|
+
return {
|
|
55
|
+
close() {
|
|
56
|
+
try {
|
|
57
|
+
if (child.pid)
|
|
58
|
+
child.kill();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Show an image creative inline (tmux split running timg). Null when no tmux surface. */
|
|
71
|
+
export function renderImage(url, ctx) {
|
|
72
|
+
try {
|
|
73
|
+
if (!ctx.inTmux || !ctx.caps.image)
|
|
74
|
+
return null;
|
|
75
|
+
const spec = buildImagePlayback(url);
|
|
76
|
+
const inner = `${spec.bin} ${spec.args.map((a) => (a === url ? shellQuote(a) : a)).join(' ')}; sleep 6`;
|
|
77
|
+
return tmuxSplitPane(inner, 30);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Show a one-line text creative in a tmux split. Null when no tmux surface. */
|
|
84
|
+
export function renderText(creative, ctx) {
|
|
85
|
+
try {
|
|
86
|
+
if (!ctx.inTmux || !ctx.caps.text)
|
|
87
|
+
return null;
|
|
88
|
+
const line = renderTextLine(creative);
|
|
89
|
+
if (!line)
|
|
90
|
+
return null;
|
|
91
|
+
// printf the styled line (incl. OSC-8) into the split, then hold it briefly.
|
|
92
|
+
const inner = `printf '%s\\n' ${shellQuote(line)}; sleep 8`;
|
|
93
|
+
return tmuxSplitPane(inner, 12);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Surface a programmatic (Leg A) fill. In tmux: a clickable OSC-8 link to the hosted IMA
|
|
101
|
+
* player in a non-modal split pane (the dev clicks; the runner kills the pane when dead-time
|
|
102
|
+
* ends). Outside tmux (VS Code / Cursor / plain): open the player in the default browser —
|
|
103
|
+
* the webview IS the Leg A surface. Either way the player runs the served VAST through the
|
|
104
|
+
* Google IMA SDK (the exact path the screen app uses) and bills via the wrap's signed
|
|
105
|
+
* vast_event pixels, independent of this handle's lifetime. Null only when the creative
|
|
106
|
+
* carries no playable IMA player URL.
|
|
107
|
+
*/
|
|
108
|
+
export function renderProgrammatic(creative, ctx) {
|
|
109
|
+
try {
|
|
110
|
+
const plan = planProgrammaticPlayback(creative, { inTmux: ctx.inTmux, platform: ctx.platform });
|
|
111
|
+
if (!plan)
|
|
112
|
+
return null;
|
|
113
|
+
if (plan.strategy === 'tmux-link' && plan.line) {
|
|
114
|
+
const inner = `printf '%s\\n' ${shellQuote(plan.line)}; sleep 60`;
|
|
115
|
+
return tmuxSplitPane(inner, 18);
|
|
116
|
+
}
|
|
117
|
+
if (plan.strategy === 'open-url' && plan.spec) {
|
|
118
|
+
// No tmux (VS Code/Cursor/plain): open the hosted IMA player in the default browser.
|
|
119
|
+
// The tab outlives this dead-time and the IMA player bills via vast_event on its own,
|
|
120
|
+
// so close() is a no-op — we never need to (and can't) tear down the user's tab.
|
|
121
|
+
const child = spawn(plan.spec.bin, plan.spec.args, { detached: true, stdio: 'ignore' });
|
|
122
|
+
child.on('error', () => { });
|
|
123
|
+
child.unref();
|
|
124
|
+
return { close: () => { } };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Dispatch a creative to the right renderer. Returns a handle, or NULL when nothing
|
|
134
|
+
* was rendered (the caller must then skip the impression report).
|
|
135
|
+
*
|
|
136
|
+
* Leg A (programmatic) takes precedence: a real-CPM VAST fill outranks the direct/house
|
|
137
|
+
* creative the same fetch may carry. A creative with an `imaPlayerUrl` is Leg A or nothing
|
|
138
|
+
* — we never fall through to a Leg B render for it, so the completion path stays
|
|
139
|
+
* unambiguous (Leg A bills via vast_event, Leg B via the agent_direct beacon).
|
|
140
|
+
*/
|
|
141
|
+
export function renderCreative(creative, ctx) {
|
|
142
|
+
if (creative.imaPlayerUrl)
|
|
143
|
+
return renderProgrammatic(creative, ctx);
|
|
144
|
+
if (creative.mediaType === 'video' && creative.mediaUrl)
|
|
145
|
+
return renderVideo(creative.mediaUrl, ctx);
|
|
146
|
+
if (creative.mediaType === 'image' && creative.mediaUrl)
|
|
147
|
+
return renderImage(creative.mediaUrl, ctx);
|
|
148
|
+
if (creative.mediaType === 'text')
|
|
149
|
+
return renderText(creative, ctx);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render adapters for the `trillboards code` wrapper (pure command builders).
|
|
3
|
+
*
|
|
4
|
+
* Given the chosen creative + the surface capabilities, render it during dead-time:
|
|
5
|
+
* video → mpv in a non-modal tmux split pane where the terminal supports it,
|
|
6
|
+
* else a spawned mpv window (VS Code/Cursor/unknown)
|
|
7
|
+
* image → timg / kitten icat inline
|
|
8
|
+
* text → an OSC-8 sponsor line written to the wrapper's own output
|
|
9
|
+
*
|
|
10
|
+
* The command BUILDERS are pure (return {bin,args}) so they're unit-tested; the
|
|
11
|
+
* actual spawning lives in render-runner.ts.
|
|
12
|
+
*/
|
|
13
|
+
import type { VirtualScreenCreative } from './ads-client.js';
|
|
14
|
+
/** The render adapters operate on the canonical creative shape from ads-client. */
|
|
15
|
+
export type RenderableCreative = VirtualScreenCreative;
|
|
16
|
+
export interface SpawnSpec {
|
|
17
|
+
bin: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
}
|
|
20
|
+
/** POSIX single-quote a string for safe interpolation into a shell command. */
|
|
21
|
+
export declare function shellQuote(s: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Build the command to play a video in a NON-MODAL tmux split pane. `-d` keeps focus
|
|
24
|
+
* (and keystrokes) on the agent's pane — the ad plays beside it without stealing
|
|
25
|
+
* input; the pane auto-closes when mpv reaches EOF. Each tmux pane is its own grid,
|
|
26
|
+
* so the agent never repaints over the video. Requires `set -g allow-passthrough on`.
|
|
27
|
+
* `-P -F '#{pane_id}'` prints the new pane id so the runner can kill it early.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildTmuxPaneVideo(url: string, sizePct?: number): SpawnSpec;
|
|
30
|
+
/** Build the command to play a video in mpv's OWN window (fallback: VS Code/Cursor/no-tmux). */
|
|
31
|
+
export declare function buildSpawnWindowVideo(url: string, platform?: NodeJS.Platform): SpawnSpec;
|
|
32
|
+
/** Build the inline-image command (timg auto-detects kitty/iTerm2/sixel). */
|
|
33
|
+
export declare function buildImagePlayback(url: string): SpawnSpec;
|
|
34
|
+
/**
|
|
35
|
+
* Build the OS command to open a URL in the user's default browser — the Leg A webview
|
|
36
|
+
* surface (VS Code / Cursor / any no-tmux terminal). This is where AdX/IMA legitimately
|
|
37
|
+
* runs (a real DOM), and it mirrors how a Leg B video spawns its own window off-tmux.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildOpenUrl(url: string, platform?: NodeJS.Platform): SpawnSpec;
|
|
40
|
+
/** The plain-text sponsor line (reuses the shipped `buildSponsorMessage`). */
|
|
41
|
+
export declare function renderTextLine(creative: RenderableCreative): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The programmatic (Leg A) line: sanctioned disclosure + an OSC-8 clickable link to the
|
|
44
|
+
* hosted IMA player, plus the raw URL as a fallback for terminals without OSC-8. Clicking
|
|
45
|
+
* opens the player in a browser/webview, which runs the served VAST via the Google IMA SDK
|
|
46
|
+
* (the same path the screen app uses) and bills via the wrap's signed vast_event pixels.
|
|
47
|
+
* Returns null when the creative carries no playable IMA player URL.
|
|
48
|
+
*/
|
|
49
|
+
export declare function renderProgrammaticLine(creative: RenderableCreative): string | null;
|
|
50
|
+
export interface ProgrammaticStrategy {
|
|
51
|
+
strategy: 'tmux-link' | 'open-url';
|
|
52
|
+
/** 'tmux-link': the styled OSC-8 line to print in a non-modal pane. */
|
|
53
|
+
line?: string;
|
|
54
|
+
/** 'open-url': the OS command that opens the hosted IMA player in the default browser. */
|
|
55
|
+
spec?: SpawnSpec;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Decide HOW to surface a Leg A (programmatic) fill: a clickable OSC-8 link in a non-modal
|
|
59
|
+
* tmux pane when we're in tmux, else open the hosted IMA player in the default browser.
|
|
60
|
+
* The no-tmux branch is the VS Code / Cursor / plain-terminal "webview" surface — opening a
|
|
61
|
+
* browser is the only way to run AdX/IMA there (it needs a real DOM), and it mirrors how a
|
|
62
|
+
* Leg B video spawns its own window off-tmux. So a programmatic fill is ALWAYS renderable
|
|
63
|
+
* on some surface — never dropped just because there's no tmux (the bug Codex flagged).
|
|
64
|
+
* Null only when the creative carries no playable IMA player URL.
|
|
65
|
+
*/
|
|
66
|
+
export declare function planProgrammaticPlayback(creative: RenderableCreative, env: {
|
|
67
|
+
inTmux: boolean;
|
|
68
|
+
platform?: NodeJS.Platform;
|
|
69
|
+
}): ProgrammaticStrategy | null;
|
|
70
|
+
export interface VideoStrategy {
|
|
71
|
+
strategy: 'tmux-pane' | 'spawn-window';
|
|
72
|
+
spec: SpawnSpec;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Decide HOW to play a video for this surface: a non-modal tmux split pane when we're
|
|
76
|
+
* in tmux on a video-capable terminal, else a spawned mpv window.
|
|
77
|
+
*/
|
|
78
|
+
export declare function planVideoPlayback(url: string, opts: {
|
|
79
|
+
inTmux: boolean;
|
|
80
|
+
videoCapable: boolean;
|
|
81
|
+
platform?: NodeJS.Platform;
|
|
82
|
+
}): VideoStrategy;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render adapters for the `trillboards code` wrapper (pure command builders).
|
|
3
|
+
*
|
|
4
|
+
* Given the chosen creative + the surface capabilities, render it during dead-time:
|
|
5
|
+
* video → mpv in a non-modal tmux split pane where the terminal supports it,
|
|
6
|
+
* else a spawned mpv window (VS Code/Cursor/unknown)
|
|
7
|
+
* image → timg / kitten icat inline
|
|
8
|
+
* text → an OSC-8 sponsor line written to the wrapper's own output
|
|
9
|
+
*
|
|
10
|
+
* The command BUILDERS are pure (return {bin,args}) so they're unit-tested; the
|
|
11
|
+
* actual spawning lives in render-runner.ts.
|
|
12
|
+
*/
|
|
13
|
+
import { buildSponsorMessage } from './agent-hook.js';
|
|
14
|
+
import { colorize, osc8 } from './format-helpers.js';
|
|
15
|
+
/** Sanctioned, disclosed copy for the programmatic (Leg A) video link shown in dead-time. */
|
|
16
|
+
const SANCTIONED_PROGRAMMATIC_LINE = 'Sponsored — your idle agent earned you a paid impression. Tap to play the video ad:';
|
|
17
|
+
/** mpv flags that make `--vo=kitty` play smoothly (Ghostty needs shm+really-quiet). */
|
|
18
|
+
const MPV_TERMINAL_FLAGS = [
|
|
19
|
+
'--vo=kitty',
|
|
20
|
+
'--vo-kitty-use-shm=yes',
|
|
21
|
+
'--profile=sw-fast',
|
|
22
|
+
'--really-quiet',
|
|
23
|
+
'--no-input-default-bindings',
|
|
24
|
+
'--loop=no',
|
|
25
|
+
];
|
|
26
|
+
/** POSIX single-quote a string for safe interpolation into a shell command. */
|
|
27
|
+
export function shellQuote(s) {
|
|
28
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the command to play a video in a NON-MODAL tmux split pane. `-d` keeps focus
|
|
32
|
+
* (and keystrokes) on the agent's pane — the ad plays beside it without stealing
|
|
33
|
+
* input; the pane auto-closes when mpv reaches EOF. Each tmux pane is its own grid,
|
|
34
|
+
* so the agent never repaints over the video. Requires `set -g allow-passthrough on`.
|
|
35
|
+
* `-P -F '#{pane_id}'` prints the new pane id so the runner can kill it early.
|
|
36
|
+
*/
|
|
37
|
+
export function buildTmuxPaneVideo(url, sizePct = 35) {
|
|
38
|
+
const inner = `mpv ${MPV_TERMINAL_FLAGS.join(' ')} ${shellQuote(url)}`;
|
|
39
|
+
return {
|
|
40
|
+
bin: 'tmux',
|
|
41
|
+
args: ['split-window', '-d', '-P', '-F', '#{pane_id}', '-l', `${sizePct}%`, inner],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Build the command to play a video in mpv's OWN window (fallback: VS Code/Cursor/no-tmux). */
|
|
45
|
+
export function buildSpawnWindowVideo(url, platform = process.platform) {
|
|
46
|
+
if (platform === 'darwin')
|
|
47
|
+
return { bin: 'open', args: ['-na', 'mpv', '--args', url] };
|
|
48
|
+
if (platform === 'win32')
|
|
49
|
+
return { bin: 'mpv', args: [url] };
|
|
50
|
+
// Linux/other: mpv opens its own window via the default VO.
|
|
51
|
+
return { bin: 'mpv', args: [url] };
|
|
52
|
+
}
|
|
53
|
+
/** Build the inline-image command (timg auto-detects kitty/iTerm2/sixel). */
|
|
54
|
+
export function buildImagePlayback(url) {
|
|
55
|
+
// timg auto-selects the best inline-graphics protocol; -g bounds the cell box.
|
|
56
|
+
return { bin: 'timg', args: ['-g', '60x30', '--upscale', url] };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the OS command to open a URL in the user's default browser — the Leg A webview
|
|
60
|
+
* surface (VS Code / Cursor / any no-tmux terminal). This is where AdX/IMA legitimately
|
|
61
|
+
* runs (a real DOM), and it mirrors how a Leg B video spawns its own window off-tmux.
|
|
62
|
+
*/
|
|
63
|
+
export function buildOpenUrl(url, platform = process.platform) {
|
|
64
|
+
if (platform === 'darwin')
|
|
65
|
+
return { bin: 'open', args: [url] };
|
|
66
|
+
if (platform === 'win32')
|
|
67
|
+
return { bin: 'cmd', args: ['/c', 'start', '', url] };
|
|
68
|
+
// Linux/other: xdg-open routes to the registered default browser.
|
|
69
|
+
return { bin: 'xdg-open', args: [url] };
|
|
70
|
+
}
|
|
71
|
+
/** The plain-text sponsor line (reuses the shipped `buildSponsorMessage`). */
|
|
72
|
+
export function renderTextLine(creative) {
|
|
73
|
+
const ad = {
|
|
74
|
+
adId: creative.adId,
|
|
75
|
+
name: creative.name,
|
|
76
|
+
description: creative.description,
|
|
77
|
+
campaignLink: creative.campaignLink,
|
|
78
|
+
variantName: creative.variantName,
|
|
79
|
+
};
|
|
80
|
+
return buildSponsorMessage(ad);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The programmatic (Leg A) line: sanctioned disclosure + an OSC-8 clickable link to the
|
|
84
|
+
* hosted IMA player, plus the raw URL as a fallback for terminals without OSC-8. Clicking
|
|
85
|
+
* opens the player in a browser/webview, which runs the served VAST via the Google IMA SDK
|
|
86
|
+
* (the same path the screen app uses) and bills via the wrap's signed vast_event pixels.
|
|
87
|
+
* Returns null when the creative carries no playable IMA player URL.
|
|
88
|
+
*/
|
|
89
|
+
export function renderProgrammaticLine(creative) {
|
|
90
|
+
if (!creative.imaPlayerUrl)
|
|
91
|
+
return null;
|
|
92
|
+
const link = osc8(creative.imaPlayerUrl, colorize('▶ Play sponsored video', 'green'));
|
|
93
|
+
return `${colorize(SANCTIONED_PROGRAMMATIC_LINE, 'bold')}\n ${link}\n ${colorize(creative.imaPlayerUrl, 'dim')}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Decide HOW to surface a Leg A (programmatic) fill: a clickable OSC-8 link in a non-modal
|
|
97
|
+
* tmux pane when we're in tmux, else open the hosted IMA player in the default browser.
|
|
98
|
+
* The no-tmux branch is the VS Code / Cursor / plain-terminal "webview" surface — opening a
|
|
99
|
+
* browser is the only way to run AdX/IMA there (it needs a real DOM), and it mirrors how a
|
|
100
|
+
* Leg B video spawns its own window off-tmux. So a programmatic fill is ALWAYS renderable
|
|
101
|
+
* on some surface — never dropped just because there's no tmux (the bug Codex flagged).
|
|
102
|
+
* Null only when the creative carries no playable IMA player URL.
|
|
103
|
+
*/
|
|
104
|
+
export function planProgrammaticPlayback(creative, env) {
|
|
105
|
+
if (!creative.imaPlayerUrl)
|
|
106
|
+
return null;
|
|
107
|
+
if (env.inTmux) {
|
|
108
|
+
const line = renderProgrammaticLine(creative);
|
|
109
|
+
if (!line)
|
|
110
|
+
return null;
|
|
111
|
+
return { strategy: 'tmux-link', line };
|
|
112
|
+
}
|
|
113
|
+
return { strategy: 'open-url', spec: buildOpenUrl(creative.imaPlayerUrl, env.platform ?? process.platform) };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Decide HOW to play a video for this surface: a non-modal tmux split pane when we're
|
|
117
|
+
* in tmux on a video-capable terminal, else a spawned mpv window.
|
|
118
|
+
*/
|
|
119
|
+
export function planVideoPlayback(url, opts) {
|
|
120
|
+
if (opts.inTmux && opts.videoCapable) {
|
|
121
|
+
return { strategy: 'tmux-pane', spec: buildTmuxPaneVideo(url) };
|
|
122
|
+
}
|
|
123
|
+
return { strategy: 'spawn-window', spec: buildSpawnWindowVideo(url, opts.platform) };
|
|
124
|
+
}
|