@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,32 @@
1
+ // AUTO-GENERATED from OpenAPI spec (federated-api) -- do not edit manually
2
+ // Regenerate with: npm run generate
3
+ import { Flags } from '@oclif/core';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { apiRequest } from '../../http.js';
6
+ import { formatOutput } from '../../output.js';
7
+ export default class Federatedperformanceupload extends BaseCommand {
8
+ static description = "Report on-device model performance metrics.";
9
+ static examples = [`$ trillboards generated federated-performance-upload`];
10
+ static args = {};
11
+ static flags = {
12
+ ...BaseCommand.baseFlags,
13
+ data: Flags.string({ description: 'JSON request body' }),
14
+ };
15
+ async run() {
16
+ const { args, flags } = await this.parse(Federatedperformanceupload);
17
+ this.setParsedFlags(flags);
18
+ const apiKey = this.getApiKey();
19
+ if (!apiKey)
20
+ this.error('Not logged in. Run: trillboards login');
21
+ let urlPath = '/v1/federated/performance';
22
+ const params = {};
23
+ const body = flags.data ? JSON.parse(flags.data) : undefined;
24
+ const result = await apiRequest('POST', urlPath, {
25
+ apiKey,
26
+ baseUrl: flags['base-url'] ?? 'https://api.trillboards.com',
27
+ params: Object.keys(params).length > 0 ? params : undefined,
28
+ body,
29
+ });
30
+ this.log(formatOutput(result, this.getFormat()));
31
+ }
32
+ }
@@ -0,0 +1,13 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export default class Federatedtasteupload extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {};
6
+ static flags: {
7
+ data: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ format: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,32 @@
1
+ // AUTO-GENERATED from OpenAPI spec (federated-api) -- do not edit manually
2
+ // Regenerate with: npm run generate
3
+ import { Flags } from '@oclif/core';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { apiRequest } from '../../http.js';
6
+ import { formatOutput } from '../../output.js';
7
+ export default class Federatedtasteupload extends BaseCommand {
8
+ static description = "Upload a screen-taste embedding for content personalization aggregation.";
9
+ static examples = [`$ trillboards generated federated-taste-upload`];
10
+ static args = {};
11
+ static flags = {
12
+ ...BaseCommand.baseFlags,
13
+ data: Flags.string({ description: 'JSON request body' }),
14
+ };
15
+ async run() {
16
+ const { args, flags } = await this.parse(Federatedtasteupload);
17
+ this.setParsedFlags(flags);
18
+ const apiKey = this.getApiKey();
19
+ if (!apiKey)
20
+ this.error('Not logged in. Run: trillboards login');
21
+ let urlPath = '/v1/federated/taste';
22
+ const params = {};
23
+ const body = flags.data ? JSON.parse(flags.data) : undefined;
24
+ const result = await apiRequest('POST', urlPath, {
25
+ apiKey,
26
+ baseUrl: flags['base-url'] ?? 'https://api.trillboards.com',
27
+ params: Object.keys(params).length > 0 ? params : undefined,
28
+ body,
29
+ });
30
+ this.log(formatOutput(result, this.getFormat()));
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export default class Postdeviceheartbeat extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ deviceId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ data: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ format: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<void>;
15
+ }
@@ -0,0 +1,35 @@
1
+ // AUTO-GENERATED from OpenAPI spec (device-api) -- do not edit manually
2
+ // Regenerate with: npm run generate
3
+ import { Args, Flags } from '@oclif/core';
4
+ import { BaseCommand } from '../../base-command.js';
5
+ import { apiRequest } from '../../http.js';
6
+ import { formatOutput } from '../../output.js';
7
+ export default class Postdeviceheartbeat extends BaseCommand {
8
+ static description = "Submit a device heartbeat.";
9
+ static examples = [`$ trillboards generated post-device-heartbeat`];
10
+ static args = {
11
+ deviceId: Args.string({ description: 'Device fingerprint or external device ID.', required: true }),
12
+ };
13
+ static flags = {
14
+ ...BaseCommand.baseFlags,
15
+ data: Flags.string({ description: 'JSON request body' }),
16
+ };
17
+ async run() {
18
+ const { args, flags } = await this.parse(Postdeviceheartbeat);
19
+ this.setParsedFlags(flags);
20
+ const apiKey = this.getApiKey();
21
+ if (!apiKey)
22
+ this.error('Not logged in. Run: trillboards login');
23
+ let urlPath = '/v1/partner/device/{deviceId}/heartbeat';
24
+ urlPath = urlPath.replace('{deviceId}', args['deviceId']);
25
+ const params = {};
26
+ const body = flags.data ? JSON.parse(flags.data) : undefined;
27
+ const result = await apiRequest('POST', urlPath, {
28
+ apiKey,
29
+ baseUrl: flags['base-url'] ?? 'https://api.trillboards.com',
30
+ params: Object.keys(params).length > 0 ? params : undefined,
31
+ body,
32
+ });
33
+ this.log(formatOutput(result, this.getFormat()));
34
+ }
35
+ }
@@ -175,7 +175,15 @@ export default class Listen extends BaseCommand {
175
175
  headers: {
176
176
  'Content-Type': 'application/json',
177
177
  'X-Trillboards-Event': envelope.event,
178
- 'X-Trillboards-Signature': signature,
178
+ // `sha256=` prefix matches the server-side verifier's expected
179
+ // format (webhooks.ts:verifySignatureHeader). Emitting the bare
180
+ // hex made the CLI's delivered payloads fail signature validation
181
+ // silently on the receiving side.
182
+ 'X-Trillboards-Signature': `sha256=${signature}`,
183
+ // Explicit send timestamp so receivers can implement replay
184
+ // protection without trusting Date parsing of the default
185
+ // `Date` header (which varies by node_fetch impl).
186
+ 'X-Trillboards-Timestamp': String(started),
179
187
  },
180
188
  method: 'POST',
181
189
  signal: controller.signal,
@@ -108,7 +108,7 @@ export default class LogsTail extends BaseCommand {
108
108
  * Parse a status filter string like "4xx,5xx,200" into lookup patterns.
109
109
  */
110
110
  static parseStatusPatterns(raw) {
111
- return raw.split(',').map((s) => s.trim()).filter(Boolean).map((token) => {
111
+ const patterns = raw.split(',').map((s) => s.trim()).filter(Boolean).map((token) => {
112
112
  const m = /^(\d)xx$/i.exec(token);
113
113
  if (m)
114
114
  return { rangeStart: Number(m[1]) * 100 };
@@ -117,6 +117,15 @@ export default class LogsTail extends BaseCommand {
117
117
  return { exact: n };
118
118
  return {};
119
119
  }).filter((p) => p.exact !== undefined || p.rangeStart !== undefined);
120
+ // Refuse to proceed on a filter string that matches nothing — previous
121
+ // silent fallback to "no filter" let typos (e.g. `--status xyz`) stream
122
+ // every event to the user and looked like a successful filter was
123
+ // applied. Fail loudly so the invocation can be fixed.
124
+ if (patterns.length === 0) {
125
+ throw new Error(`--status "${raw}" did not match any valid status pattern. ` +
126
+ `Use exact codes like 500 or ranges like 5xx, comma-separated.`);
127
+ }
128
+ return patterns;
120
129
  }
121
130
  static sleep(ms) {
122
131
  return new Promise((resolve) => {
package/dist/config.d.ts CHANGED
@@ -3,6 +3,27 @@ export interface TrillboardsConfig {
3
3
  base_url?: string;
4
4
  default_format?: 'json' | 'table' | 'yaml';
5
5
  partner_name?: string;
6
+ user_token?: string;
7
+ user_refresh_token?: string;
8
+ /** Epoch milliseconds at which `user_token` expires (access token TTL is 1h). */
9
+ user_token_expires_at?: number;
10
+ user_email?: string;
11
+ /**
12
+ * Stable per-install machine id — a generated UUID persisted here on first
13
+ * `ads start`. Deliberately NOT derived from hostname so it survives renames
14
+ * and uniquely identifies this install for idempotent virtual-screen registration.
15
+ */
16
+ machine_id?: string;
17
+ /** Registered virtual-screen Mongo id (from /v2/earner/virtual-screen/register). */
18
+ screen_id?: string;
19
+ /** Registered virtual-screen device Mongo id (from the same register call). */
20
+ device_id?: string;
21
+ /**
22
+ * Epoch ms of the last in-place sponsor line shown by the Claude Code dead-time
23
+ * hook (`ads hook`). Frequency-caps the hook so it surfaces at most once per
24
+ * interval instead of on every prompt.
25
+ */
26
+ agent_hook_last_shown_at?: number;
6
27
  }
7
28
  export declare function readConfig(): TrillboardsConfig;
8
29
  export declare function writeConfig(config: TrillboardsConfig): void;
package/dist/config.js CHANGED
@@ -1,18 +1,32 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- const CONFIG_DIR = join(homedir(), '.trillboards');
5
- const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
4
+ /**
5
+ * Config directory. Overridable via TRILLBOARDS_CONFIG_DIR so tests never touch
6
+ * the real ~/.trillboards/config.json (which holds live credentials).
7
+ */
8
+ function configDir() {
9
+ const override = process.env.TRILLBOARDS_CONFIG_DIR;
10
+ if (override && override.trim())
11
+ return override;
12
+ return join(homedir(), '.trillboards');
13
+ }
14
+ function configFile() {
15
+ return join(configDir(), 'config.json');
16
+ }
6
17
  function ensureConfigDir() {
7
- if (!existsSync(CONFIG_DIR)) {
8
- mkdirSync(CONFIG_DIR, { recursive: true });
18
+ const dir = configDir();
19
+ if (!existsSync(dir)) {
20
+ // 0o700 — owner-only, matching the sensitivity of stored tokens/keys.
21
+ mkdirSync(dir, { mode: 0o700, recursive: true });
9
22
  }
10
23
  }
11
24
  export function readConfig() {
12
25
  try {
13
- if (!existsSync(CONFIG_FILE))
26
+ const file = configFile();
27
+ if (!existsSync(file))
14
28
  return {};
15
- const raw = readFileSync(CONFIG_FILE, 'utf-8');
29
+ const raw = readFileSync(file, 'utf-8');
16
30
  return JSON.parse(raw);
17
31
  }
18
32
  catch {
@@ -21,12 +35,23 @@ export function readConfig() {
21
35
  }
22
36
  export function writeConfig(config) {
23
37
  ensureConfigDir();
24
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
38
+ const file = configFile();
39
+ // 0o600 — owner read/write only. Tokens and the partner key live here. The
40
+ // writeFileSync `mode` only applies when CREATING the file, so chmod explicitly
41
+ // afterward to also tighten a PRE-EXISTING config (e.g. one written by the partner
42
+ // `login` command before this hardening). Codex P2 on PR #7282.
43
+ writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
44
+ try {
45
+ chmodSync(file, 0o600);
46
+ }
47
+ catch {
48
+ // Best-effort: platforms without POSIX permissions (e.g. Windows) ignore chmod.
49
+ }
25
50
  }
26
51
  export function updateConfig(updates) {
27
52
  const existing = readConfig();
28
53
  writeConfig({ ...existing, ...updates });
29
54
  }
30
55
  export function getConfigPath() {
31
- return CONFIG_FILE;
56
+ return configFile();
32
57
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Dead-time detection for the `trillboards code` PTY wrapper.
3
+ *
4
+ * The wrapper tees the wrapped agent's terminal output through this detector. The
5
+ * agent's "thinking" state is signalled by an actively-redrawing spinner line
6
+ * (braille frames + "esc to interrupt"); the moment the agent starts streaming its
7
+ * answer, real content flows. So: spinner ⇒ enter dead-time, content ⇒ exit.
8
+ *
9
+ * The classifiers are pure and the state machine takes an injected clock, so the
10
+ * whole thing is unit-tested. The exact spinner signature is broad (braille +
11
+ * common glyph sets + "esc to interrupt") and tunable against real agent output.
12
+ */
13
+ /** True when a chunk is a spinner redraw (the agent is thinking, not emitting answer text). */
14
+ export declare function isSpinnerChunk(chunk: string): boolean;
15
+ /** True when a chunk carries substantial agent answer text (≈ streaming has begun). */
16
+ export declare function isContentChunk(chunk: string, minChars?: number): boolean;
17
+ export interface DeadTimeOptions {
18
+ now?: () => number;
19
+ /** A spinner must persist this long before we treat it as real dead-time (debounce flicker). */
20
+ spinnerStartMs?: number;
21
+ /** No spinner activity for this long ⇒ thinking ended (safety exit). */
22
+ spinnerStopMs?: number;
23
+ }
24
+ export interface DeadTimeCallbacks {
25
+ onEnter: () => void;
26
+ onExit: () => void;
27
+ }
28
+ /**
29
+ * Streaming state machine. Feed it the agent's output chunks (`feed`) and the
30
+ * user's keystrokes (`onUserInput`), and call `tick()` periodically; it invokes
31
+ * onEnter/onExit exactly on the dead-time boundaries. Never throws.
32
+ */
33
+ export declare class DeadTimeDetector {
34
+ private readonly now;
35
+ private readonly spinnerStartMs;
36
+ private readonly spinnerStopMs;
37
+ private readonly cb;
38
+ private inDeadTime;
39
+ private firstSpinnerAt;
40
+ private lastSpinnerAt;
41
+ constructor(cb: DeadTimeCallbacks, opts?: DeadTimeOptions);
42
+ get inDeadTimeNow(): boolean;
43
+ /** Process a chunk of the agent's terminal output. */
44
+ feed(chunk: string): void;
45
+ /** The developer typed — treat as the end of dead-time (they're back in control). */
46
+ onUserInput(): void;
47
+ /** Call periodically; exits dead-time if the spinner has been quiet (agent finished). */
48
+ tick(): void;
49
+ private enter;
50
+ private exit;
51
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Dead-time detection for the `trillboards code` PTY wrapper.
3
+ *
4
+ * The wrapper tees the wrapped agent's terminal output through this detector. The
5
+ * agent's "thinking" state is signalled by an actively-redrawing spinner line
6
+ * (braille frames + "esc to interrupt"); the moment the agent starts streaming its
7
+ * answer, real content flows. So: spinner ⇒ enter dead-time, content ⇒ exit.
8
+ *
9
+ * The classifiers are pure and the state machine takes an injected clock, so the
10
+ * whole thing is unit-tested. The exact spinner signature is broad (braille +
11
+ * common glyph sets + "esc to interrupt") and tunable against real agent output.
12
+ */
13
+ // Braille spinner range (Claude Code / many TUIs animate these) + circle spinners.
14
+ // Deliberately NO ASCII punctuation (| / \ -): those appear constantly in normal
15
+ // agent output (paths, flags, URLs, markdown bullets, code) and would misclassify
16
+ // real content as a spinner redraw. The "esc to interrupt" hint is the strong signal.
17
+ const SPINNER_GLYPHS = /[⠀-⣿◐-◓◰-◷]/;
18
+ const INTERRUPT_HINT = /esc to (?:interrupt|cancel)/i;
19
+ // Strip ANSI/OSC/cursor control so we can measure "real" printable content.
20
+ const ANSI = /\[[0-9;?]*[ -/]*[@-~]/g;
21
+ const OSC = /\][^]*?(?:|\\)/g;
22
+ const CR_REDRAW = /[\r]/g;
23
+ /** True when a chunk is a spinner redraw (the agent is thinking, not emitting answer text). */
24
+ export function isSpinnerChunk(chunk) {
25
+ if (INTERRUPT_HINT.test(chunk))
26
+ return true;
27
+ // A spinner redraw is short and carries a spinner glyph (often after a CR).
28
+ if (chunk.length <= 400 && SPINNER_GLYPHS.test(chunk))
29
+ return true;
30
+ return false;
31
+ }
32
+ /** True when a chunk carries substantial agent answer text (≈ streaming has begun). */
33
+ export function isContentChunk(chunk, minChars = 12) {
34
+ if (isSpinnerChunk(chunk))
35
+ return false; // a spinner redraw is never "content"
36
+ const stripped = chunk.replace(ANSI, '').replace(OSC, '').replace(CR_REDRAW, '').trim();
37
+ // Count letters/digits — prose, not pure punctuation/box-drawing.
38
+ const wordChars = (stripped.match(/[\p{L}\p{N}]/gu) ?? []).length;
39
+ return wordChars >= minChars;
40
+ }
41
+ /**
42
+ * Streaming state machine. Feed it the agent's output chunks (`feed`) and the
43
+ * user's keystrokes (`onUserInput`), and call `tick()` periodically; it invokes
44
+ * onEnter/onExit exactly on the dead-time boundaries. Never throws.
45
+ */
46
+ export class DeadTimeDetector {
47
+ now;
48
+ spinnerStartMs;
49
+ spinnerStopMs;
50
+ cb;
51
+ inDeadTime = false;
52
+ firstSpinnerAt = null;
53
+ lastSpinnerAt = 0;
54
+ constructor(cb, opts = {}) {
55
+ this.cb = cb;
56
+ this.now = opts.now ?? (() => Date.now());
57
+ this.spinnerStartMs = opts.spinnerStartMs ?? 250;
58
+ this.spinnerStopMs = opts.spinnerStopMs ?? 1200;
59
+ }
60
+ get inDeadTimeNow() {
61
+ return this.inDeadTime;
62
+ }
63
+ /** Process a chunk of the agent's terminal output. */
64
+ feed(chunk) {
65
+ try {
66
+ if (isSpinnerChunk(chunk)) {
67
+ const t = this.now();
68
+ if (this.firstSpinnerAt === null)
69
+ this.firstSpinnerAt = t;
70
+ this.lastSpinnerAt = t;
71
+ // Enter only once the spinner has persisted past the debounce window.
72
+ if (!this.inDeadTime && t - this.firstSpinnerAt >= this.spinnerStartMs) {
73
+ this.enter();
74
+ }
75
+ return;
76
+ }
77
+ if (isContentChunk(chunk)) {
78
+ // Real answer text began → thinking is over.
79
+ this.firstSpinnerAt = null;
80
+ if (this.inDeadTime)
81
+ this.exit();
82
+ }
83
+ }
84
+ catch {
85
+ // never let detection break the wrapped agent
86
+ }
87
+ }
88
+ /** The developer typed — treat as the end of dead-time (they're back in control). */
89
+ onUserInput() {
90
+ this.firstSpinnerAt = null;
91
+ if (this.inDeadTime)
92
+ this.exit();
93
+ }
94
+ /** Call periodically; exits dead-time if the spinner has been quiet (agent finished). */
95
+ tick() {
96
+ if (this.inDeadTime && this.now() - this.lastSpinnerAt >= this.spinnerStopMs) {
97
+ this.exit();
98
+ }
99
+ }
100
+ enter() {
101
+ this.inDeadTime = true;
102
+ try {
103
+ this.cb.onEnter();
104
+ }
105
+ catch {
106
+ /* ignore */
107
+ }
108
+ }
109
+ exit() {
110
+ this.inDeadTime = false;
111
+ this.firstSpinnerAt = null;
112
+ try {
113
+ this.cb.onExit();
114
+ }
115
+ catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ }
@@ -11,6 +11,12 @@ export declare const ansi: {
11
11
  readonly yellow: "\u001B[33m";
12
12
  };
13
13
  export declare function colorize(text: string, color: keyof typeof ansi): string;
14
+ /**
15
+ * Wrap text in an OSC-8 terminal hyperlink. Supporting terminals render `label` as a
16
+ * clickable link to `url`; others just show the label (so always print the raw URL too).
17
+ * OSC-8: ESC ] 8 ; ; <url> BEL <label> ESC ] 8 ; ; BEL
18
+ */
19
+ export declare function osc8(url: string, label: string): string;
14
20
  /**
15
21
  * Format a number as USD currency: $1,234.56
16
22
  */
@@ -16,6 +16,16 @@ export function colorize(text, color) {
16
16
  return text;
17
17
  return `${ansi[color]}${text}${ansi.reset}`;
18
18
  }
19
+ /**
20
+ * Wrap text in an OSC-8 terminal hyperlink. Supporting terminals render `label` as a
21
+ * clickable link to `url`; others just show the label (so always print the raw URL too).
22
+ * OSC-8: ESC ] 8 ; ; <url> BEL <label> ESC ] 8 ; ; BEL
23
+ */
24
+ export function osc8(url, label) {
25
+ const ESC = String.fromCharCode(27); // \x1b
26
+ const BEL = String.fromCharCode(7); // \x07 terminates the OSC sequence
27
+ return `${ESC}]8;;${url}${BEL}${label}${ESC}]8;;${BEL}`;
28
+ }
19
29
  /**
20
30
  * Format a number as USD currency: $1,234.56
21
31
  */
package/dist/http.d.ts CHANGED
@@ -5,7 +5,14 @@ export declare class TrillboardsApiError extends Error {
5
5
  static fromResponse(body: Record<string, unknown>, statusCode: number): TrillboardsApiError;
6
6
  }
7
7
  export interface ApiRequestOptions {
8
- apiKey: string;
8
+ /** Partner API key (trb_partner_*). Sent as `Authorization: Bearer <apiKey>`. */
9
+ apiKey?: string;
10
+ /**
11
+ * User JWT (the agent-ads developer identity). When set, it takes precedence
12
+ * over `apiKey` for the Authorization header. Distinct from `apiKey` because
13
+ * user-JWT routes (e.g. /v2/earner/virtual-screen/register) only accept a JWT.
14
+ */
15
+ token?: string;
9
16
  baseUrl: string;
10
17
  body?: unknown;
11
18
  params?: Record<string, string>;
package/dist/http.js CHANGED
@@ -24,11 +24,16 @@ export async function apiRequest(method, path, options) {
24
24
  }
25
25
  }
26
26
  const headers = {
27
- 'Authorization': `Bearer ${options.apiKey}`,
28
27
  'Content-Type': 'application/json',
29
28
  'User-Agent': `trillboards-cli/${VERSION}`,
30
29
  'X-Trillboards-Client-Type': 'agent',
31
30
  };
31
+ // Prefer the user JWT when present; fall back to the partner API key. Only one
32
+ // Authorization scheme is sent. Public routes pass neither.
33
+ const bearer = options.token || options.apiKey;
34
+ if (bearer) {
35
+ headers.Authorization = `Bearer ${bearer}`;
36
+ }
32
37
  const response = await fetch(url.toString(), {
33
38
  body: options.body ? JSON.stringify(options.body) : undefined,
34
39
  headers,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolve a STABLE per-install machine id.
3
+ *
4
+ * Deliberately a generated UUID persisted in config — NOT derived from hostname.
5
+ * Hostnames change (DHCP, renames, containers) and collide across machines; a
6
+ * persisted UUID uniquely and durably identifies this install, which is what the
7
+ * virtual-screen idempotency key (md5(userMongoId:machineId)) needs so the same
8
+ * install always maps to the same virtual screen.
9
+ *
10
+ * First call generates + persists; subsequent calls return the persisted value.
11
+ */
12
+ export declare function getStableMachineId(): string;
@@ -0,0 +1,22 @@
1
+ import crypto from 'node:crypto';
2
+ import { readConfig, updateConfig } from './config.js';
3
+ /**
4
+ * Resolve a STABLE per-install machine id.
5
+ *
6
+ * Deliberately a generated UUID persisted in config — NOT derived from hostname.
7
+ * Hostnames change (DHCP, renames, containers) and collide across machines; a
8
+ * persisted UUID uniquely and durably identifies this install, which is what the
9
+ * virtual-screen idempotency key (md5(userMongoId:machineId)) needs so the same
10
+ * install always maps to the same virtual screen.
11
+ *
12
+ * First call generates + persists; subsequent calls return the persisted value.
13
+ */
14
+ export function getStableMachineId() {
15
+ const cfg = readConfig();
16
+ if (cfg.machine_id && cfg.machine_id.trim()) {
17
+ return cfg.machine_id;
18
+ }
19
+ const id = crypto.randomUUID();
20
+ updateConfig({ machine_id: id });
21
+ return id;
22
+ }
@@ -0,0 +1,46 @@
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 type { VirtualScreenCreative } from './ads-client.js';
14
+ import type { SurfaceCapabilities } from './capability.js';
15
+ /** True iff the optional node-pty native module loaded — i.e. `code` can ad-wrap. */
16
+ export declare function isPtyAvailable(): boolean;
17
+ export interface WrapperDeps {
18
+ caps: SurfaceCapabilities;
19
+ capabilitiesParam: string;
20
+ /** Fetch the best creative for our surface (returns null on no-fill). */
21
+ fetchCreative: (capabilitiesParam: string) => Promise<VirtualScreenCreative | null>;
22
+ /** Report the impression (best-effort telemetry; the wrapper never blocks on it). */
23
+ reportImpression: (creative: VirtualScreenCreative) => Promise<unknown>;
24
+ /**
25
+ * Fire the server-signed completion beacon when an ad finishes playing
26
+ * (play-and-count; the billable agent_direct signal). Best-effort, non-blocking.
27
+ */
28
+ reportCompletion: (creative: VirtualScreenCreative) => Promise<unknown>;
29
+ /** Keep the virtual screen online for serving/billing. */
30
+ heartbeat: () => Promise<unknown>;
31
+ /** Minimum gap between ads (frequency cap). */
32
+ intervalMs: number;
33
+ }
34
+ /**
35
+ * Run the agent under a PTY and serve ads into its dead-time. Resolves with the
36
+ * agent's exit code. Restores the terminal + tears down all timers/renders on exit.
37
+ */
38
+ export declare function runWrappedAgent(agent: string, agentArgs: string[], deps: WrapperDeps): Promise<number>;
39
+ /**
40
+ * Run the agent verbatim with inherited stdio (NO ad-wrapping). The graceful fallback
41
+ * when node-pty is unavailable — the developer's coding agent works exactly as normal;
42
+ * they just don't earn from `code` here (they can run `trillboards ads enable`, the text
43
+ * hook, which needs no native deps and works in every terminal). Resolves with the
44
+ * agent's exit code.
45
+ */
46
+ export declare function runAgentPassthrough(agent: string, agentArgs: string[]): Promise<number>;