@freebuild/pi 0.0.0-stage → 0.0.2

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 (4) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +103 -2
  3. package/package.json +26 -4
  4. package/src/index.ts +1081 -0
package/LICENSE ADDED
@@ -0,0 +1,64 @@
1
+ Freebuild Proprietary License
2
+ Copyright (c) 2026 Freebuild. All rights reserved.
3
+
4
+ This license applies to the Freebuild repository and the Freebuild service,
5
+ except for third-party components, which remain under their own licenses.
6
+
7
+ This software, including its source code, documentation, and associated files
8
+ (the "Software"), is proprietary and is not open source.
9
+
10
+ 1. Limited license
11
+
12
+ Subject to your compliance with the Freebuild Terms of Service
13
+ (https://freebuild.dev/terms.html), Freebuild grants you a limited,
14
+ non-exclusive, non-transferable, non-sublicensable, revocable license to
15
+ install and use the Software on devices you control, solely for your internal
16
+ personal or business purposes in connection with the Freebuild service.
17
+
18
+ 2. Restrictions
19
+
20
+ You may not:
21
+
22
+ (a) copy, redistribute, sublicense, sell, rent, lease, or otherwise make the
23
+ Software available to any third party;
24
+ (b) modify, translate, or create derivative works of the Software;
25
+ (c) reverse engineer, decompile, or disassemble the Software, except to the
26
+ extent permitted by applicable law;
27
+ (d) remove or alter any copyright, trademark, or other proprietary notices;
28
+ (e) use the Software to build, operate, or provide a competing coding-agent
29
+ product or service; or
30
+ (f) use the Software after your Freebuild service access has been terminated.
31
+
32
+ 3. Ownership
33
+
34
+ Freebuild and its licensors retain all right, title, and interest in the
35
+ Software, including all intellectual property rights. No rights are granted
36
+ except as expressly stated in this license.
37
+
38
+ 4. Trademarks
39
+
40
+ This license does not grant any right to use the Freebuild name, logo, or other
41
+ trademarks.
42
+
43
+ 5. No warranty
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
47
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
48
+
49
+ 6. Limitation of liability
50
+
51
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, FREEBUILD WILL NOT BE LIABLE FOR ANY
52
+ INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY
53
+ LOSS OF PROFITS, DATA, OR BUSINESS, ARISING OUT OF OR IN CONNECTION WITH THE
54
+ SOFTWARE.
55
+
56
+ 7. Termination
57
+
58
+ This license terminates automatically if you breach its terms. On termination,
59
+ you must stop using the Software and delete all copies.
60
+
61
+ 8. Contact
62
+
63
+ For licensing questions, contact Freebuild through
64
+ https://freebuild.dev/contact.html.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # Freebuild for Pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi extension that connects Pi to the Freebuild broker and shows the sponsor panel during sponsored runs.
4
+
5
+ This is the Pi beta.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:@freebuild/pi@latest
11
+ ```
12
+
13
+ For local development from this repository:
14
+
15
+ ```bash
16
+ pi install "$(pwd)/pi-extension"
17
+ ```
18
+
19
+ Restart Pi, then:
20
+
21
+ ```text
22
+ /freebuild-license
23
+ ```
24
+
25
+ Paste a Freebuild license key, or pass it directly:
26
+
27
+ ```text
28
+ /freebuild-license fb_...
29
+ ```
30
+
31
+ The command verifies the key against the broker, saves it to `~/.config/freebuild/credentials.json`, and selects the Freebuild model.
32
+
33
+ ## Use
34
+
35
+ ```text
36
+ /model
37
+ ```
38
+
39
+ Choose:
40
+
41
+ ```text
42
+ freebuild/deepseek-flash
43
+ ```
44
+
45
+ The extension registers the provider as `freebuild` and sends:
46
+
47
+ - `Authorization: Bearer <license key>`
48
+ - `X-Freebuild-Install: <install id>`
49
+ - `X-Freebuild-Client: pi/0.0.2`
50
+ - `X-Freebuild-Client: pi/0.0.2-headless` in print and JSON modes
51
+
52
+ ## Sponsor panel
53
+
54
+ The panel appears above the editor while a sponsored run is active. On terminals at least 80 columns wide it uses a large full-width layout with a big image and copy block. Narrow terminals fall back to the compact panel.
55
+
56
+ - `SPONSORED` for paid campaigns
57
+ - `AFFILIATE` for affiliate campaigns
58
+ - `FREEBUILD` for house inventory
59
+ - `ADVERTISE` for house advertising inventory
60
+
61
+ Print and JSON modes do not render the panel and do not record impressions. That avoids counting an ad the user could not see.
62
+
63
+ Open the sponsor with `alt+o` or:
64
+
65
+ ```text
66
+ /freebuild-sponsor
67
+ ```
68
+
69
+ The plugin records `open` and `close` events for each run and uses the tracked click endpoint when possible. Image-only campaigns always render their campaign label, the brand, and the open hint, so Pi never shows a terminal panel with only an image.
70
+
71
+ ## Commands
72
+
73
+ ```text
74
+ /freebuild-license [key] Save and verify a license
75
+ /freebuild-sponsor Open the current sponsor
76
+ /freebuild-status Show license, model, install, and broker
77
+ ```
78
+
79
+ ## Environment
80
+
81
+ ```text
82
+ FREEBUILD_CREDENTIALS Override the credentials file path
83
+ FREEBUILD_API_URL Override the web origin
84
+ FREEBUILD_BROKER_URL Override the broker URL
85
+ FREEBUILD_SPONSOR_URL Override the sponsor panel URL
86
+ FREEBUILD_LICENSE_KEY Override the stored license key
87
+ ```
88
+
89
+ ## Development
90
+
91
+ Load the extension without installing it:
92
+
93
+ ```bash
94
+ pi --no-extensions -e ./pi-extension --list-models freebuild
95
+ ```
96
+
97
+ The package has no build step. Pi loads TypeScript through jiti.
98
+
99
+ ## License
100
+
101
+ Proprietary. Copyright (c) 2026 Freebuild. All rights reserved.
102
+
103
+ This package is licensed under the Freebuild Proprietary License. See
104
+ [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@freebuild/pi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "author": "Freebuild <hello@freebuild.dev> (https://freebuild.dev)",
5
+ "description": "Freebuild sponsored model provider and sponsor panel for Pi",
6
+ "type": "module",
7
+ "license": "SEE LICENSE IN LICENSE",
8
+ "keywords": [
9
+ "pi-package",
10
+ "freebuild",
11
+ "sponsor",
12
+ "coding-agent"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./src/index.ts"
17
+ ]
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "@earendil-works/pi-tui": "*"
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,1081 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ getCapabilities,
10
+ HStack,
11
+ Image,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ wrapTextWithAnsi,
15
+ type Component,
16
+ type TUI,
17
+ } from "@earendil-works/pi-tui";
18
+
19
+ const PLUGIN_VERSION = "0.0.2";
20
+ const CLIENT_ID = `pi/${PLUGIN_VERSION}`;
21
+ const WIDGET_KEY = "freebuild-sponsor";
22
+ const DEFAULT_API_URL = "https://freebuild.dev";
23
+ const DEFAULT_BROKER_URL = "https://freebuild.dev/broker";
24
+ const MAX_IMAGE_BYTES = 3_000_000;
25
+ const REQUEST_TIMEOUT_MS = 6_000;
26
+
27
+ type Theme = ExtensionContext["ui"]["theme"];
28
+
29
+ interface Credentials {
30
+ licenseKey?: string;
31
+ installId?: string;
32
+ brokerUrl?: string;
33
+ apiUrl?: string;
34
+ }
35
+
36
+ interface PanelCampaign {
37
+ id: string;
38
+ brand: string;
39
+ label: string;
40
+ kicker: string;
41
+ title: string;
42
+ body: string;
43
+ url: string;
44
+ clickUrl: string;
45
+ eventUrl: string;
46
+ accent: string;
47
+ imageUrl: string;
48
+ imageOnly: boolean;
49
+ wordmark: string;
50
+ showWordmark: boolean;
51
+ align: "left" | "center";
52
+ }
53
+
54
+ interface ImageData {
55
+ base64: string;
56
+ mimeType: string;
57
+ }
58
+
59
+ const credentialsPath =
60
+ process.env.FREEBUILD_CREDENTIALS ||
61
+ join(homedir(), ".config", "freebuild", "credentials.json");
62
+
63
+ const credentials = readCredentials();
64
+ const apiUrl = (
65
+ process.env.FREEBUILD_API_URL ||
66
+ credentials.apiUrl ||
67
+ DEFAULT_API_URL
68
+ ).replace(/\/+$/, "");
69
+ const brokerUrl = (
70
+ process.env.FREEBUILD_BROKER_URL ||
71
+ credentials.brokerUrl ||
72
+ DEFAULT_BROKER_URL
73
+ ).replace(/\/+$/, "");
74
+ const sponsorUrl = (
75
+ process.env.FREEBUILD_SPONSOR_URL || `${apiUrl}/api/sponsor/panel`
76
+ ).replace(/\/+$/, "");
77
+
78
+ let installId = String(credentials.installId || "").trim();
79
+ if (!installId) {
80
+ installId = createId("install-");
81
+ saveCredentials({ installId });
82
+ }
83
+
84
+ let licenseKey = (
85
+ process.env.FREEBUILD_LICENSE_KEY ||
86
+ credentials.licenseKey ||
87
+ ""
88
+ ).trim();
89
+ let sessionId = createId("session-");
90
+ let runId = "";
91
+ let runActive = false;
92
+ let runToken = 0;
93
+ let openSent = false;
94
+ let panelStartedAt = 0;
95
+ let shownCampaign: PanelCampaign | undefined;
96
+
97
+ function readCredentials(): Credentials {
98
+ try {
99
+ const raw = readFileSync(credentialsPath, "utf8").replace(/^\uFEFF/, "");
100
+ const value = JSON.parse(raw) as unknown;
101
+ return value && typeof value === "object" ? (value as Credentials) : {};
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ function saveCredentials(patch: Partial<Credentials>) {
108
+ const next = { ...readCredentials(), ...patch };
109
+ try {
110
+ mkdirSync(dirname(credentialsPath), { recursive: true });
111
+ writeFileSync(credentialsPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
112
+ try {
113
+ chmodSync(credentialsPath, 0o600);
114
+ } catch {
115
+ // Windows and some filesystems do not implement chmod.
116
+ }
117
+ } catch {
118
+ // A read-only home directory must not break the current process.
119
+ }
120
+ }
121
+
122
+ function createId(prefix: string): string {
123
+ return `${prefix}${randomUUID().replace(/-/g, "").slice(0, 28)}`;
124
+ }
125
+
126
+ function normalizeSessionId(value: string): string {
127
+ const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 80);
128
+ return cleaned.length >= 6 ? cleaned : createId("session-");
129
+ }
130
+
131
+ function stringValue(value: unknown): string {
132
+ return typeof value === "string" ? value.trim() : "";
133
+ }
134
+
135
+ function normalizeLabel(value: unknown): string {
136
+ const label = stringValue(value).toUpperCase();
137
+ return ["SPONSORED", "AFFILIATE", "FREEBUILD", "ADVERTISE"].includes(label)
138
+ ? label
139
+ : "SPONSORED";
140
+ }
141
+
142
+ function normalizeCampaign(value: unknown): PanelCampaign | undefined {
143
+ if (!value || typeof value !== "object") {
144
+ return undefined;
145
+ }
146
+
147
+ const raw = value as Record<string, unknown>;
148
+ const id = stringValue(raw.id);
149
+ if (!id) {
150
+ return undefined;
151
+ }
152
+
153
+ const accent = stringValue(raw.accent).toLowerCase();
154
+ const align = stringValue(raw.align).toLowerCase();
155
+
156
+ return {
157
+ id,
158
+ brand: stringValue(raw.brand),
159
+ label: normalizeLabel(raw.label || raw.sponsor),
160
+ kicker: stringValue(raw.kicker),
161
+ title: stringValue(raw.title),
162
+ body: stringValue(raw.body),
163
+ url: stringValue(raw.url),
164
+ clickUrl: stringValue(raw.clickUrl) || stringValue(raw.url),
165
+ eventUrl: stringValue(raw.eventUrl),
166
+ accent: /^#[0-9a-f]{6}$/i.test(accent) ? accent : "#5b8cff",
167
+ imageUrl: stringValue(raw.imageUrl),
168
+ imageOnly: Boolean(raw.imageOnly),
169
+ wordmark: stringValue(raw.wordmark).toUpperCase(),
170
+ showWordmark: raw.showWordmark !== false,
171
+ align: align === "left" ? "left" : "center",
172
+ };
173
+ }
174
+
175
+ function houseFallback(): PanelCampaign {
176
+ return {
177
+ id: "house",
178
+ brand: "Freebuild",
179
+ label: "FREEBUILD",
180
+ kicker: "",
181
+ title: "Build more for less.",
182
+ body: "The sponsor panel is temporarily unavailable. Check plans or ask about sponsoring Freebuild.",
183
+ url: `${apiUrl}/terminal/pricing.html`,
184
+ clickUrl: `${apiUrl}/terminal/pricing.html`,
185
+ eventUrl: "",
186
+ accent: "#5b8cff",
187
+ imageUrl: "",
188
+ imageOnly: false,
189
+ wordmark: "FREEBUILD",
190
+ showWordmark: true,
191
+ align: "center",
192
+ };
193
+ }
194
+
195
+ function loadingPanel(): PanelCampaign {
196
+ return {
197
+ ...houseFallback(),
198
+ title: "Sponsored session",
199
+ body: "Loading the current sponsor...",
200
+ };
201
+ }
202
+
203
+ async function request(
204
+ url: string,
205
+ init: RequestInit = {},
206
+ timeoutMs = REQUEST_TIMEOUT_MS,
207
+ ): Promise<Response> {
208
+ const controller = new AbortController();
209
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
210
+ try {
211
+ return await fetch(url, { ...init, signal: controller.signal });
212
+ } finally {
213
+ clearTimeout(timer);
214
+ }
215
+ }
216
+
217
+ async function fetchJson(
218
+ url: string,
219
+ init: RequestInit = {},
220
+ timeoutMs = REQUEST_TIMEOUT_MS,
221
+ ): Promise<any> {
222
+ const response = await request(url, init, timeoutMs);
223
+ if (!response.ok) {
224
+ throw new Error(`HTTP ${response.status}`);
225
+ }
226
+ return response.json();
227
+ }
228
+
229
+ function authorizationHeaders(): Record<string, string> {
230
+ const headers: Record<string, string> = {
231
+ accept: "application/json",
232
+ "x-freebuild-install": installId,
233
+ "x-freebuild-client": CLIENT_ID,
234
+ };
235
+ if (licenseKey) {
236
+ headers.authorization = `Bearer ${licenseKey}`;
237
+ }
238
+ return headers;
239
+ }
240
+
241
+ async function fetchPanel(
242
+ run: string,
243
+ preview = false,
244
+ ): Promise<PanelCampaign | undefined> {
245
+ const params = new URLSearchParams();
246
+ if (preview) {
247
+ params.set("preview", "1");
248
+ } else if (run) {
249
+ params.set("runId", run);
250
+ }
251
+ params.set("client", CLIENT_ID);
252
+
253
+ const separator = sponsorUrl.includes("?") ? "&" : "?";
254
+ const data = await fetchJson(
255
+ `${sponsorUrl}${separator}${params.toString()}`,
256
+ { headers: authorizationHeaders() },
257
+ );
258
+ return normalizeCampaign(data?.campaign ?? data);
259
+ }
260
+
261
+ async function loadImage(campaign: PanelCampaign): Promise<ImageData | undefined> {
262
+ if (!campaign.imageUrl || !getCapabilities().images) {
263
+ return undefined;
264
+ }
265
+ if (!/^https?:\/\//i.test(campaign.imageUrl)) {
266
+ return undefined;
267
+ }
268
+
269
+ try {
270
+ const response = await request(
271
+ campaign.imageUrl,
272
+ { headers: { accept: "image/*" } },
273
+ REQUEST_TIMEOUT_MS,
274
+ );
275
+ if (!response.ok) {
276
+ return undefined;
277
+ }
278
+ const mimeType =
279
+ (response.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
280
+ if (!mimeType.startsWith("image/")) {
281
+ return undefined;
282
+ }
283
+ const buffer = Buffer.from(await response.arrayBuffer());
284
+ if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) {
285
+ return undefined;
286
+ }
287
+ return { base64: buffer.toString("base64"), mimeType };
288
+ } catch {
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ function sendEvent(
294
+ campaign: PanelCampaign,
295
+ event: "open" | "close",
296
+ durationMs: number,
297
+ eventRunId: string,
298
+ ) {
299
+ if (!licenseKey || !campaign.eventUrl || !eventRunId || campaign.id === "house") {
300
+ return;
301
+ }
302
+
303
+ void request(
304
+ campaign.eventUrl,
305
+ {
306
+ method: "POST",
307
+ keepalive: true,
308
+ headers: {
309
+ ...authorizationHeaders(),
310
+ "content-type": "application/json",
311
+ },
312
+ body: JSON.stringify({
313
+ campaignId: campaign.id,
314
+ sessionId,
315
+ runId: eventRunId,
316
+ client: CLIENT_ID,
317
+ event,
318
+ durationMs,
319
+ tokens: 0,
320
+ }),
321
+ },
322
+ REQUEST_TIMEOUT_MS,
323
+ ).catch(() => {
324
+ // Exposure tracking is best-effort and must never interrupt the agent.
325
+ });
326
+ }
327
+
328
+ function panelLabel(campaign: PanelCampaign): string {
329
+ return campaign.label || (campaign.id === "house" ? "FREEBUILD" : "SPONSORED");
330
+ }
331
+
332
+ function panelBrand(campaign: PanelCampaign): string {
333
+ return (
334
+ campaign.brand ||
335
+ (campaign.showWordmark ? campaign.wordmark : "") ||
336
+ (campaign.id === "house" ? "Freebuild" : "Sponsor")
337
+ );
338
+ }
339
+
340
+ function panelHeading(campaign: PanelCampaign): string {
341
+ const label = panelLabel(campaign);
342
+ const brand = panelBrand(campaign);
343
+ return brand && brand.toUpperCase() !== label ? `${label} ${brand}` : label;
344
+ }
345
+
346
+ function styledHeading(theme: Theme, campaign: PanelCampaign): string {
347
+ const label = panelLabel(campaign);
348
+ const brand = panelBrand(campaign);
349
+ return brand && brand.toUpperCase() !== label
350
+ ? `${theme.fg("accent", theme.bold(label))} ${theme.bold(brand)}`
351
+ : theme.fg("accent", theme.bold(label));
352
+ }
353
+
354
+ function panelTextLines(campaign: PanelCampaign, width: number): string[] {
355
+ const inner = Math.max(20, Math.min(width, 96));
356
+ const lines = [panelHeading(campaign)];
357
+
358
+ if (campaign.kicker) {
359
+ lines.push(...wrapTextWithAnsi(campaign.kicker, inner).slice(0, 1));
360
+ }
361
+ if (campaign.title) {
362
+ lines.push(...wrapTextWithAnsi(campaign.title, inner).slice(0, 2));
363
+ }
364
+ if (campaign.body) {
365
+ lines.push("");
366
+ lines.push(...wrapTextWithAnsi(campaign.body, inner).slice(0, 3));
367
+ }
368
+ lines.push("");
369
+ lines.push("alt+o or /freebuild-sponsor to open");
370
+
371
+ return lines.map((line) => truncateToWidth(line, inner));
372
+ }
373
+
374
+ class SponsorCopy implements Component {
375
+ private readonly theme: Theme;
376
+ private readonly campaign: PanelCampaign;
377
+
378
+ constructor(theme: Theme, campaign: PanelCampaign) {
379
+ this.theme = theme;
380
+ this.campaign = campaign;
381
+ }
382
+
383
+ invalidate() {}
384
+
385
+ render(width: number): string[] {
386
+ const theme = this.theme;
387
+ const lines = [styledHeading(theme, this.campaign)];
388
+
389
+ if (this.campaign.kicker) {
390
+ lines.push(
391
+ ...wrapTextWithAnsi(
392
+ theme.fg("muted", this.campaign.kicker),
393
+ width,
394
+ ).slice(0, 1),
395
+ );
396
+ }
397
+ if (this.campaign.title) {
398
+ lines.push(
399
+ ...wrapTextWithAnsi(theme.bold(this.campaign.title), width).slice(
400
+ 0,
401
+ 3,
402
+ ),
403
+ );
404
+ }
405
+ if (this.campaign.body) {
406
+ lines.push("");
407
+ lines.push(
408
+ ...wrapTextWithAnsi(
409
+ theme.fg("muted", this.campaign.body),
410
+ width,
411
+ ).slice(0, 4),
412
+ );
413
+ }
414
+
415
+ lines.push("");
416
+ lines.push(theme.fg("dim", "alt+o or /freebuild-sponsor to open"));
417
+
418
+ return lines.map((line) => truncateToWidth(line, width));
419
+ }
420
+ }
421
+
422
+ type StackItem = {
423
+ component: Component;
424
+ basis?: number;
425
+ grow?: number;
426
+ shrink?: number;
427
+ minSize?: number;
428
+ maxSize?: number;
429
+ };
430
+
431
+ export class SponsorPanel implements Component {
432
+ private readonly theme: Theme;
433
+ private readonly campaign: PanelCampaign;
434
+ private readonly image?: Image;
435
+ private readonly tui?: TUI;
436
+ private readonly copy: SponsorCopy;
437
+
438
+ constructor(
439
+ theme: Theme,
440
+ campaign: PanelCampaign,
441
+ image?: Image,
442
+ tui?: TUI,
443
+ ) {
444
+ this.theme = theme;
445
+ this.campaign = campaign;
446
+ this.image = image;
447
+ this.tui = tui;
448
+ this.copy = new SponsorCopy(theme, campaign);
449
+ }
450
+
451
+ invalidate() {
452
+ this.image?.invalidate();
453
+ this.copy.invalidate();
454
+ }
455
+
456
+ render(width: number): string[] {
457
+ const rows = this.tui?.terminal.rows ?? 24;
458
+ if (width >= 80 && rows >= 30) {
459
+ return this.renderLarge(width, rows);
460
+ }
461
+ return this.renderCompact(width);
462
+ }
463
+
464
+ private renderLarge(width: number, rows: number): string[] {
465
+ const theme = this.theme;
466
+ const inner = Math.max(40, width - 2);
467
+ const targetHeight = Math.max(14, Math.min(28, Math.floor(rows * 0.5)));
468
+ const bodyHeight = Math.max(10, targetHeight - 2);
469
+ const hasImage = Boolean(this.image);
470
+ const imageWidth = hasImage
471
+ ? Math.max(24, Math.min(56, Math.floor(inner * 0.38)))
472
+ : 0;
473
+ const copyWidth = hasImage
474
+ ? Math.max(24, inner - imageWidth - 2)
475
+ : inner;
476
+ const children: StackItem[] = [];
477
+
478
+ if (this.image) {
479
+ children.push({
480
+ component: this.image,
481
+ basis: imageWidth,
482
+ minSize: imageWidth,
483
+ maxSize: imageWidth,
484
+ shrink: 0,
485
+ });
486
+ }
487
+ children.push({
488
+ component: this.copy,
489
+ basis: copyWidth,
490
+ grow: 1,
491
+ minSize: 20,
492
+ });
493
+
494
+ const stack = new HStack(children, {
495
+ gap: hasImage ? 2 : 0,
496
+ align: "center",
497
+ });
498
+ let body = stack.render(inner);
499
+ if (body.length > bodyHeight) {
500
+ body = body.slice(0, bodyHeight);
501
+ }
502
+
503
+ const padTop = Math.max(0, Math.floor((bodyHeight - body.length) / 2));
504
+ const padBottom = Math.max(0, bodyHeight - body.length - padTop);
505
+ const divider = theme.fg("borderMuted", "\u2500".repeat(inner));
506
+
507
+ return [
508
+ divider,
509
+ ...Array<string>(padTop).fill(""),
510
+ ...body,
511
+ ...Array<string>(padBottom).fill(""),
512
+ divider,
513
+ ].map((line) => truncateToWidth(line, inner));
514
+ }
515
+
516
+ private renderCompact(width: number): string[] {
517
+ const theme = this.theme;
518
+ const inner = Math.max(20, Math.min(width - 2, 96));
519
+ const lines: string[] = [];
520
+ const border = (text: string) => theme.fg("borderMuted", text);
521
+
522
+ lines.push(border(`\u250c${"\u2500".repeat(inner)}\u2510`));
523
+ lines.push(this.row(styledHeading(theme, this.campaign), inner));
524
+
525
+ if (this.campaign.kicker) {
526
+ lines.push(
527
+ ...this.wrapped(
528
+ theme.fg("muted", this.campaign.kicker),
529
+ inner,
530
+ 1,
531
+ ),
532
+ );
533
+ }
534
+ if (this.campaign.title) {
535
+ lines.push(
536
+ ...this.wrapped(theme.bold(this.campaign.title), inner, 2),
537
+ );
538
+ }
539
+ if (this.campaign.body) {
540
+ lines.push(
541
+ ...this.wrapped(theme.fg("muted", this.campaign.body), inner, 2),
542
+ );
543
+ }
544
+
545
+ if (this.image) {
546
+ try {
547
+ const imageWidth = Math.min(width, 14);
548
+ lines.push(...this.image.render(imageWidth).slice(0, 6));
549
+ } catch {
550
+ // A terminal-level image failure leaves the text panel intact.
551
+ }
552
+ }
553
+
554
+ lines.push(
555
+ this.row(
556
+ theme.fg("dim", "alt+o or /freebuild-sponsor to open"),
557
+ inner,
558
+ ),
559
+ );
560
+ lines.push(border(`\u2514${"\u2500".repeat(inner)}\u2518`));
561
+
562
+ return lines;
563
+ }
564
+
565
+ private wrapped(text: string, width: number, limit: number): string[] {
566
+ return wrapTextWithAnsi(text, width)
567
+ .slice(0, limit)
568
+ .map((line) => this.row(line, width));
569
+ }
570
+
571
+ private row(text: string, width: number): string {
572
+ const clipped = truncateToWidth(text, width);
573
+ const padding = Math.max(0, width - visibleWidth(clipped));
574
+ return `${this.theme.fg("borderMuted", "\u2502")}${clipped}${" ".repeat(padding)}${this.theme.fg("borderMuted", "\u2502")}`;
575
+ }
576
+ }
577
+
578
+ function showPanel(
579
+ ctx: ExtensionContext,
580
+ campaign: PanelCampaign,
581
+ image?: ImageData,
582
+ ): boolean {
583
+ try {
584
+ if (ctx.mode === "rpc") {
585
+ // RPC clients receive string widget lines. Component factories and
586
+ // terminal images are ignored in this mode.
587
+ ctx.ui.setWidget(WIDGET_KEY, panelTextLines(campaign, 96), {
588
+ placement: "aboveEditor",
589
+ });
590
+ return true;
591
+ }
592
+
593
+ ctx.ui.setWidget(
594
+ WIDGET_KEY,
595
+ (tui, theme) => {
596
+ let renderedImage: Image | undefined;
597
+ if (image && getCapabilities().images) {
598
+ const maxImageWidth = Math.max(
599
+ 24,
600
+ Math.min(56, Math.floor(tui.terminal.columns * 0.35)),
601
+ );
602
+ const maxImageHeight = Math.max(
603
+ 8,
604
+ Math.min(22, Math.floor(tui.terminal.rows * 0.45)),
605
+ );
606
+ renderedImage = new Image(
607
+ image.base64,
608
+ image.mimeType,
609
+ { fallbackColor: (value) => theme.fg("dim", value) },
610
+ {
611
+ maxWidthCells: maxImageWidth,
612
+ maxHeightCells: maxImageHeight,
613
+ filename: "sponsor",
614
+ },
615
+ );
616
+ }
617
+ return new SponsorPanel(theme, campaign, renderedImage, tui);
618
+ },
619
+ { placement: "aboveEditor" },
620
+ );
621
+ return true;
622
+ } catch {
623
+ // The panel must not take down a session during a terminal transition.
624
+ return false;
625
+ }
626
+ }
627
+
628
+ function clearPanel(ctx: ExtensionContext) {
629
+ try {
630
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
631
+ } catch {
632
+ // The widget may already be gone during shutdown.
633
+ }
634
+ }
635
+
636
+ function isFreebuildModel(ctx: ExtensionContext): boolean {
637
+ return String(ctx.model?.provider || "") === "freebuild";
638
+ }
639
+
640
+ function syncStatus(ctx: ExtensionContext) {
641
+ try {
642
+ if (isFreebuildModel(ctx)) {
643
+ ctx.ui.setStatus(
644
+ "freebuild",
645
+ licenseKey ? "Freebuild sponsored" : "Freebuild: no license",
646
+ );
647
+ } else {
648
+ ctx.ui.setStatus("freebuild", undefined);
649
+ }
650
+ } catch {
651
+ // Status display is cosmetic.
652
+ }
653
+ }
654
+
655
+ async function showRunPanel(ctx: ExtensionContext, token: number) {
656
+ const currentRun = runId;
657
+ let campaign = await fetchPanel(currentRun).catch(() => undefined);
658
+ if (!campaign) {
659
+ campaign = houseFallback();
660
+ }
661
+ if (!runActive || token !== runToken) {
662
+ return;
663
+ }
664
+
665
+ // Image-only campaigns still render the campaign label, the brand, and the
666
+ // open hint. Pi never shows a terminal panel with only an image.
667
+
668
+ if (!showPanel(ctx, campaign)) {
669
+ return;
670
+ }
671
+
672
+ shownCampaign = campaign;
673
+ panelStartedAt = Date.now();
674
+
675
+ if (campaign.id !== "house" && campaign.eventUrl) {
676
+ openSent = true;
677
+ sendEvent(campaign, "open", 0, currentRun);
678
+ }
679
+
680
+ void loadImage(campaign).then((image) => {
681
+ if (
682
+ !image ||
683
+ !runActive ||
684
+ token !== runToken ||
685
+ shownCampaign?.id !== campaign.id
686
+ ) {
687
+ return;
688
+ }
689
+ showPanel(ctx, campaign, image);
690
+ });
691
+ }
692
+
693
+ function beginRun(ctx: ExtensionContext) {
694
+ if (runActive) {
695
+ return;
696
+ }
697
+ syncStatus(ctx);
698
+ if (!isFreebuildModel(ctx)) {
699
+ clearPanel(ctx);
700
+ return;
701
+ }
702
+
703
+ // Print and JSON modes cannot render a panel. Do not record an impression
704
+ // when the user never had a chance to see one.
705
+ if (!ctx.hasUI) {
706
+ return;
707
+ }
708
+
709
+ if (!licenseKey) {
710
+ ctx.ui.notify(
711
+ "Freebuild license missing. Run /freebuild-license.",
712
+ "warning",
713
+ );
714
+ return;
715
+ }
716
+
717
+ runActive = true;
718
+ const token = ++runToken;
719
+ runId = createId("run-");
720
+ openSent = false;
721
+ panelStartedAt = 0;
722
+ shownCampaign = undefined;
723
+
724
+ // Show the mandatory panel immediately, then replace it when the selected
725
+ // campaign arrives. The loading panel is house inventory and is not tracked.
726
+ showPanel(ctx, loadingPanel());
727
+ void showRunPanel(ctx, token);
728
+ }
729
+
730
+ function endRun(ctx: ExtensionContext) {
731
+ if (!runActive) {
732
+ clearPanel(ctx);
733
+ return;
734
+ }
735
+
736
+ const campaign = shownCampaign;
737
+ const closingRunId = runId;
738
+ const durationMs = panelStartedAt ? Date.now() - panelStartedAt : 0;
739
+ const shouldClose =
740
+ openSent &&
741
+ Boolean(campaign) &&
742
+ campaign!.id !== "house" &&
743
+ Boolean(campaign!.eventUrl) &&
744
+ Boolean(closingRunId);
745
+
746
+ runActive = false;
747
+ runToken++;
748
+ runId = "";
749
+ openSent = false;
750
+ panelStartedAt = 0;
751
+ shownCampaign = undefined;
752
+ clearPanel(ctx);
753
+
754
+ if (shouldClose) {
755
+ sendEvent(campaign!, "close", durationMs, closingRunId);
756
+ }
757
+ }
758
+
759
+ function registerProvider(pi: ExtensionAPI, key: string) {
760
+ if (!key) {
761
+ return;
762
+ }
763
+
764
+ pi.registerProvider("freebuild", {
765
+ name: "Freebuild",
766
+ baseUrl: `${brokerUrl}/v1`,
767
+ apiKey: key,
768
+ api: "openai-completions",
769
+ authHeader: true,
770
+ headers: {
771
+ "X-Freebuild-Install": installId,
772
+ "X-Freebuild-Client": CLIENT_ID,
773
+ },
774
+ models: [
775
+ {
776
+ id: "deepseek-flash",
777
+ name: "DeepSeek V4.1 Flash 1017",
778
+ reasoning: true,
779
+ thinkingLevelMap: {
780
+ minimal: null,
781
+ low: "low",
782
+ medium: null,
783
+ high: "high",
784
+ max: "max",
785
+ },
786
+ input: ["text", "image"],
787
+ inputLimits: {
788
+ images: {
789
+ resize: {
790
+ maxWidth: 2000,
791
+ maxHeight: 2000,
792
+ maxBytes: 4718592,
793
+ jpegQuality: 80,
794
+ },
795
+ },
796
+ },
797
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
798
+ contextWindow: 1_000_000,
799
+ maxTokens: 384_000,
800
+ compat: {
801
+ supportsStore: false,
802
+ supportsDeveloperRole: false,
803
+ supportsReasoningEffort: true,
804
+ supportsUsageInStreaming: true,
805
+ supportsFinishReason: true,
806
+ maxTokensField: "max_tokens",
807
+ requiresReasoningContentOnAssistantMessages: true,
808
+ thinkingFormat: "deepseek",
809
+ supportsStrictMode: true,
810
+ },
811
+ },
812
+ ],
813
+ });
814
+ }
815
+
816
+ async function probeLicense(
817
+ key: string,
818
+ ): Promise<{ ok: true } | { ok: false; message: string }> {
819
+ try {
820
+ const response = await request(
821
+ `${brokerUrl}/api/me/entitlement`,
822
+ {
823
+ headers: {
824
+ accept: "application/json",
825
+ authorization: `Bearer ${key}`,
826
+ "x-freebuild-install": installId,
827
+ "x-freebuild-client": CLIENT_ID,
828
+ },
829
+ },
830
+ REQUEST_TIMEOUT_MS,
831
+ );
832
+
833
+ if (response.status === 401 || response.status === 404) {
834
+ return {
835
+ ok: false,
836
+ message:
837
+ "That license key is not active. It may have been rotated, revoked, or cancelled.",
838
+ };
839
+ }
840
+ if (response.status === 403) {
841
+ return {
842
+ ok: false,
843
+ message:
844
+ "That license is not on the sponsored plan, or it is bound to another install.",
845
+ };
846
+ }
847
+ if (!response.ok) {
848
+ return {
849
+ ok: false,
850
+ message: `The Freebuild broker returned ${response.status}. Try again in a moment.`,
851
+ };
852
+ }
853
+
854
+ const data = (await response.json()) as any;
855
+ if (!data?.sponsored || data?.panel === false) {
856
+ return {
857
+ ok: false,
858
+ message:
859
+ "That license is not on the sponsored plan. The broker rejected it.",
860
+ };
861
+ }
862
+ return { ok: true };
863
+ } catch {
864
+ return {
865
+ ok: false,
866
+ message:
867
+ "Could not reach the Freebuild broker. Check your connection and try again.",
868
+ };
869
+ }
870
+ }
871
+
872
+ function openUrl(url: string): boolean {
873
+ if (!/^https?:\/\//i.test(url)) {
874
+ return false;
875
+ }
876
+
877
+ const commands: Array<[string, string[]]> =
878
+ process.platform === "win32"
879
+ ? [
880
+ ["rundll32.exe", ["url.dll,FileProtocolHandler", url]],
881
+ ["explorer.exe", [url]],
882
+ ["cmd.exe", ["/d", "/s", "/c", "start", "", url]],
883
+ ]
884
+ : process.platform === "darwin"
885
+ ? [["open", [url]]]
886
+ : [
887
+ ["xdg-open", [url]],
888
+ ["gio", ["open", url]],
889
+ ];
890
+
891
+ const attempt = (index: number): boolean => {
892
+ const command = commands[index];
893
+ if (!command) {
894
+ return false;
895
+ }
896
+ try {
897
+ const child = spawn(command[0], command[1], {
898
+ detached: true,
899
+ stdio: "ignore",
900
+ });
901
+ child.once("error", () => {
902
+ if (index + 1 < commands.length) {
903
+ attempt(index + 1);
904
+ }
905
+ });
906
+ child.unref();
907
+ return true;
908
+ } catch {
909
+ return index + 1 < commands.length ? attempt(index + 1) : false;
910
+ }
911
+ };
912
+
913
+ return attempt(0);
914
+ }
915
+
916
+ async function openSponsor(ctx: ExtensionContext) {
917
+ if (!licenseKey) {
918
+ ctx.ui.notify("Run /freebuild-license first.", "warning");
919
+ return;
920
+ }
921
+
922
+ let campaign = shownCampaign;
923
+ if (!campaign) {
924
+ campaign = await fetchPanel("", true).catch(() => undefined);
925
+ }
926
+ campaign ||= houseFallback();
927
+
928
+ const target = campaign.url || campaign.clickUrl;
929
+ if (!target) {
930
+ ctx.ui.notify("No sponsor link is available.", "warning");
931
+ return;
932
+ }
933
+
934
+ const activeRun = runId;
935
+ if (!activeRun || campaign.id === "house" || !campaign.clickUrl) {
936
+ if (!openUrl(target)) {
937
+ ctx.ui.notify(`Sponsor: ${target}`, "info");
938
+ }
939
+ return;
940
+ }
941
+
942
+ try {
943
+ const url = new URL(campaign.clickUrl);
944
+ url.searchParams.set("sid", sessionId);
945
+ url.searchParams.set("iid", installId);
946
+ url.searchParams.set("rid", activeRun);
947
+ url.searchParams.set("format", "json");
948
+
949
+ const data = await fetchJson(url.toString(), {
950
+ headers: authorizationHeaders(),
951
+ keepalive: true,
952
+ });
953
+ const destination = String(data?.url || "");
954
+ if (!destination || !openUrl(destination)) {
955
+ throw new Error("missing click destination");
956
+ }
957
+ } catch {
958
+ if (!openUrl(target)) {
959
+ ctx.ui.notify(`Sponsor: ${target}`, "info");
960
+ }
961
+ }
962
+ }
963
+
964
+ export default function (pi: ExtensionAPI) {
965
+ if (licenseKey) {
966
+ registerProvider(pi, licenseKey);
967
+ }
968
+
969
+ pi.on("before_provider_headers", (event, ctx) => {
970
+ const installKey = Object.keys(event.headers).find(
971
+ (key) => key.toLowerCase() === "x-freebuild-install",
972
+ );
973
+ if (!installKey) {
974
+ return;
975
+ }
976
+
977
+ // Mark headless runs separately. The abuse report can then ignore
978
+ // usage that never had a panel to show.
979
+ const clientKey = Object.keys(event.headers).find(
980
+ (key) => key.toLowerCase() === "x-freebuild-client",
981
+ );
982
+ if (clientKey) {
983
+ event.headers[clientKey] = null;
984
+ }
985
+ event.headers["x-freebuild-client"] =
986
+ ctx.mode === "tui" || ctx.mode === "rpc"
987
+ ? CLIENT_ID
988
+ : `${CLIENT_ID}-headless`;
989
+ });
990
+
991
+ pi.on("session_start", (_event, ctx) => {
992
+ sessionId = normalizeSessionId(ctx.sessionManager.getSessionId());
993
+ syncStatus(ctx);
994
+ });
995
+
996
+ pi.on("agent_start", (_event, ctx) => {
997
+ beginRun(ctx);
998
+ });
999
+
1000
+ pi.on("agent_settled", (_event, ctx) => {
1001
+ endRun(ctx);
1002
+ });
1003
+
1004
+ pi.on("model_select", (_event, ctx) => {
1005
+ syncStatus(ctx);
1006
+ if (!isFreebuildModel(ctx)) {
1007
+ endRun(ctx);
1008
+ }
1009
+ });
1010
+
1011
+ pi.on("session_shutdown", (_event, ctx) => {
1012
+ endRun(ctx);
1013
+ });
1014
+
1015
+ pi.registerCommand("freebuild-license", {
1016
+ description: "Save and verify a Freebuild license key",
1017
+ handler: async (args, ctx) => {
1018
+ const entered =
1019
+ args.trim() ||
1020
+ (await ctx.ui.input(
1021
+ "Freebuild license key",
1022
+ "fb_...",
1023
+ )) ||
1024
+ "";
1025
+ const key = entered.trim();
1026
+ if (!key) {
1027
+ return;
1028
+ }
1029
+
1030
+ const probe = await probeLicense(key);
1031
+ if (!probe.ok) {
1032
+ ctx.ui.notify(probe.message, "error");
1033
+ return;
1034
+ }
1035
+
1036
+ licenseKey = key;
1037
+ saveCredentials({ licenseKey: key, installId, brokerUrl, apiUrl });
1038
+ registerProvider(pi, key);
1039
+
1040
+ const model = ctx.modelRegistry.find("freebuild", "deepseek-flash");
1041
+ const selected = model ? await pi.setModel(model) : false;
1042
+ ctx.ui.notify(
1043
+ selected
1044
+ ? "Freebuild activated. Model set to DeepSeek V4.1 Flash 1017."
1045
+ : "Freebuild activated. Run /model and choose Freebuild.",
1046
+ "info",
1047
+ );
1048
+ syncStatus(ctx);
1049
+ },
1050
+ });
1051
+
1052
+ pi.registerCommand("freebuild-sponsor", {
1053
+ description: "Open the current Freebuild sponsor",
1054
+ handler: async (_args, ctx) => {
1055
+ await openSponsor(ctx);
1056
+ },
1057
+ });
1058
+
1059
+ pi.registerCommand("freebuild-status", {
1060
+ description: "Show Freebuild license and sponsor status",
1061
+ handler: async (_args, ctx) => {
1062
+ const model = ctx.model
1063
+ ? `${ctx.model.provider}/${ctx.model.id}`
1064
+ : "none";
1065
+ const maskedInstall = installId
1066
+ ? `${installId.slice(0, 12)}...`
1067
+ : "missing";
1068
+ ctx.ui.notify(
1069
+ `Freebuild: ${licenseKey ? "license saved" : "no license"} | model ${model} | install ${maskedInstall} | broker ${brokerUrl}`,
1070
+ "info",
1071
+ );
1072
+ },
1073
+ });
1074
+
1075
+ pi.registerShortcut("alt+o", {
1076
+ description: "Open the Freebuild sponsor",
1077
+ handler: async (ctx) => {
1078
+ await openSponsor(ctx);
1079
+ },
1080
+ });
1081
+ }