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.
@@ -0,0 +1,429 @@
1
+ // The Privateer `/models` picker — a searchable model selector that puts the
2
+ // PRIVACY POSTURE of every model on screen. Pi's built-in `/model` picker (which
3
+ // this shadows via the redirect patch in patches/@earendil-works+pi-coding-agent)
4
+ // shows only `id [provider]`; it has no hook for a privacy shield and can't be
5
+ // overridden from an extension (its command name is a reserved builtin, dispatched
6
+ // before extension commands). So we ship our OWN picker through `ctx.ui.custom`,
7
+ // which renders a full pi-tui component: fuzzy search + a per-row shield (⛉ TEE /
8
+ // ◈ ZDR / · standard) + tier grouping + a legend, ranked strongest-privacy-first.
9
+ //
10
+ // Honest-labeling contract (pi-privacy posture/tiers.ts): a TEE row is only a
11
+ // *claim* (tee-unverified, yellow) until a live attestation confirms it
12
+ // (tee-verified, green). The picker seeds each row with the server's baseline tier
13
+ // (GET /api/models `privacy.tier`, via account.ts) and then attests the TEE-capable
14
+ // rows in the background, upgrading the shield in place. ZDR-enforced rows are never
15
+ // downgraded by attestation — we only attest rows that claim a TEE.
16
+
17
+ import {
18
+ Container,
19
+ fuzzyFilter,
20
+ getKeybindings,
21
+ Input,
22
+ Spacer,
23
+ Text,
24
+ } from "@earendil-works/pi-tui";
25
+ import { TIERS, tierRank, type PrivacyTier } from "pi-privacy";
26
+ import { verifyModelPosture } from "pi-privacy";
27
+ import {
28
+ accountBaselineTier,
29
+ accountCatalogLoaded,
30
+ accountPosture,
31
+ fetchAccountCatalog,
32
+ } from "../src/providers/account.ts";
33
+
34
+ // A minimal view of Pi's theme (passed to the ui.custom factory) — enough to color
35
+ // text without pulling Pi's internal theme types into an extension.
36
+ interface ThemeLike {
37
+ fg(color: string, text: string): string;
38
+ bold(text: string): string;
39
+ }
40
+
41
+ // A pi-tui TUI handle (the first arg the ui.custom factory receives). We only use
42
+ // requestRender; keep the surface tiny so this doesn't couple to Pi internals.
43
+ interface TuiLike {
44
+ requestRender(): void;
45
+ }
46
+
47
+ interface ModelLike {
48
+ provider: string;
49
+ id: string;
50
+ name?: string;
51
+ }
52
+
53
+ interface Row {
54
+ provider: string;
55
+ id: string;
56
+ name: string;
57
+ model: ModelLike;
58
+ tier: PrivacyTier;
59
+ // true once a live attestation has resolved this row (so we don't re-attest).
60
+ attested?: boolean;
61
+ }
62
+
63
+ // posture bucket → theme color name (Pi's palette) for the shield glyph.
64
+ const POSTURE_COLOR: Record<string, string> = {
65
+ green: "success",
66
+ yellow: "warning",
67
+ red: "error",
68
+ neutral: "muted",
69
+ };
70
+
71
+ // The shield/marker glyph per tier family. TEE → shield; ZDR → diamond; local →
72
+ // house; standard → dot. Color comes from the tier's posture bucket, so verified
73
+ // (green) and merely-claimed (yellow) read differently at a glance — the same
74
+ // distinction the status-bar badge (privateer-posture.ts) draws.
75
+ function glyphFor(tier: PrivacyTier): string {
76
+ if (tier === "tee-verified" || tier === "tee-unverified") return "⛉";
77
+ if (tier === "local") return "⌂";
78
+ if (tier === "zdr-enforced" || tier === "zdr-policy") return "◈";
79
+ return "·";
80
+ }
81
+
82
+ function shield(theme: ThemeLike, tier: PrivacyTier): string {
83
+ const info = TIERS[tier];
84
+ const color = POSTURE_COLOR[info.posture] ?? "muted";
85
+ return theme.fg(color, glyphFor(tier));
86
+ }
87
+
88
+ // Server/prefix baseline tier for a provider+model, before any live attestation.
89
+ function baselineTier(provider: string, id: string): PrivacyTier {
90
+ if (provider === "privateer") {
91
+ return (
92
+ accountBaselineTier(id) ??
93
+ (id.startsWith("near/") || id.startsWith("tinfoil/") ? "tee-unverified" : "standard")
94
+ );
95
+ }
96
+ if (provider === "tinfoil" || provider === "nearai") return "tee-unverified";
97
+ if (provider === "ollama") return "local";
98
+ return "standard";
99
+ }
100
+
101
+ // A row claims a TEE and is therefore worth attesting live (to go green — or to
102
+ // honestly drop to standard if the attestation fails). ZDR/standard rows are left
103
+ // on their server baseline; attesting them would only ever weaken an honest label.
104
+ function isTeeCandidate(row: Row): boolean {
105
+ if (row.tier !== "tee-verified" && row.tier !== "tee-unverified") return false;
106
+ if (row.provider === "privateer") return row.id.startsWith("near/") || row.id.startsWith("tinfoil/");
107
+ return row.provider === "tinfoil" || row.provider === "nearai";
108
+ }
109
+
110
+ // Run the live attestation for one TEE-candidate row. privateer/* goes through the
111
+ // account server-proxy path (accountPosture); other providers use pi-privacy's
112
+ // direct client attestation. Returns undefined on any failure (keep the baseline).
113
+ async function attestRow(row: Row): Promise<PrivacyTier | undefined> {
114
+ try {
115
+ if (row.provider === "privateer") {
116
+ const res = await accountPosture(row.id);
117
+ return res.tier;
118
+ }
119
+ const apiKey =
120
+ row.provider === "nearai"
121
+ ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY
122
+ : undefined;
123
+ const res = await verifyModelPosture(row.provider, row.id, { apiKey });
124
+ return res.tier;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ // The search text a row is matched against — id, provider, name, and the tier label
131
+ // so a user can type "tee" or "zdr" to filter by posture.
132
+ function searchText(row: Row): string {
133
+ return `${row.provider} ${row.provider}/${row.id} ${row.id} ${row.name} ${TIERS[row.tier].label}`;
134
+ }
135
+
136
+ // Strongest-privacy-first, then provider, then id — so the safest options surface at
137
+ // the top of an unfiltered list.
138
+ function sortRows(rows: Row[]): Row[] {
139
+ return [...rows].sort((a, b) => {
140
+ const r = tierRank(a.tier) - tierRank(b.tier);
141
+ if (r !== 0) return r;
142
+ if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
143
+ return a.id.localeCompare(b.id);
144
+ });
145
+ }
146
+
147
+ const MAX_VISIBLE = 12;
148
+
149
+ class ModelsPicker extends Container {
150
+ private tui: TuiLike;
151
+ private theme: ThemeLike;
152
+ private rows: Row[];
153
+ private filtered: Row[];
154
+ private selectedIndex = 0;
155
+ private searchInput: Input;
156
+ private listContainer: Container;
157
+ private legendText: Text;
158
+ private blurbText: Text;
159
+ private currentId: string | undefined;
160
+ private onSelect: (row: Row) => void;
161
+ private onCancel: () => void;
162
+ private _focused = false;
163
+
164
+ constructor(opts: {
165
+ tui: TuiLike;
166
+ theme: ThemeLike;
167
+ rows: Row[];
168
+ current: ModelLike | undefined;
169
+ onSelect: (row: Row) => void;
170
+ onCancel: () => void;
171
+ }) {
172
+ super();
173
+ this.tui = opts.tui;
174
+ this.theme = opts.theme;
175
+ this.rows = sortRows(opts.rows);
176
+ this.filtered = this.rows;
177
+ this.currentId = opts.current ? `${opts.current.provider}/${opts.current.id}` : undefined;
178
+ this.onSelect = opts.onSelect;
179
+ this.onCancel = opts.onCancel;
180
+
181
+ const t = this.theme;
182
+ this.addChild(new Text(t.fg("accent", t.bold("Select a model")), 1, 0));
183
+ this.legendText = new Text(this.legend(), 1, 0);
184
+ this.addChild(this.legendText);
185
+ this.addChild(new Spacer(1));
186
+
187
+ this.searchInput = new Input();
188
+ this.searchInput.onSubmit = () => {
189
+ const row = this.filtered[this.selectedIndex];
190
+ if (row) this.onSelect(row);
191
+ };
192
+ this.addChild(this.searchInput);
193
+ this.addChild(new Spacer(1));
194
+
195
+ this.listContainer = new Container();
196
+ this.addChild(this.listContainer);
197
+ this.addChild(new Spacer(1));
198
+
199
+ this.blurbText = new Text("", 1, 0);
200
+ this.addChild(this.blurbText);
201
+ this.addChild(
202
+ new Text(
203
+ t.fg("muted", "↑↓ navigate ⏎ select type to search esc cancel"),
204
+ 1,
205
+ 0,
206
+ ),
207
+ );
208
+
209
+ // Keep selection on the current model if it's in the list.
210
+ const cur = this.rows.findIndex((r) => `${r.provider}/${r.id}` === this.currentId);
211
+ if (cur >= 0) this.selectedIndex = cur;
212
+
213
+ this.updateList();
214
+ void this.attestTeeRows();
215
+ }
216
+
217
+ get focused(): boolean {
218
+ return this._focused;
219
+ }
220
+ set focused(v: boolean) {
221
+ this._focused = v;
222
+ this.searchInput.focused = v;
223
+ }
224
+
225
+ private legend(): string {
226
+ const t = this.theme;
227
+ return (
228
+ `${shield(t, "tee-verified")} ${t.fg("muted", "TEE")} ` +
229
+ `${shield(t, "zdr-enforced")} ${t.fg("muted", "ZDR")} ` +
230
+ `${shield(t, "standard")} ${t.fg("muted", "Standard")} ` +
231
+ t.fg("muted", "— green = verified/enforced, yellow = claimed")
232
+ );
233
+ }
234
+
235
+ // Fire attestation for every TEE-candidate row in parallel; upgrade each shield in
236
+ // place as its result lands. Cheap in practice (the enabled catalog is mostly
237
+ // ZDR/standard), and never blocks the picker — the baseline renders immediately.
238
+ private async attestTeeRows(): Promise<void> {
239
+ const candidates = this.rows.filter((r) => isTeeCandidate(r) && !r.attested);
240
+ if (!candidates.length) return;
241
+ await Promise.allSettled(
242
+ candidates.map(async (row) => {
243
+ const tier = await attestRow(row);
244
+ row.attested = true;
245
+ if (tier && tier !== row.tier) {
246
+ row.tier = tier;
247
+ }
248
+ }),
249
+ );
250
+ // Re-sort (a newly-verified TEE may move up) and re-render once, preserving the
251
+ // highlighted model across the reshuffle.
252
+ const selected = this.filtered[this.selectedIndex];
253
+ this.rows = sortRows(this.rows);
254
+ this.applyFilter(this.searchInput.getValue(), selected);
255
+ this.legendText.setText(this.legend());
256
+ this.tui.requestRender();
257
+ }
258
+
259
+ private applyFilter(query: string, keep?: Row): void {
260
+ this.filtered = query
261
+ ? fuzzyFilter(this.rows, query, (r: Row) => searchText(r))
262
+ : this.rows;
263
+ if (keep) {
264
+ const i = this.filtered.findIndex((r) => r.provider === keep.provider && r.id === keep.id);
265
+ this.selectedIndex = i >= 0 ? i : 0;
266
+ } else {
267
+ this.selectedIndex = 0;
268
+ }
269
+ this.updateList();
270
+ }
271
+
272
+ private updateList(): void {
273
+ const t = this.theme;
274
+ this.listContainer.clear();
275
+ if (this.filtered.length === 0) {
276
+ this.listContainer.addChild(new Text(t.fg("muted", " No matching models"), 1, 0));
277
+ this.blurbText.setText("");
278
+ return;
279
+ }
280
+ if (this.selectedIndex >= this.filtered.length) this.selectedIndex = this.filtered.length - 1;
281
+
282
+ const start = Math.max(
283
+ 0,
284
+ Math.min(this.selectedIndex - Math.floor(MAX_VISIBLE / 2), this.filtered.length - MAX_VISIBLE),
285
+ );
286
+ const end = Math.min(start + MAX_VISIBLE, this.filtered.length);
287
+ for (let i = start; i < end; i++) {
288
+ const row = this.filtered[i];
289
+ const isSel = i === this.selectedIndex;
290
+ const isCur = `${row.provider}/${row.id}` === this.currentId;
291
+ const mark = shield(t, row.tier);
292
+ const label = TIERS[row.tier].label;
293
+ const idText = isSel ? t.fg("accent", row.id) : t.fg("text", row.id);
294
+ const prov = t.fg("muted", `[${row.provider}]`);
295
+ const tierText = t.fg(POSTURE_COLOR[TIERS[row.tier].posture] ?? "muted", label);
296
+ const check = isCur ? t.fg("success", " ✓") : "";
297
+ const prefix = isSel ? t.fg("accent", "→ ") : " ";
298
+ this.listContainer.addChild(
299
+ new Text(`${prefix}${mark} ${idText} ${prov} ${tierText}${check}`, 1, 0),
300
+ );
301
+ }
302
+ if (start > 0 || end < this.filtered.length) {
303
+ this.listContainer.addChild(
304
+ new Text(t.fg("muted", ` (${this.selectedIndex + 1}/${this.filtered.length})`), 1, 0),
305
+ );
306
+ }
307
+ // Honest one-liner for the highlighted model — states the LIMIT of the claim.
308
+ const sel = this.filtered[this.selectedIndex];
309
+ if (sel) this.blurbText.setText(t.fg("muted", ` ${TIERS[sel.tier].blurb}`));
310
+ }
311
+
312
+ // Seed the search box (from `/models <query>`) and filter to match.
313
+ setQuery(q: string): void {
314
+ this.searchInput.setValue(q);
315
+ this.applyFilter(q);
316
+ }
317
+
318
+ handleInput(keyData: string): void {
319
+ const kb = getKeybindings();
320
+ if (kb.matches(keyData, "tui.select.up")) {
321
+ if (!this.filtered.length) return;
322
+ this.selectedIndex =
323
+ this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1;
324
+ this.updateList();
325
+ } else if (kb.matches(keyData, "tui.select.down")) {
326
+ if (!this.filtered.length) return;
327
+ this.selectedIndex =
328
+ this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1;
329
+ this.updateList();
330
+ } else if (kb.matches(keyData, "tui.select.confirm")) {
331
+ const row = this.filtered[this.selectedIndex];
332
+ if (row) this.onSelect(row);
333
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
334
+ this.onCancel();
335
+ } else {
336
+ // Everything else edits the search box, then re-filters.
337
+ this.searchInput.handleInput(keyData);
338
+ this.applyFilter(this.searchInput.getValue());
339
+ }
340
+ }
341
+ }
342
+
343
+ // The extension entrypoint. Registers `/models`; the redirect patch also routes
344
+ // `/model` here so the shielded picker is the single model surface.
345
+ export default function privateerModels(pi: {
346
+ registerCommand?: (name: string, opts: unknown) => void;
347
+ setModel?: (model: ModelLike) => Promise<boolean> | boolean;
348
+ }): void {
349
+ if (typeof pi.registerCommand !== "function") return;
350
+
351
+ pi.registerCommand("models", {
352
+ description: "Pick a model with its privacy posture (TEE / ZDR / standard) — searchable",
353
+ argumentHint: "[search]",
354
+ handler: async (args: string, ctx: any): Promise<void> => {
355
+ const ui = ctx?.ui;
356
+ if (!ui?.custom) {
357
+ ctx?.ui?.notify?.("The model picker needs an interactive terminal.", "warning");
358
+ return;
359
+ }
360
+ const registry = ctx.modelRegistry;
361
+ // Refresh so a just-fetched account catalog / models.json edit is reflected.
362
+ try {
363
+ registry?.refresh?.();
364
+ } catch {
365
+ /* non-fatal */
366
+ }
367
+ // Make sure the account baseline tiers are loaded (first-open may race the
368
+ // provider's background fetch). Best-effort; falls back to prefix heuristics.
369
+ if (!accountCatalogLoaded()) {
370
+ try {
371
+ await fetchAccountCatalog();
372
+ } catch {
373
+ /* keep heuristics */
374
+ }
375
+ }
376
+
377
+ let available: ModelLike[] = [];
378
+ try {
379
+ available = (await registry.getAvailable()) as ModelLike[];
380
+ } catch (e) {
381
+ ui.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
382
+ return;
383
+ }
384
+ if (!available.length) {
385
+ ui.notify?.("No models available. Use /login to add a provider.", "warning");
386
+ return;
387
+ }
388
+
389
+ const rows: Row[] = available.map((m) => ({
390
+ provider: m.provider,
391
+ id: m.id,
392
+ name: m.name ?? m.id,
393
+ model: m,
394
+ tier: baselineTier(m.provider, m.id),
395
+ }));
396
+
397
+ const initialQuery = String(args ?? "").trim();
398
+ const chosen: Row | undefined = await ui.custom(
399
+ (tui: TuiLike, theme: ThemeLike, _kb: unknown, close: (result?: Row) => void) => {
400
+ const picker = new ModelsPicker({
401
+ tui,
402
+ theme,
403
+ rows,
404
+ current: ctx.model,
405
+ onSelect: (row) => close(row),
406
+ onCancel: () => close(undefined),
407
+ });
408
+ if (initialQuery) picker.setQuery(initialQuery);
409
+ return picker;
410
+ },
411
+ );
412
+
413
+ if (!chosen) return;
414
+ try {
415
+ const ok = await pi.setModel!(chosen.model);
416
+ if (ok === false) {
417
+ ui.notify?.(`No API key for ${chosen.provider}/${chosen.id}.`, "warning");
418
+ return;
419
+ }
420
+ ui.notify?.(
421
+ `Model: ${chosen.provider}/${chosen.id} · ${TIERS[chosen.tier].label}`,
422
+ "info",
423
+ );
424
+ } catch (e) {
425
+ ui.notify?.(`Couldn't switch model: ${(e as Error).message}`, "error");
426
+ }
427
+ },
428
+ });
429
+ }
@@ -8,31 +8,28 @@
8
8
 
9
9
  import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
10
  import { accountPosture } from "../src/providers/account.ts";
11
+ import { type Palette, paletteFor } from "../src/ui/palette.ts";
11
12
 
12
13
  const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
13
14
 
14
- // ANSI so the shield "references the previous color": the TEE tiers used to show a
15
- // green/yellow traffic-light dot — now they show a shield tinted the same color
16
- // (green = verified, yellow = unconfirmed). The status bar renders these escapes.
17
- const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
18
-
19
- // The TEE tiers render as a colored shield + "Trusted Execution" (pi-privacy labels
20
- // these "Verified TEE" / "TEE (unconfirmed)"; we rename to Trusted Execution for the
21
- // privateer badge and swap the dot for a shield). Everything else keeps the dot.
22
- function badgeLabel(tier: PrivacyTier): string | null {
23
- if (tier === "tee-verified") return `${GREEN}⛉ Trusted Execution${RESET}`;
24
- if (tier === "tee-unverified") return `${YELLOW}⛉ Trusted Execution (unconfirmed)${RESET}`;
15
+ // The shield "references the previous color": the TEE tiers show a shield tinted like the
16
+ // old traffic-light dot (green = verified, yellow = unconfirmed). The colours come from
17
+ // the active theme (paletteFor) so the badge stays legible on a light terminal too — a
18
+ // bare "\x1b[33m" yellow washes out on white. The status bar renders these escapes.
19
+ function badgeLabel(tier: PrivacyTier, p: Palette): string | null {
20
+ if (tier === "tee-verified") return `${p.GREEN}⛉ Trusted Execution${p.RESET}`;
21
+ if (tier === "tee-unverified") return `${p.YELLOW}⛉ Trusted Execution (unconfirmed)${p.RESET}`;
25
22
  return null;
26
23
  }
27
24
 
28
- async function badgeFor(provider: string, modelId: string): Promise<string> {
25
+ async function badgeFor(provider: string, modelId: string, p: Palette): Promise<string> {
29
26
  const res =
30
27
  provider === "privateer"
31
28
  ? await accountPosture(modelId)
32
29
  : await verifyModelPosture(provider, modelId, {
33
30
  apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
34
31
  });
35
- const shield = badgeLabel(res.tier as PrivacyTier);
32
+ const shield = badgeLabel(res.tier as PrivacyTier, p);
36
33
  if (shield) return shield;
37
34
  const info = TIERS[res.tier as PrivacyTier];
38
35
  return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
@@ -46,7 +43,7 @@ export default function privateerPosture(pi: any): void {
46
43
  const mine = ++seq;
47
44
  try {
48
45
  ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
49
- const badge = await badgeFor(provider, modelId);
46
+ const badge = await badgeFor(provider, modelId, paletteFor(ctx?.ui?.theme));
50
47
  if (mine === seq) ctx.ui.setStatus("privacy", badge);
51
48
  } catch {
52
49
  if (mine === seq) ctx.ui.setStatus("privacy", undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "bin",
38
38
  "src",
39
39
  "extensions",
40
+ "patches",
40
41
  "README.md",
41
42
  "LICENSE"
42
43
  ],
@@ -46,7 +47,8 @@
46
47
  "channels": "node --env-file=.env --import tsx src/channels/run.ts",
47
48
  "dev": "tsx watch src/main.ts",
48
49
  "typecheck": "tsc --noEmit",
49
- "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
50
+ "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done",
51
+ "postinstall": "patch-package"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=22.19.0"
@@ -60,11 +62,12 @@
60
62
  "@noble/ciphers": "^2.1.1",
61
63
  "@noble/curves": "^1.9.7",
62
64
  "@noble/hashes": "^1.7.1",
65
+ "patch-package": "^8.0.1",
63
66
  "pi-mcp-adapter": "^2.11.0",
64
67
  "pi-privacy": "^0.3.0",
65
68
  "pi-subagents": "^0.34.0",
66
- "privateer-workflow": "^0.1.0",
67
69
  "picomatch": "^4.0.4",
70
+ "privateer-workflow": "^0.1.0",
68
71
  "tsx": "^4.16.0",
69
72
  "typebox": "^1.3.4",
70
73
  "undici": "^7.28.0",
@@ -0,0 +1,23 @@
1
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
2
+ index 5d65200..a997ad7 100644
3
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
4
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
5
+ @@ -2042,7 +2042,17 @@ export class InteractiveMode {
6
+ if (text === "/model" || text.startsWith("/model ")) {
7
+ const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
8
+ this.editor.setText("");
9
+ - await this.handleModelCommand(searchTerm);
10
+ + // Privateer redirect: when the shielded picker extension is loaded
11
+ + // (registers `/models`), route `/model` to it so users get the
12
+ + // privacy-posture picker via muscle memory. Falls back to Pi's
13
+ + // built-in picker when the extension isn't present.
14
+ + const privateerModels = searchTerm ? `/models ${searchTerm}` : "/models";
15
+ + if (this.isExtensionCommand(privateerModels)) {
16
+ + await this.session.prompt(privateerModels);
17
+ + }
18
+ + else {
19
+ + await this.handleModelCommand(searchTerm);
20
+ + }
21
+ return;
22
+ }
23
+ if (text === "/export" || text.startsWith("/export ")) {
package/src/cli/chat.ts CHANGED
@@ -10,9 +10,14 @@
10
10
 
11
11
  import "../boot.ts"; // env + attestation dispatcher, before any Pi import
12
12
  import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
13
+ import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
13
14
  import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
14
15
 
15
- const RESET = "\x1b[0m", DIM = "\x1b[2m", CYAN = "\x1b[36m", YELLOW = "\x1b[33m", RED = "\x1b[31m", GREEN = "\x1b[32m";
16
+ // This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
17
+ // itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
18
+ // yellow / "\x1b[36m" cyan and faint "\x1b[2m" dim wash out, so cliPalette swaps in dark
19
+ // 256-colour indices there. On a dark terminal it's the same named colours as before.
20
+ const { RESET, DIM, CYAN, YELLOW, RED, GREEN } = cliPalette();
16
21
 
17
22
  async function main() {
18
23
  const readline = await import("node:readline");
@@ -34,7 +39,7 @@ async function main() {
34
39
  const priv = await import("../auth/privateer.ts");
35
40
  const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
36
41
  const { agentVersion } = await import("../config/version.ts");
37
- const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
42
+ const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
38
43
 
39
44
  // resolveDefaultModel() already honours PRIVATEER_MODEL first, then the account
40
45
  // default when signed in, then a BYO key — one source of truth (defaultModel.ts).
@@ -470,6 +475,14 @@ async function main() {
470
475
  },
471
476
  });
472
477
  console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
478
+ // Move the live session onto a confidential model right away, so the next prompt
479
+ // doesn't dead-end on the keyless launch model ("No API key found for openrouter").
480
+ // resolveSignedInModel prefers Tinfoil GLM 5.2, else the account's NEAR channel;
481
+ // PRIVATEER_MODEL (a deliberate override) is respected and left alone.
482
+ if (!process.env.PRIVATEER_MODEL?.trim()) {
483
+ const target = resolveSignedInModel();
484
+ if (target !== currentSpec) await switchModel(target, false);
485
+ }
473
486
  } catch (e) {
474
487
  console.log(`${RED}${(e as Error).message}${RESET}`);
475
488
  }