@trillboards/cli 0.1.0 → 0.1.3
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 +415 -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-csi-venue-history.d.ts +15 -0
- package/dist/commands/generated/generated-csi-venue-history.js +35 -0
- package/dist/commands/generated/generated-csi-venue-occupancy.d.ts +14 -0
- package/dist/commands/generated/generated-csi-venue-occupancy.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/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 +37 -0
- package/dist/format-helpers.js +41 -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 +88 -0
- package/dist/pty-wrapper.js +287 -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 +1186 -301
- package/package.json +9 -2
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
* ── REFUSED: rendering the ad INSIDE the agent's own grid ──────────────────────
|
|
14
|
+
*
|
|
15
|
+
* There was a standing proposal to make this wrapper draw the ad in the coding
|
|
16
|
+
* agent's own terminal instead of on a side surface, by same-length byte-patching
|
|
17
|
+
* the agent binary and re-signing it with `codesign`, or by `DYLD_INSERT_LIBRARIES`
|
|
18
|
+
* interposing its write path. Both are technically achievable. Neither is being
|
|
19
|
+
* built, and the reason belongs here rather than in a tracking list, because this
|
|
20
|
+
* `onData` tee is exactly the code it would replace.
|
|
21
|
+
*
|
|
22
|
+
* 1. IT BREAKS THE THING IT INJECTS INTO. Patching a vendor's binary invalidates
|
|
23
|
+
* that vendor's code signature. Re-signing it with OUR certificate makes us the
|
|
24
|
+
* attesting party for someone else's software, and on macOS a re-signed binary
|
|
25
|
+
* loses the notarization and the entitlements the original shipped with. A
|
|
26
|
+
* DYLD interpose is the same trespass at runtime: it puts our code inside a
|
|
27
|
+
* process the user trusts with their source tree and their credentials. An ad
|
|
28
|
+
* surface is not a good enough reason to hold either of those.
|
|
29
|
+
*
|
|
30
|
+
* 2. THE FAIL-OPEN PROMISE CANNOT BE KEPT. Everything else here is safe because
|
|
31
|
+
* it is ADDITIVE — `onData` copies bytes, it never rewrites them, so a bug in
|
|
32
|
+
* ad logic cannot corrupt the agent's display. A compositor that reserves rows
|
|
33
|
+
* inside the agent's grid has to rewrite cursor and scroll-region sequences it
|
|
34
|
+
* does not own, and there is no version of that which degrades to "the agent
|
|
35
|
+
* looks normal". "Behind a flag, fail-open" describes a fallback this design
|
|
36
|
+
* does not have.
|
|
37
|
+
*
|
|
38
|
+
* The refusal rests on 1 and 2, which are properties of the technique itself.
|
|
39
|
+
*
|
|
40
|
+
* RETRACTED — "it would ship to nobody". An earlier version of this note gave a
|
|
41
|
+
* third reason: that `@trillboards/cli` was stuck at 0.1.1 and the whole Leg A
|
|
42
|
+
* surface was unpublished. That is FALSE, and it is recorded here because it is
|
|
43
|
+
* an easy thing to re-derive wrongly from `publish-cli.yml`'s skip-if-published
|
|
44
|
+
* branch. npm's `gitHead` for 0.1.1 is `01696a57`, that commit IS an ancestor of
|
|
45
|
+
* `production`, and there are ZERO commits to `trillboards-cli/` after it — so
|
|
46
|
+
* the published tarball is current and Leg A has been installable since
|
|
47
|
+
* 2026-06-21. Do not repeat the claim, and do not weigh distribution as an
|
|
48
|
+
* argument here at all: it would not save this design if it were true, and it is
|
|
49
|
+
* not true.
|
|
50
|
+
*
|
|
51
|
+
* If this is ever revisited, the prerequisite is a way to render inside another
|
|
52
|
+
* process that does not forge that process's signature and can genuinely fail
|
|
53
|
+
* open — not a better patching technique, and not a distribution milestone.
|
|
54
|
+
*/
|
|
55
|
+
import type { VirtualScreenCreative } from './ads-client.js';
|
|
56
|
+
import type { SurfaceCapabilities } from './capability.js';
|
|
57
|
+
/** True iff the optional node-pty native module loaded — i.e. `code` can ad-wrap. */
|
|
58
|
+
export declare function isPtyAvailable(): boolean;
|
|
59
|
+
export interface WrapperDeps {
|
|
60
|
+
caps: SurfaceCapabilities;
|
|
61
|
+
capabilitiesParam: string;
|
|
62
|
+
/** Fetch the best creative for our surface (returns null on no-fill). */
|
|
63
|
+
fetchCreative: (capabilitiesParam: string) => Promise<VirtualScreenCreative | null>;
|
|
64
|
+
/** Report the impression (best-effort telemetry; the wrapper never blocks on it). */
|
|
65
|
+
reportImpression: (creative: VirtualScreenCreative) => Promise<unknown>;
|
|
66
|
+
/**
|
|
67
|
+
* Fire the server-signed completion beacon when an ad finishes playing
|
|
68
|
+
* (play-and-count; the billable agent_direct signal). Best-effort, non-blocking.
|
|
69
|
+
*/
|
|
70
|
+
reportCompletion: (creative: VirtualScreenCreative) => Promise<unknown>;
|
|
71
|
+
/** Keep the virtual screen online for serving/billing. */
|
|
72
|
+
heartbeat: () => Promise<unknown>;
|
|
73
|
+
/** Minimum gap between ads (frequency cap). */
|
|
74
|
+
intervalMs: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run the agent under a PTY and serve ads into its dead-time. Resolves with the
|
|
78
|
+
* agent's exit code. Restores the terminal + tears down all timers/renders on exit.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runWrappedAgent(agent: string, agentArgs: string[], deps: WrapperDeps): Promise<number>;
|
|
81
|
+
/**
|
|
82
|
+
* Run the agent verbatim with inherited stdio (NO ad-wrapping). The graceful fallback
|
|
83
|
+
* when node-pty is unavailable — the developer's coding agent works exactly as normal;
|
|
84
|
+
* they just don't earn from `code` here (they can run `trillboards ads enable`, the text
|
|
85
|
+
* hook, which needs no native deps and works in every terminal). Resolves with the
|
|
86
|
+
* agent's exit code.
|
|
87
|
+
*/
|
|
88
|
+
export declare function runAgentPassthrough(agent: string, agentArgs: string[]): Promise<number>;
|
|
@@ -0,0 +1,287 @@
|
|
|
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
|
+
* ── REFUSED: rendering the ad INSIDE the agent's own grid ──────────────────────
|
|
14
|
+
*
|
|
15
|
+
* There was a standing proposal to make this wrapper draw the ad in the coding
|
|
16
|
+
* agent's own terminal instead of on a side surface, by same-length byte-patching
|
|
17
|
+
* the agent binary and re-signing it with `codesign`, or by `DYLD_INSERT_LIBRARIES`
|
|
18
|
+
* interposing its write path. Both are technically achievable. Neither is being
|
|
19
|
+
* built, and the reason belongs here rather than in a tracking list, because this
|
|
20
|
+
* `onData` tee is exactly the code it would replace.
|
|
21
|
+
*
|
|
22
|
+
* 1. IT BREAKS THE THING IT INJECTS INTO. Patching a vendor's binary invalidates
|
|
23
|
+
* that vendor's code signature. Re-signing it with OUR certificate makes us the
|
|
24
|
+
* attesting party for someone else's software, and on macOS a re-signed binary
|
|
25
|
+
* loses the notarization and the entitlements the original shipped with. A
|
|
26
|
+
* DYLD interpose is the same trespass at runtime: it puts our code inside a
|
|
27
|
+
* process the user trusts with their source tree and their credentials. An ad
|
|
28
|
+
* surface is not a good enough reason to hold either of those.
|
|
29
|
+
*
|
|
30
|
+
* 2. THE FAIL-OPEN PROMISE CANNOT BE KEPT. Everything else here is safe because
|
|
31
|
+
* it is ADDITIVE — `onData` copies bytes, it never rewrites them, so a bug in
|
|
32
|
+
* ad logic cannot corrupt the agent's display. A compositor that reserves rows
|
|
33
|
+
* inside the agent's grid has to rewrite cursor and scroll-region sequences it
|
|
34
|
+
* does not own, and there is no version of that which degrades to "the agent
|
|
35
|
+
* looks normal". "Behind a flag, fail-open" describes a fallback this design
|
|
36
|
+
* does not have.
|
|
37
|
+
*
|
|
38
|
+
* The refusal rests on 1 and 2, which are properties of the technique itself.
|
|
39
|
+
*
|
|
40
|
+
* RETRACTED — "it would ship to nobody". An earlier version of this note gave a
|
|
41
|
+
* third reason: that `@trillboards/cli` was stuck at 0.1.1 and the whole Leg A
|
|
42
|
+
* surface was unpublished. That is FALSE, and it is recorded here because it is
|
|
43
|
+
* an easy thing to re-derive wrongly from `publish-cli.yml`'s skip-if-published
|
|
44
|
+
* branch. npm's `gitHead` for 0.1.1 is `01696a57`, that commit IS an ancestor of
|
|
45
|
+
* `production`, and there are ZERO commits to `trillboards-cli/` after it — so
|
|
46
|
+
* the published tarball is current and Leg A has been installable since
|
|
47
|
+
* 2026-06-21. Do not repeat the claim, and do not weigh distribution as an
|
|
48
|
+
* argument here at all: it would not save this design if it were true, and it is
|
|
49
|
+
* not true.
|
|
50
|
+
*
|
|
51
|
+
* If this is ever revisited, the prerequisite is a way to render inside another
|
|
52
|
+
* process that does not forge that process's signature and can genuinely fail
|
|
53
|
+
* open — not a better patching technique, and not a distribution milestone.
|
|
54
|
+
*/
|
|
55
|
+
import { spawn as childSpawn } from 'node:child_process';
|
|
56
|
+
import { createRequire } from 'node:module';
|
|
57
|
+
import { DeadTimeDetector } from './dead-time.js';
|
|
58
|
+
import { renderCreative } from './render-runner.js';
|
|
59
|
+
// node-pty is an OPTIONAL native dependency: it ships prebuilds for darwin/win32 only —
|
|
60
|
+
// no Linux prebuild — so a plain `npm i -g @trillboards/cli` would FAIL the C++ rebuild on
|
|
61
|
+
// Linux/Docker/Codespaces/WSL without python3+make+g++. It now lives in
|
|
62
|
+
// `optionalDependencies`, so a failed build is non-fatal (the package still installs and
|
|
63
|
+
// every other command works), and we LAZY-require it here (ESM → createRequire) so merely
|
|
64
|
+
// importing this module never throws. `trillboards code` checks isPtyAvailable() and
|
|
65
|
+
// degrades to a transparent passthrough (runAgentPassthrough) when it's absent.
|
|
66
|
+
const _require = createRequire(import.meta.url);
|
|
67
|
+
let _ptySpawn;
|
|
68
|
+
function loadPtySpawn() {
|
|
69
|
+
if (_ptySpawn !== undefined)
|
|
70
|
+
return _ptySpawn;
|
|
71
|
+
try {
|
|
72
|
+
_ptySpawn = _require('node-pty').spawn;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
_ptySpawn = null;
|
|
76
|
+
}
|
|
77
|
+
return _ptySpawn;
|
|
78
|
+
}
|
|
79
|
+
/** True iff the optional node-pty native module loaded — i.e. `code` can ad-wrap. */
|
|
80
|
+
export function isPtyAvailable() {
|
|
81
|
+
return loadPtySpawn() !== null;
|
|
82
|
+
}
|
|
83
|
+
const HEARTBEAT_MS = 30_000;
|
|
84
|
+
const TICK_MS = 250;
|
|
85
|
+
/** Minimum on-screen time before a play-and-count completion is reported as billable. */
|
|
86
|
+
const MIN_PLAY_MS = 1000;
|
|
87
|
+
/**
|
|
88
|
+
* Run the agent under a PTY and serve ads into its dead-time. Resolves with the
|
|
89
|
+
* agent's exit code. Restores the terminal + tears down all timers/renders on exit.
|
|
90
|
+
*/
|
|
91
|
+
export function runWrappedAgent(agent, agentArgs, deps) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const renderCtx = { caps: deps.caps, inTmux: Boolean(process.env.TMUX), platform: process.platform };
|
|
94
|
+
let lastShownAt = 0;
|
|
95
|
+
let currentRender = null;
|
|
96
|
+
let currentCreative = null;
|
|
97
|
+
// When the ad actually became visible (render assigned) — NOT the dead-time
|
|
98
|
+
// entry. Creative fetch + render take non-trivial time; measuring play time
|
|
99
|
+
// from here avoids reporting a completion for an ad shown < MIN_PLAY_MS (codex).
|
|
100
|
+
let renderStartedAt = 0;
|
|
101
|
+
const closeRender = () => {
|
|
102
|
+
// Capture before clearing so the completion fires for the ad that was just shown.
|
|
103
|
+
const shownCreative = currentRender ? currentCreative : null;
|
|
104
|
+
const shownMs = currentRender ? Date.now() - renderStartedAt : 0;
|
|
105
|
+
if (currentRender) {
|
|
106
|
+
try {
|
|
107
|
+
currentRender.close();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
currentRender = null;
|
|
113
|
+
}
|
|
114
|
+
currentCreative = null;
|
|
115
|
+
// Play-and-count: the ad was displayed through this dead-time window and is
|
|
116
|
+
// now torn down. Fire the server-signed completion beacon (the billable
|
|
117
|
+
// agent_direct signal) iff it was on screen long enough to count as a play.
|
|
118
|
+
if (shownCreative && shownMs >= MIN_PLAY_MS) {
|
|
119
|
+
void deps.reportCompletion(shownCreative).catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const onDeadTimeEnter = () => {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
if (now - lastShownAt < deps.intervalMs)
|
|
125
|
+
return; // frequency cap
|
|
126
|
+
lastShownAt = now;
|
|
127
|
+
// Fire-and-forget: fetch + render + report must never block the agent.
|
|
128
|
+
void (async () => {
|
|
129
|
+
let creative = null;
|
|
130
|
+
try {
|
|
131
|
+
creative = await deps.fetchCreative(deps.capabilitiesParam);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!creative)
|
|
137
|
+
return;
|
|
138
|
+
let handle = null;
|
|
139
|
+
try {
|
|
140
|
+
handle = renderCreative(creative, renderCtx);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Nothing rendered (no surface for this creative) → never report an unshown ad.
|
|
146
|
+
if (!handle)
|
|
147
|
+
return;
|
|
148
|
+
currentRender = handle;
|
|
149
|
+
currentCreative = creative;
|
|
150
|
+
renderStartedAt = Date.now(); // ad is now visible — billable play clock starts here
|
|
151
|
+
try {
|
|
152
|
+
await deps.reportImpression(creative);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
};
|
|
159
|
+
const detector = new DeadTimeDetector({ onEnter: onDeadTimeEnter, onExit: closeRender }, { spinnerStartMs: 300, spinnerStopMs: 1500 });
|
|
160
|
+
const ptySpawn = loadPtySpawn();
|
|
161
|
+
if (!ptySpawn) {
|
|
162
|
+
// node-pty unavailable (e.g. a Linux install with no compiler) — run the agent
|
|
163
|
+
// verbatim, un-wrapped, so the developer is NEVER blocked. (code.ts gates on
|
|
164
|
+
// isPtyAvailable() to print guidance first; this is the belt-and-suspenders.)
|
|
165
|
+
runAgentPassthrough(agent, agentArgs).then(resolve, () => resolve(1));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const cols = process.stdout.columns || 80;
|
|
169
|
+
const rows = process.stdout.rows || 24;
|
|
170
|
+
const term = ptySpawn(agent, agentArgs, {
|
|
171
|
+
name: process.env.TERM || 'xterm-256color',
|
|
172
|
+
cols,
|
|
173
|
+
rows,
|
|
174
|
+
cwd: process.cwd(),
|
|
175
|
+
env: process.env,
|
|
176
|
+
});
|
|
177
|
+
// ── agent output → our stdout + the dead-time detector ──
|
|
178
|
+
term.onData((data) => {
|
|
179
|
+
try {
|
|
180
|
+
process.stdout.write(data);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* ignore */
|
|
184
|
+
}
|
|
185
|
+
detector.feed(data);
|
|
186
|
+
});
|
|
187
|
+
// ── our stdin (raw) → the agent; a keystroke also ends dead-time ──
|
|
188
|
+
const stdin = process.stdin;
|
|
189
|
+
if (stdin.isTTY) {
|
|
190
|
+
try {
|
|
191
|
+
stdin.setRawMode(true);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* ignore */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
stdin.resume();
|
|
198
|
+
const onStdin = (data) => {
|
|
199
|
+
try {
|
|
200
|
+
term.write(data.toString('utf8'));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* ignore */
|
|
204
|
+
}
|
|
205
|
+
detector.onUserInput();
|
|
206
|
+
};
|
|
207
|
+
stdin.on('data', onStdin);
|
|
208
|
+
// ── resize passthrough (the kernel won't propagate it to the pty) ──
|
|
209
|
+
const onResize = () => {
|
|
210
|
+
try {
|
|
211
|
+
term.resize(process.stdout.columns || cols, process.stdout.rows || rows);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
/* ignore */
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
process.stdout.on('resize', onResize);
|
|
218
|
+
const tick = setInterval(() => detector.tick(), TICK_MS);
|
|
219
|
+
const beat = setInterval(() => {
|
|
220
|
+
void deps.heartbeat().catch(() => { });
|
|
221
|
+
}, HEARTBEAT_MS);
|
|
222
|
+
void deps.heartbeat().catch(() => { }); // immediate first heartbeat → online for serving
|
|
223
|
+
let settled = false;
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
if (settled)
|
|
226
|
+
return;
|
|
227
|
+
settled = true;
|
|
228
|
+
clearInterval(tick);
|
|
229
|
+
clearInterval(beat);
|
|
230
|
+
closeRender();
|
|
231
|
+
stdin.removeListener('data', onStdin);
|
|
232
|
+
process.stdout.removeListener('resize', onResize);
|
|
233
|
+
try {
|
|
234
|
+
if (stdin.isTTY)
|
|
235
|
+
stdin.setRawMode(false);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* ignore */
|
|
239
|
+
}
|
|
240
|
+
stdin.pause();
|
|
241
|
+
};
|
|
242
|
+
term.onExit(({ exitCode }) => {
|
|
243
|
+
cleanup();
|
|
244
|
+
resolve(exitCode ?? 0);
|
|
245
|
+
});
|
|
246
|
+
// If the parent is asked to quit, take the agent down with us.
|
|
247
|
+
const onSig = () => {
|
|
248
|
+
try {
|
|
249
|
+
term.kill();
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
/* ignore */
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
process.on('SIGINT', onSig);
|
|
256
|
+
process.on('SIGTERM', onSig);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Run the agent verbatim with inherited stdio (NO ad-wrapping). The graceful fallback
|
|
261
|
+
* when node-pty is unavailable — the developer's coding agent works exactly as normal;
|
|
262
|
+
* they just don't earn from `code` here (they can run `trillboards ads enable`, the text
|
|
263
|
+
* hook, which needs no native deps and works in every terminal). Resolves with the
|
|
264
|
+
* agent's exit code.
|
|
265
|
+
*/
|
|
266
|
+
export function runAgentPassthrough(agent, agentArgs) {
|
|
267
|
+
return new Promise((resolve) => {
|
|
268
|
+
const child = childSpawn(agent, agentArgs, { stdio: 'inherit', env: process.env });
|
|
269
|
+
const onSig = () => {
|
|
270
|
+
try {
|
|
271
|
+
child.kill();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
/* ignore */
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
process.on('SIGINT', onSig);
|
|
278
|
+
process.on('SIGTERM', onSig);
|
|
279
|
+
const settle = (code) => {
|
|
280
|
+
process.removeListener('SIGINT', onSig);
|
|
281
|
+
process.removeListener('SIGTERM', onSig);
|
|
282
|
+
resolve(code);
|
|
283
|
+
};
|
|
284
|
+
child.on('exit', (code) => settle(code ?? 0));
|
|
285
|
+
child.on('error', () => settle(127)); // command-not-found etc.
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -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;
|