@trillboards/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +414 -43
- package/dist/ads-client.d.ts +146 -0
- package/dist/ads-client.js +310 -0
- package/dist/agent-hook.d.ts +64 -0
- package/dist/agent-hook.js +96 -0
- package/dist/capability.d.ts +31 -0
- package/dist/capability.js +67 -0
- package/dist/claude-settings.d.ts +64 -0
- package/dist/claude-settings.js +126 -0
- package/dist/commands/ads/disable.d.ts +10 -0
- package/dist/commands/ads/disable.js +25 -0
- package/dist/commands/ads/enable.d.ts +11 -0
- package/dist/commands/ads/enable.js +32 -0
- package/dist/commands/ads/hook.d.ts +16 -0
- package/dist/commands/ads/hook.js +90 -0
- package/dist/commands/ads/login.d.ts +21 -0
- package/dist/commands/ads/login.js +108 -0
- package/dist/commands/ads/start.d.ts +20 -0
- package/dist/commands/ads/start.js +175 -0
- package/dist/commands/code.d.ts +12 -0
- package/dist/commands/code.js +84 -0
- package/dist/commands/devices/reconcile.d.ts +10 -0
- package/dist/commands/devices/reconcile.js +94 -0
- package/dist/commands/generated/generated-device-heartbeat-get.d.ts +14 -0
- package/dist/commands/generated/generated-device-heartbeat-get.js +32 -0
- package/dist/commands/generated/generated-federated-get-latest-model-descriptor.d.ts +19 -0
- package/dist/commands/generated/generated-federated-get-latest-model-descriptor.js +47 -0
- package/dist/commands/generated/generated-federated-get-model-weights.d.ts +19 -0
- package/dist/commands/generated/generated-federated-get-model-weights.js +47 -0
- package/dist/commands/generated/generated-federated-gradient-upload.d.ts +13 -0
- package/dist/commands/generated/generated-federated-gradient-upload.js +32 -0
- package/dist/commands/generated/generated-federated-performance-upload.d.ts +13 -0
- package/dist/commands/generated/generated-federated-performance-upload.js +32 -0
- package/dist/commands/generated/generated-federated-taste-upload.d.ts +13 -0
- package/dist/commands/generated/generated-federated-taste-upload.js +32 -0
- package/dist/commands/generated/generated-post-device-heartbeat.d.ts +15 -0
- package/dist/commands/generated/generated-post-device-heartbeat.js +35 -0
- package/dist/commands/listen.js +9 -1
- package/dist/commands/logs/tail.js +10 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +34 -9
- package/dist/dead-time.d.ts +51 -0
- package/dist/dead-time.js +119 -0
- package/dist/format-helpers.d.ts +6 -0
- package/dist/format-helpers.js +10 -0
- package/dist/http.d.ts +8 -1
- package/dist/http.js +6 -1
- package/dist/machine-id.d.ts +12 -0
- package/dist/machine-id.js +22 -0
- package/dist/pty-wrapper.d.ts +46 -0
- package/dist/pty-wrapper.js +245 -0
- package/dist/render-runner.d.ts +48 -0
- package/dist/render-runner.js +151 -0
- package/dist/render.d.ts +82 -0
- package/dist/render.js +124 -0
- package/dist/user-auth.d.ts +57 -0
- package/dist/user-auth.js +110 -0
- package/oclif.manifest.json +1140 -255
- package/package.json +9 -2
|
@@ -0,0 +1,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
|
+
}
|
package/dist/commands/listen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
26
|
+
const file = configFile();
|
|
27
|
+
if (!existsSync(file))
|
|
14
28
|
return {};
|
|
15
|
-
const raw = readFileSync(
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/format-helpers.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/format-helpers.js
CHANGED
|
@@ -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
|
-
|
|
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>;
|