privateer-agent 0.5.0 → 0.6.0

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.
@@ -24,7 +24,9 @@ import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import * as priv from "../src/auth/privateer.ts";
26
26
  import { makeAccountProvider } from "../src/providers/account.ts";
27
+ import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
27
28
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
29
+ import { type Palette, paletteFor } from "../src/ui/palette.ts";
28
30
 
29
31
  const VERSION: string = (() => {
30
32
  try {
@@ -34,37 +36,26 @@ const VERSION: string = (() => {
34
36
  }
35
37
  })();
36
38
 
37
- // ── palette (Privateer brand) ────────────────────────────────────────────────
38
- // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
39
- // truecolor and mangles it (the old indigo/cyan came out green). These indices are
40
- // universally supported. Navy (the logo mark) is too dark to read on a dark terminal, so
41
- // the whole banner silhouette, wordmark, frame, and accents is painted white: a clean
42
- // single-color mark, like the logo but legible on dark.
43
- const ESC = "\x1b[";
44
- const RESET = `${ESC}0m`;
45
- const BOLD = `${ESC}1m`;
46
- const c = (n: number): string => `${ESC}38;5;${n}m`;
47
- const OCEAN = c(231); // white (#ffffff) — anchor / wordmark "P"
48
- const OCEAN_LIGHT = c(231); // white (#ffffff) — wordmark, version, path
49
- const BORDER = c(231); // white (#ffffff) — the frame
50
- const DIM = `${ESC}90m`;
51
- const GREEN = `${ESC}32m`;
52
- const YELLOW = `${ESC}33m`;
39
+ // The banner paints from Pi's ACTIVE theme (paletteFor, in src/ui/palette.ts) rather
40
+ // than a fixed colour. It used to hardcode everything to white (256-color 231) "because
41
+ // navy is too dark on a dark terminal" which inverts the problem on a LIGHT terminal
42
+ // (white-on-white the whole mark, wordmark, and frame vanish). Pi auto-detects the
43
+ // terminal background and picks a light or dark theme; ctx.ui.setHeader hands our factory
44
+ // that live Theme (and ctx.ui.theme exposes it to the sign-in widget), so the banner's
45
+ // colours resolve to dark ink on a light bg and light ink on a dark bg.
53
46
 
54
47
  // The Privateer mark: our symbol — a padlock (with a keyhole) fused into an anchor,
55
48
  // "bring your own model" meets lock-and-key privacy — drawn from the app's logo. It's
56
- // rendered with terminal HALF-BLOCKS, so each text row packs TWO pixel rows: a "▀"
57
- // whose FOREGROUND paints the top pixel and BACKGROUND the bottom (one pixel "▀"/"▄"
58
- // on the default bg; none → a plain space). 256-color indices only, same reason as the
59
- // palette above. Every built line is MARK_W visible cells wide (SGR escapes don't
60
- // count), so the text column beside it stays aligned. To redraw: edit PIXELS (each char
61
- // is a PX palette key), keeping every row MARK_W long and the row COUNT even — the
62
- // builder derives the escapes from that.
49
+ // rendered with terminal HALF-BLOCKS, so each text row packs TWO pixel rows. The mark is
50
+ // a SINGLE colour (every set pixel is the accent), so a cell never needs two different
51
+ // colours: both pixels set → a full block "█", top only → "▀", bottom only "▄", none →
52
+ // a space all painted with the accent as FOREGROUND, no background cells at all. That
53
+ // makes the mark inherit the theme's ink (dark on light, light on dark) with one colour,
54
+ // and sidesteps the old bg-bleed hazard entirely. Every built line is MARK_W visible
55
+ // cells wide (SGR escapes don't count), so the text column beside it stays aligned. To
56
+ // redraw: edit PIXELS (each char is "O" ink or "." transparent), keeping every row MARK_W
57
+ // long and the row COUNT even — the builder pairs rows into half-block cells.
63
58
  const MARK_W = 12;
64
- const PX: Record<string, number | null> = {
65
- ".": null, // transparent — the frame (and the knocked-out keyhole) shows through
66
- O: 231, // white (#ffffff, top of the 256-color cube) — the silhouette, a clean single-color mark
67
- };
68
59
  // 12 wide; an EVEN number of rows so they pair cleanly into half-block cells. Two blank
69
60
  // leading rows give the lock a little headroom without dropping the whole mark too low.
70
61
  // A small padlock rides on top as the anchor's ring: a narrow rounded shackle over an
@@ -83,26 +74,27 @@ const PIXELS = [
83
74
  ".OO..OO..OO.", "..OO.OO.OO..",
84
75
  "..OOOOOOOO..", "...OOOOOO...", "....OOOO....", ".....OO.....",
85
76
  ];
86
- // Build the mark once at load. Each cell resets SGR so a background color can never
87
- // bleed into the row padding the framer adds after it.
88
- const MARK: string[] = (() => {
77
+ // Build the mark for a given palette (the accent is the ink). Cheap called once per
78
+ // header factory invocation, i.e. once per theme, not per frame. Each cell resets SGR so
79
+ // the accent can never bleed into the row padding the framer adds after it.
80
+ function buildMark(p: Palette): string[] {
89
81
  const rows: string[] = [];
90
82
  for (let r = 0; r < PIXELS.length; r += 2) {
91
83
  const top = PIXELS[r];
92
84
  const bot = PIXELS[r + 1] ?? ".".repeat(MARK_W);
93
85
  let line = "";
94
86
  for (let x = 0; x < MARK_W; x++) {
95
- const t = PX[top[x]];
96
- const bcol = PX[bot[x]];
97
- if (t == null && bcol == null) line += " ";
98
- else if (t != null && bcol != null) line += `${ESC}38;5;${t}m${ESC}48;5;${bcol}m▀${RESET}`;
99
- else if (t != null) line += `${ESC}38;5;${t}m▀${RESET}`;
100
- else line += `${ESC}38;5;${bcol}m▄${RESET}`;
87
+ const t = top[x] === "O";
88
+ const b = bot[x] === "O";
89
+ if (t && b) line += `${p.ACCENT}█${p.RESET}`;
90
+ else if (t) line += `${p.ACCENT}▀${p.RESET}`;
91
+ else if (b) line += `${p.ACCENT}▄${p.RESET}`;
92
+ else line += " ";
101
93
  }
102
94
  rows.push(line);
103
95
  }
104
96
  return rows;
105
- })();
97
+ }
106
98
 
107
99
  // Visible width = characters after stripping SGR escapes. Everything we render inside
108
100
  // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
@@ -140,16 +132,16 @@ function shortCwd(): string {
140
132
  // - signed out AND the current model bills to a Privateer account → it can't run
141
133
  // until they sign in, so say so plainly (warning)
142
134
  // - signed out on their own key → a quiet tease that /login adds more
143
- function accountLine(modelProvider?: string): string {
135
+ function accountLine(p: Palette, modelProvider?: string): string {
144
136
  const u = priv.currentUser();
145
137
  if (u) {
146
138
  const label = clean(u.email ?? (u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id));
147
- return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
139
+ return `${p.GREEN}connected${p.DIM} as ${p.RESET}${p.INK}${label}${p.RESET}`;
148
140
  }
149
141
  if (modelProvider === "privateer") {
150
- return `${YELLOW}not signed in · /login to use this model${RESET}`;
142
+ return `${p.YELLOW}not signed in · /login to use this model${p.RESET}`;
151
143
  }
152
- return `${DIM}not signed in · ${OCEAN_LIGHT}/login${DIM} to connect your account${RESET}`;
144
+ return `${p.DIM}not signed in · ${p.INK}/login${p.DIM} to connect your account${p.RESET}`;
153
145
  }
154
146
 
155
147
  // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
@@ -167,12 +159,12 @@ function isNewer(a: string, b: string): boolean {
167
159
  // The "update available" banner line, or "" when we're current / offline / unchecked.
168
160
  // Reads the cache the launcher refreshes in the background (see bin/privateer-tui) —
169
161
  // never fetches here, so the banner stays synchronous and never blocks on the network.
170
- function updateNotice(): string {
162
+ function updateNotice(p: Palette): string {
171
163
  try {
172
164
  const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
173
165
  const { latest } = JSON.parse(readFileSync(join(home, "update-check.json"), "utf8"));
174
166
  if (typeof latest === "string" && isNewer(latest, VERSION)) {
175
- return `${YELLOW}↑ v${latest} available${DIM} · run ${RESET}${OCEAN_LIGHT}privateer update${RESET}`;
167
+ return `${p.YELLOW}↑ v${latest} available${p.DIM} · run ${p.RESET}${p.INK}privateer update${p.RESET}`;
176
168
  }
177
169
  } catch {
178
170
  // no cache yet, unreadable, or malformed — show nothing.
@@ -184,16 +176,16 @@ function updateNotice(): string {
184
176
  // loaded (so the moat's "the agent knows this project" state is visible), otherwise a
185
177
  // quiet tease that /init scaffolds one. Reads the filesystem at render time, so it
186
178
  // reflects the current cwd and updates after /init (via onContextChanged → refresh).
187
- function contextLine(): string {
179
+ function contextLine(p: Palette): string {
188
180
  const files = discoverContextFiles();
189
181
  if (files.length === 0) {
190
- return `${DIM}no PRIVATEER.md · ${OCEAN_LIGHT}/init${DIM} to add project context${RESET}`;
182
+ return `${p.DIM}no PRIVATEER.md · ${p.INK}/init${p.DIM} to add project context${p.RESET}`;
191
183
  }
192
184
  // Show the nearest (deepest, wins-last) file's path; note any additional ancestors
193
185
  // with a "+N" so the header stays one line but the count isn't hidden.
194
186
  const nearest = shortPath(files[files.length - 1].path);
195
- const more = files.length > 1 ? `${DIM} +${files.length - 1}${RESET}` : "";
196
- return `${GREEN}⚓${DIM} ${RESET}${OCEAN_LIGHT}${nearest}${RESET}${more}`;
187
+ const more = files.length > 1 ? `${p.DIM} +${files.length - 1}${p.RESET}` : "";
188
+ return `${p.GREEN}⚓${p.DIM} ${p.RESET}${p.INK}${nearest}${p.RESET}${more}`;
197
189
  }
198
190
 
199
191
  // ── "What's New" — a tiny in-banner changelog ────────────────────────────────
@@ -206,11 +198,11 @@ const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
206
198
  { text: "Self-update built in —", cmd: "privateer update" },
207
199
  ];
208
200
 
209
- function whatsNewRows(): string[] {
210
- const head = `${BOLD}${OCEAN_LIGHT}✦ What's new${RESET}`;
201
+ function whatsNewRows(p: Palette): string[] {
202
+ const head = `${p.BOLD}${p.INK}✦ What's new${p.RESET}`;
211
203
  const items = WHATS_NEW.map(
212
204
  ({ text, cmd }) =>
213
- `${OCEAN}·${RESET} ${DIM}${text}${RESET}${cmd ? ` ${OCEAN_LIGHT}${cmd}${RESET}` : ""}`,
205
+ `${p.ACCENT}·${p.RESET} ${p.DIM}${text}${p.RESET}${cmd ? ` ${p.INK}${cmd}${p.RESET}` : ""}`,
214
206
  );
215
207
  return [head, ...items];
216
208
  }
@@ -220,51 +212,55 @@ function whatsNewRows(): string[] {
220
212
  // mark), so we zip by row index and pad the short side — every text-only row lands in
221
213
  // the same column as the rows beside the mark. One place owns the left gutter, so
222
214
  // spacing can't drift between the mark rows and the trailing rows.
223
- function renderBanner(width: number, modelProvider?: string): string[] {
215
+ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?: string): string[] {
224
216
  // Right column, top to bottom. The two leading blanks drop the wordmark down so it
225
217
  // sits beside the lock body (not the shackle); the rest follows in reading order.
226
218
  const text: string[] = [
227
219
  "",
228
- `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}${DIM} privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
229
- `${DIM}Chart your own course privately.${RESET}`,
220
+ `${p.BOLD}${p.ACCENT}✻ ${p.ACCENT}P${p.INK}RIVATEER${p.RESET}${p.DIM} privateer-agent ${p.INK}v${VERSION}${p.RESET}`,
221
+ `${p.DIM}Chart your own course privately.${p.RESET}`,
230
222
  "",
231
- accountLine(modelProvider),
232
- `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
233
- contextLine(),
223
+ accountLine(p, modelProvider),
224
+ `${p.INK}${shortCwd()}${p.RESET}`,
225
+ contextLine(p),
234
226
  ];
235
- const notice = updateNotice();
227
+ const notice = updateNotice(p);
236
228
  if (notice) text.push(notice);
237
229
  // A blank spacer, then the What's New block — set off below the identity lines.
238
- text.push("", ...whatsNewRows());
230
+ text.push("", ...whatsNewRows(p));
239
231
 
240
232
  // Zip the mark and the text column by row. Rows past the mark's height get a blank
241
233
  // gutter of the mark's width, so the text stays in one column throughout.
242
234
  const gap = " ";
243
- const height = Math.max(MARK.length, text.length);
235
+ const height = Math.max(mark.length, text.length);
244
236
  const rows: string[] = [];
245
237
  for (let i = 0; i < height; i++) {
246
238
  // The mark lines already carry their own per-pixel colors, so we don't wrap them.
247
- const left = i < MARK.length ? MARK[i] : " ".repeat(MARK_W);
239
+ const left = i < mark.length ? mark[i] : " ".repeat(MARK_W);
248
240
  rows.push(`${left}${gap}${text[i] ?? ""}`.trimEnd());
249
241
  }
250
242
 
251
243
  const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
252
244
  const inner = Math.min(cap, Math.max(...rows.map(vlen)));
253
245
  const bar = "─".repeat(inner + 2);
254
- const out = [`${BORDER}╭${bar}╮${RESET}`];
246
+ const out = [`${p.BORDER}╭${bar}╮${p.RESET}`];
255
247
  for (const row of rows) {
256
248
  const pad = Math.max(0, inner - vlen(row));
257
- out.push(`${BORDER}│${RESET} ${row}${" ".repeat(pad)} ${BORDER}│${RESET}`);
249
+ out.push(`${p.BORDER}│${p.RESET} ${row}${" ".repeat(pad)} ${p.BORDER}│${p.RESET}`);
258
250
  }
259
- out.push(`${BORDER}╰${bar}╯${RESET}`);
251
+ out.push(`${p.BORDER}╰${bar}╯${p.RESET}`);
260
252
  return out;
261
253
  }
262
254
 
263
255
  // A Pi header Component (setHeader factory return). Static banner; captures the model
264
- // provider so the account line reflects the picked model.
265
- function headerComponent(modelProvider?: string) {
256
+ // provider so the account line reflects the picked model, and the live theme so every
257
+ // colour tracks the terminal background (dark ink on light, light ink on dark). The
258
+ // palette and mark are resolved once here (per theme), not per frame.
259
+ function headerComponent(theme: any, modelProvider?: string) {
260
+ const p = paletteFor(theme);
261
+ const mark = buildMark(p);
266
262
  return {
267
- render: (width: number): string[] => renderBanner(width, modelProvider),
263
+ render: (width: number): string[] => renderBanner(width, p, mark, modelProvider),
268
264
  invalidate() {},
269
265
  };
270
266
  }
@@ -293,8 +289,12 @@ export default function privateerBrand(pi: any): void {
293
289
  }
294
290
  };
295
291
 
292
+ // Pi calls the factory with (tui, theme) — pass the live theme through so the banner
293
+ // paints from it. Falls back to ctx.ui.theme when a Pi build hands the factory no theme.
296
294
  const setHeader = (ctx: any) =>
297
- ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
295
+ ctx?.ui?.setHeader?.((_tui: any, theme: any) =>
296
+ headerComponent(theme ?? ctx?.ui?.theme, currentModelProvider),
297
+ );
298
298
 
299
299
  const refresh = (ctx: any) => {
300
300
  dbg(`refresh: hasUI=${!!ctx?.hasUI} hasSetHeader=${typeof ctx?.ui?.setHeader} user=${priv.currentUser()?.email ?? null}`);
@@ -355,6 +355,7 @@ export default function privateerBrand(pi: any): void {
355
355
  }
356
356
  ctx?.ui?.notify?.("Connecting to Privateer — requesting a device code…", "info");
357
357
  try {
358
+ const p = paletteFor(ctx?.ui?.theme);
358
359
  const user = await priv.runDeviceLogin({
359
360
  onCode: (code: any) => {
360
361
  const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
@@ -362,11 +363,11 @@ export default function privateerBrand(pi: any): void {
362
363
  ctx?.ui?.setWidget?.(
363
364
  "privateer-signin",
364
365
  [
365
- `${OCEAN_LIGHT}⚓ Sign in to Privateer${RESET}`,
366
- `${DIM}Approve this terminal in the Privateer app:${RESET}`,
367
- ` code ${BOLD}${OCEAN}${userCode}${RESET}`,
368
- uri ? `${DIM} or open ${RESET}${OCEAN_LIGHT}${uri}${RESET}` : "",
369
- `${DIM} waiting for approval…${RESET}`,
366
+ `${p.INK}⚓ Sign in to Privateer${p.RESET}`,
367
+ `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
368
+ ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
369
+ uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
370
+ `${p.DIM} waiting for approval…${p.RESET}`,
370
371
  ].filter(Boolean),
371
372
  { placement: "aboveEditor" },
372
373
  );
@@ -406,6 +407,47 @@ export default function privateerBrand(pi: any): void {
406
407
  );
407
408
  }
408
409
 
410
+ // Move the LIVE session onto a confidential model the instant the user signs in. A
411
+ // terminal launched with no credentials is pinned by `--model` to the keyless
412
+ // OpenRouter fallback; without this switch it stays there and the first prompt after
413
+ // sign-in dead-ends on "No API key found for openrouter". resolveSignedInModel picks
414
+ // Tinfoil GLM 5.2 (client-attested TEE) when a key is present, else the account's NEAR
415
+ // channel — private inference that works out of the box. We only override an auto-picked
416
+ // launch model, never a deliberate PRIVATEER_MODEL, and never re-switch if we're already
417
+ // on the target. The account (NEAR) credential is spawned moments AFTER sign-in fires,
418
+ // so setModel can briefly return false ("no key yet"); retry a few times so the switch
419
+ // lands as soon as the credential is ready (Tinfoil, key already in env, succeeds first
420
+ // try). Best-effort throughout — a failure just leaves the launch model in place.
421
+ async function activateSignedInModel(ctx: any): Promise<void> {
422
+ if (process.env.PRIVATEER_MODEL?.trim()) return; // deliberate override — respect it
423
+ const reg = ctx?.modelRegistry;
424
+ if (!reg?.find || typeof pi.setModel !== "function") return;
425
+ const spec = resolveSignedInModel();
426
+ const slash = spec.indexOf("/");
427
+ if (slash <= 0) return;
428
+ const provider = spec.slice(0, slash), id = spec.slice(slash + 1);
429
+ const currentSpec = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
430
+ if (currentSpec === spec) return; // already there — nothing to do
431
+ const model = reg.find(provider, id);
432
+ if (!model) { dbg(`activateSignedInModel: ${spec} not in registry`); return; }
433
+ for (let attempt = 0; attempt < 4; attempt++) {
434
+ try {
435
+ const ok = await pi.setModel(model);
436
+ if (ok !== false) {
437
+ currentModelProvider = provider;
438
+ refresh(ctx);
439
+ ctx?.ui?.notify?.(`Now using ${spec} for private inference.`, "info");
440
+ dbg(`activateSignedInModel: switched to ${spec}`);
441
+ return;
442
+ }
443
+ } catch (e) {
444
+ dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
445
+ }
446
+ await new Promise((r) => setTimeout(r, 400)); // credential still spawning — retry
447
+ }
448
+ dbg(`activateSignedInModel: gave up switching to ${spec}`);
449
+ }
450
+
409
451
  dbg("extension loaded, onSignedIn listener registering");
410
452
 
411
453
  pi.on("session_start", (_e: any, ctx: any) => {
@@ -451,6 +493,9 @@ export default function privateerBrand(pi: any): void {
451
493
  priv.onSignedIn(() => {
452
494
  dbg(`onSignedIn fired; ctxRef=${ctxRef ? "set" : "null"}`);
453
495
  refresh(ctxRef);
496
+ // Activate a confidential model in the live session so the user can prompt right
497
+ // away instead of dead-ending on the keyless launch model. See activateSignedInModel.
498
+ void activateSignedInModel(ctxRef);
454
499
  });
455
500
 
456
501
  // /init (in privateer-context) just created or changed a PRIVATEER.md — re-render the
@@ -29,6 +29,7 @@ import { agentDir } from "../src/config/paths.ts";
29
29
  import { agentVersion } from "../src/config/version.ts";
30
30
  import { SettingsManager } from "@earendil-works/pi-coding-agent";
31
31
  import * as priv from "../src/auth/privateer.ts";
32
+ import { paletteFor } from "../src/ui/palette.ts";
32
33
  import type { PermissionMode } from "../src/config/permissionMode.ts";
33
34
 
34
35
  const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
@@ -221,7 +222,6 @@ function advertiseCommands(): { name: string; description?: string }[] {
221
222
  // driven from the phone — with a reminder that `/remote-access off` stops it. We
222
223
  // keep a UI handle (captured from session_start / the command ctx) so the relay's
223
224
  // own connect/disconnect callbacks can refresh the indicator, not just the command.
224
- const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", DIM = "\x1b[2m", RESET = "\x1b[0m";
225
225
  const REMOTE_STATUS_KEY = "privateer:remote-access";
226
226
  let uiRef: any = null;
227
227
  // "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
@@ -235,10 +235,13 @@ function refreshRemoteStatus(): void {
235
235
  ui.setStatus(REMOTE_STATUS_KEY, undefined);
236
236
  return;
237
237
  }
238
+ // Paint from the active theme so the footer reads on a light terminal too (a bare
239
+ // green/yellow escape can wash out on white) — falls back to white on no theme.
240
+ const p = paletteFor(ui.theme);
238
241
  const text =
239
242
  remoteState === "connected"
240
- ? `${GREEN}⟿ remote access${RESET} ${DIM}· /remote-access off to stop${RESET}`
241
- : `${YELLOW}⟿ remote access · connecting…${RESET} ${DIM}· /remote-access off to stop${RESET}`;
243
+ ? `${p.GREEN}⟿ remote access${p.RESET} ${p.DIM}· /remote-access off to stop${p.RESET}`
244
+ : `${p.YELLOW}⟿ remote access · connecting…${p.RESET} ${p.DIM}· /remote-access off to stop${p.RESET}`;
242
245
  ui.setStatus(REMOTE_STATUS_KEY, text);
243
246
  }
244
247