@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,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
+ }
@@ -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
+ }