privateer-agent 0.6.7 → 0.6.8

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/SECURITY.md CHANGED
@@ -30,7 +30,11 @@ npm audit signatures # verifies registry signatures + p
30
30
  ```
31
31
 
32
32
  If a version lacks provenance, it did not come from this workflow. Treat that as
33
- suspicious and report it.
33
+ suspicious and report it — with one documented exception: **0.6.7 is the first release
34
+ published this way.** Trusted publishing was misconfigured until then, so every earlier
35
+ version (through 0.6.6) was published by hand from a maintainer's machine and carries no
36
+ attestation. Those are not forgeries, but they are not independently verifiable either.
37
+ If that distinction matters to you, use 0.6.7 or later.
34
38
 
35
39
  ## The permission gate
36
40
 
@@ -214,28 +214,39 @@ else {
214
214
  // auto-install. Fire-and-forget: the event loop stays alive while the TUI child runs.
215
215
  refreshUpdateCache();
216
216
 
217
- // Default model. Explicit PRIVATEER_MODEL wins; else Tinfoil GLM 5.2 (client-attested
218
- // TEE, strongest tier) when a Tinfoil key is present; else the signed-in account's
219
- // NEAR channel; else a cheap OpenRouter fallback.
217
+ // Default model. Mirrors src/providers/defaultModel.ts resolveDefaultModel() keep
218
+ // the two in step. Tinfoil's GLM 5.2 is the default either way: direct when the user
219
+ // has a Tinfoil key (pi-privacy can client-attest the enclave), over the Privateer
220
+ // subscription otherwise.
221
+ //
222
+ // The last branch is the important one. A signed-out, keyless terminal used to launch
223
+ // on `openrouter/openai/gpt-4o-mini`, which it had no key for — so the first prompt
224
+ // died on "No API key found for openrouter", /login couldn't fix it (nothing switched
225
+ // the live model), and the error named a provider the user had never heard of. It now
226
+ // launches on the SAME account model it will use once signed in: nothing to switch,
227
+ // the status bar shows what they're about to get, and the error until then names
228
+ // Privateer and points at /login.
220
229
  const CRED = path.join(PRIVATEER_HOME, "credentials.json");
221
230
  const signedIn = fs.existsSync(CRED);
231
+ const ACCOUNT_MODEL = "privateer/tinfoil/glm-5-2";
222
232
  const MODEL = process.env.PRIVATEER_MODEL
223
233
  ? process.env.PRIVATEER_MODEL
224
234
  : haveTinfoilKey()
225
235
  ? "tinfoil/glm-5-2"
226
236
  : signedIn
227
- ? "privateer/near/zai-org/GLM-5.1-FP8"
228
- : "openrouter/openai/gpt-4o-mini";
229
-
230
- // Guard the keyless dead-end. We land on the OpenRouter fallback ONLY when the user
231
- // named no model, has no Tinfoil key, AND isn't signed in (no credentials.json). If
232
- // they also have no OpenRouter/other BYO key, the very first prompt errors with a bare
233
- // "No API key found for openrouter" and nothing explains why. Worse, if this machine
234
- // was signed in before (other ~/.privateer state exists but the login file is gone),
235
- // that bare error hides a vanished session. Surface a clear, branded notice BEFORE the
236
- // TUI loads but still boot it, so `/login` inside works (and activateSignedInModel
237
- // switches the live session onto the account channel the moment they sign back in).
238
- if (MODEL === "openrouter/openai/gpt-4o-mini" && !haveByoKey()) {
237
+ ? ACCOUNT_MODEL
238
+ : haveKey("ANTHROPIC_API_KEY")
239
+ ? "anthropic/claude-opus-4-8"
240
+ : haveKey("OPENAI_API_KEY")
241
+ ? "openai/gpt-5.5"
242
+ : haveKey("OPENROUTER_API_KEY")
243
+ ? "openrouter/openai/gpt-4o-mini"
244
+ : ACCOUNT_MODEL;
245
+
246
+ // Nothing to run with: no model named, no BYO key, not signed in. The TUI still boots
247
+ // (that's where /login lives), but say why up front a returning user whose login
248
+ // file vanished otherwise has no way to tell a cleared session from a first run.
249
+ if (!signedIn && !process.env.PRIVATEER_MODEL && !haveByoKey()) {
239
250
  warnKeylessLaunch();
240
251
  }
241
252
 
@@ -367,20 +378,17 @@ function warnKeylessLaunch() {
367
378
  ? [
368
379
  "",
369
380
  " ⚓ Your Privateer login is missing — this terminal isn't signed in.",
370
- ` (no ${path.join(PRIVATEER_HOME, "credentials.json")})`,
371
381
  "",
372
- " If you were signed in before, your session was cleared. Run /login to sign",
373
- " back in you'll return to your subscription models right away. Until then,",
374
- " prompting fails with \"No API key found\" because no model key is set.",
382
+ " Run /login and approve the code in the Privateer app. You'll be back on your",
383
+ " subscription models straight away no API key needed.",
375
384
  "",
376
385
  ]
377
386
  : [
378
387
  "",
379
- " ⚓ You're not signed in to Privateer and no provider API key is set.",
388
+ " ⚓ Welcome aboard. Run /login to connect your Privateer account.",
380
389
  "",
381
- " Run /login to use your subscription, or set a provider key (e.g.",
382
- " ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY). Until then,",
383
- " prompting fails with \"No API key found\".",
390
+ " One approval in the Privateer app and you're running Tinfoil GLM 5.2 in a",
391
+ " trusted enclave no API key needed. Prefer your own key? /login keys.",
384
392
  "",
385
393
  ];
386
394
  process.stderr.write(lines.join("\n") + "\n");
@@ -5,25 +5,30 @@
5
5
  // 1. A branded startup header — the anchor mark + "✻ PRIVATEER" wordmark + the
6
6
  // "Chart your own course privately." tagline (ported from tree-cli's Banner).
7
7
  // 2. A live status-bar badge (⚓ account) showing the sign-in state at a glance.
8
- // 3. Sign-in UX — /signin, /signout, and a /privateer hub — which drive the
8
+ // 3. Auth UX — /login, /logout, and a /privateer hub — which drive the
9
9
  // device-code flow the account channel needs.
10
10
  //
11
- // Pi already owns /login and /logout for PROVIDER auth (and /whoami), so we do NOT
12
- // shadow them. The account provider now registers unconditionally (see
13
- // makeAccountProvider), so Privateer appears under Pi's /login "Use a subscription"
14
- // list and a first-time user CAN sign in through provider auth. /signin remains as a
15
- // friendlier, dedicated shortcut that drives the same account device-code flow
16
- // directly one obvious command instead of /login pick a provider.
11
+ // ONE vocabulary, deliberately: log in / log out. This file used to avoid shadowing
12
+ // Pi's built-in /login and /logout and shipped /signin and /signout alongside them,
13
+ // which left the user with two auth vocabularies and — worse — a /logout that did
14
+ // not log you out. Pi's /logout only clears Pi's own authStorage; the Privateer
15
+ // machine login lives in ~/.privateer/credentials.json and survived it, so /logout
16
+ // on a signed-in machine reported "No stored credentials to remove" and changed
17
+ // nothing. We now own both verbs: /logout here is canonical and Pi's built-in is
18
+ // redirected to it (patches/, same mechanism as the /model → /models redirect).
19
+ // /signin and /signout stay as undocumented aliases for muscle memory.
17
20
  //
18
- // On a successful /signin we hot-register the account provider so privateer/* models
19
- // appear immediately (the account catalog refreshes to the live listing without a
20
- // restart).
21
+ // The account provider also registers unconditionally (see makeAccountProvider), so
22
+ // Privateer appears under Pi's "Use a subscription" list and a first-time user can
23
+ // log in that way too. On a successful login we hot-register the account provider so
24
+ // privateer/* models appear immediately (the account catalog refreshes to the live
25
+ // listing without a restart).
21
26
 
22
27
  import { readFileSync, appendFileSync } from "node:fs";
23
28
  import { homedir } from "node:os";
24
29
  import { join } from "node:path";
25
30
  import * as priv from "../src/auth/privateer.ts";
26
- import { makeAccountProvider } from "../src/providers/account.ts";
31
+ import { armAccountCredential, makeAccountProvider } from "../src/providers/account.ts";
27
32
  import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
28
33
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
29
34
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
@@ -345,11 +350,18 @@ export default function privateerBrand(pi: any): void {
345
350
  }
346
351
  }
347
352
 
353
+ // /login. Signing in must leave the terminal ABLE TO PROMPT, not merely "connected" —
354
+ // so this runs the device flow, hot-registers the account provider, then hands off to
355
+ // activateSignedInModel to arm the channel and select the model. Every step reports.
348
356
  async function doSignIn(ctx: any): Promise<void> {
349
357
  if (priv.hasCredentials()) {
350
358
  const u = priv.currentUser();
359
+ // Already linked: still make sure THIS session can use the account (a terminal
360
+ // launched before the login, or one whose channel never armed, otherwise sits
361
+ // "signed in" and unusable), then point at the two things they might have meant.
362
+ await activateSignedInModel(ctx, { switchModel: false });
351
363
  return ctx?.ui?.notify?.(
352
- `Already signed in as ${u?.email ?? u?.id}. Run /signout to switch accounts.`,
364
+ `Signed in as ${u?.email ?? u?.id}. Run /logout to switch accounts, or /login keys to add a provider API key.`,
353
365
  "info",
354
366
  );
355
367
  }
@@ -367,7 +379,7 @@ export default function privateerBrand(pi: any): void {
367
379
  `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
368
380
  ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
369
381
  uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
370
- `${p.DIM} waiting for approval…${p.RESET}`,
382
+ `${p.DIM} waiting for approval… ${p.RESET}${p.DIM}(esc to cancel · ${p.RESET}${p.INK}/login keys${p.DIM} to use your own API key instead)${p.RESET}`,
371
383
  ].filter(Boolean),
372
384
  { placement: "aboveEditor" },
373
385
  );
@@ -381,20 +393,31 @@ export default function privateerBrand(pi: any): void {
381
393
  /* provider list fetch failed — models appear on next launch */
382
394
  }
383
395
  refresh(ctx);
384
- ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}. Your Privateer models are ready.`, "info");
396
+ ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}.`, "info");
397
+ // Arm the account channel and select the model, IN THIS SESSION. runDeviceLogin
398
+ // fires onSignedIn too, which does the same work — this await is what makes the
399
+ // outcome (and any failure) land before we hand the prompt back to the user.
400
+ await activateSignedInModel(ctx);
385
401
  } catch (e) {
386
402
  ctx?.ui?.setWidget?.("privateer-signin", undefined);
387
403
  ctx?.ui?.notify?.((e as Error).message || "Sign-in failed.", "error");
388
404
  }
389
405
  }
390
406
 
407
+ // Log out of this MACHINE — the login and every terminal spawned from it (see
408
+ // priv.logout). Unlike the old terminal-scoped sign-out this makes a blocking
409
+ // network call, so say what's happening before the round trip rather than after.
391
410
  async function doSignOut(ctx: any): Promise<void> {
392
411
  if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
393
412
  const u = priv.currentUser();
394
- await priv.logout();
413
+ ctx?.ui?.notify?.("Signing out of Privateer…", "info");
414
+ await priv.logout(); // never throws: local state is wiped whatever the network did
395
415
  dropPersistedAccount(ctx);
396
416
  refresh(ctx);
397
- ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
417
+ ctx?.ui?.notify?.(
418
+ `Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
419
+ "info",
420
+ );
398
421
  }
399
422
 
400
423
  function showStatus(ctx: any): void {
@@ -402,50 +425,103 @@ export default function privateerBrand(pi: any): void {
402
425
  ctx?.ui?.notify?.(
403
426
  u
404
427
  ? `Signed in to Privateer as ${u.email ?? u.id}.`
405
- : "Not signed in. Run /signin to connect your Privateer account.",
428
+ : "Not logged in. Run /login to connect your Privateer account.",
406
429
  "info",
407
430
  );
408
431
  }
409
432
 
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 overriderespect 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}`);
433
+ // Make the terminal USABLE the instant the user signs in — the whole point of
434
+ // logging in, and the step that used to be missing. Two halves, in this order:
435
+ //
436
+ // 1. ARM the account channel. Pi stores an OAuth credential only for a login it
437
+ // drove itself, so after our own device-code /login the `privateer` provider has
438
+ // no key at all. Arming first also matters for the now-common case where the
439
+ // launch model ALREADY is the account model (a signed-out terminal boots on it,
440
+ // see providers/defaultModel.ts): there's no switch to make, only a key to fetch,
441
+ // and the old early-return skipped it and left the next prompt to fail.
442
+ // 2. SWITCH the live model, if we aren't already on it. A terminal that launched on
443
+ // a BYO key stays on that key otherwise, so signing in appears to do nothing.
444
+ //
445
+ // resolveSignedInModel picks Tinfoil GLM 5.2direct (client-attested) when a Tinfoil
446
+ // key is present, over the subscription otherwise. A deliberate PRIVATEER_MODEL is
447
+ // never overridden. Idempotent: sign-in fires this twice by design (once when
448
+ // credentials land, once when the channel is ready), and a second run is a no-op.
449
+ // Best-effort but a failure is now REPORTED, because silently leaving the user on an
450
+ // unusable model is exactly the bug this replaces.
451
+ let activating = false;
452
+ async function activateSignedInModel(ctx: any, opts: { switchModel?: boolean } = {}): Promise<void> {
453
+ if (activating || process.env.PRIVATEER_MODEL?.trim()) return; // in-flight, or a deliberate override
454
+ activating = true;
455
+ try {
456
+ const spec = resolveSignedInModel();
457
+ const slash = spec.indexOf("/");
458
+ if (slash <= 0) return;
459
+ const provider = spec.slice(0, slash), id = spec.slice(slash + 1);
460
+
461
+ // The account channel needs a live session token before any privateer/* model can
462
+ // run. Retry briefly: this can be racing the credential the login itself minted.
463
+ if (provider === "privateer") {
464
+ let armed = false;
465
+ for (let attempt = 0; attempt < 3 && !armed; attempt++) {
466
+ armed = await armAccountCredential(ctx, { notify: false });
467
+ if (!armed) await new Promise((r) => setTimeout(r, 500));
468
+ }
469
+ if (!armed) {
470
+ dbg("activateSignedInModel: account channel not armed");
471
+ ctx?.ui?.notify?.(
472
+ "Signed in, but this terminal couldn't open an account session. Check your connection and run /login again, or sign a terminal out in the app if you're at the device limit.",
473
+ "error",
474
+ );
441
475
  return;
442
476
  }
443
- } catch (e) {
444
- dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
445
477
  }
446
- await new Promise((r) => setTimeout(r, 400)); // credential still spawning — retry
478
+
479
+ // Re-running /login while already signed in arms the channel but must NOT move the
480
+ // user off a model they picked with /models. Only a genuine sign-in switches.
481
+ if (opts.switchModel === false) return;
482
+
483
+ // Already on the target — the common case now that a signed-out terminal launches
484
+ // on the account model. Nothing to switch, but SAY so: the channel just went live
485
+ // under them, and silence after a login is what made the old flow feel broken.
486
+ const currentSpec = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
487
+ if (currentSpec === spec) {
488
+ dbg(`activateSignedInModel: already on ${spec}`);
489
+ refresh(ctx);
490
+ ctx?.ui?.notify?.(`${spec} is ready — private inference on your Privateer account.`, "info");
491
+ return;
492
+ }
493
+
494
+ const reg = ctx?.modelRegistry;
495
+ if (!reg?.find || typeof pi.setModel !== "function") return;
496
+ const model = reg.find(provider, id);
497
+ if (!model) {
498
+ // The provider's live catalog may still be loading (or this is the first sign-in
499
+ // of the run, before the account provider was registered). Say something useful
500
+ // rather than stranding them on a model their account can't bill.
501
+ dbg(`activateSignedInModel: ${spec} not in registry`);
502
+ ctx?.ui?.notify?.(`Signed in. Run /models to pick a model — ${spec} isn't loaded yet.`, "warning");
503
+ return;
504
+ }
505
+ for (let attempt = 0; attempt < 4; attempt++) {
506
+ try {
507
+ const ok = await pi.setModel(model);
508
+ if (ok !== false) {
509
+ currentModelProvider = provider;
510
+ refresh(ctx);
511
+ ctx?.ui?.notify?.(`Now using ${spec} — private inference on your Privateer account.`, "info");
512
+ dbg(`activateSignedInModel: switched to ${spec}`);
513
+ return;
514
+ }
515
+ } catch (e) {
516
+ dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
517
+ }
518
+ await new Promise((r) => setTimeout(r, 400)); // registry still settling — retry
519
+ }
520
+ dbg(`activateSignedInModel: gave up switching to ${spec}`);
521
+ ctx?.ui?.notify?.(`Signed in, but couldn't switch to ${spec}. Run /models to pick one.`, "warning");
522
+ } finally {
523
+ activating = false;
447
524
  }
448
- dbg(`activateSignedInModel: gave up switching to ${spec}`);
449
525
  }
450
526
 
451
527
  dbg("extension loaded, onSignedIn listener registering");
@@ -529,27 +605,38 @@ export default function privateerBrand(pi: any): void {
529
605
  // that's now dead server-side (see dropPersistedAccount).
530
606
  dropPersistedAccount(ctxRef);
531
607
  refresh(ctxRef);
532
- ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
608
+ ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
533
609
  });
534
610
 
535
611
  pi.registerCommand?.("update", {
536
612
  description: "Update Privateer to the latest release (npm i -g privateer-agent@latest)",
537
613
  handler: (_args: string, ctx: any) => doUpdate(ctx),
538
614
  });
539
- pi.registerCommand?.("signin", {
540
- description: "Sign in to your Privateer account (device-code flow)",
615
+ // ONE vocabulary: log in / log out. Pi's own built-ins are /login and /logout, so
616
+ // matching them is what makes the pair feel like a single concept instead of two
617
+ // half-overlapping ones (Pi's /logout used to clear only Pi's authStorage while the
618
+ // machine login lived on, which read as "logout doesn't work"). /logout here is the
619
+ // canonical command; the Pi built-in is redirected to it by patches/, the same
620
+ // mechanism as the /model → /models redirect.
621
+ //
622
+ // signin/signout stay registered as undocumented aliases — they were the shipped
623
+ // names, they're in muscle memory and in older docs, and an alias costs nothing.
624
+ pi.registerCommand?.("login", {
625
+ description: "Log in to your Privateer account · /login keys for a provider API key",
541
626
  handler: (_args: string, ctx: any) => doSignIn(ctx),
542
627
  });
543
- pi.registerCommand?.("signout", {
544
- description: "Sign out of your Privateer account on this terminal",
628
+ pi.registerCommand?.("logout", {
629
+ description: "Log out of Privateer on this machine (revokes all its terminals)",
545
630
  handler: (_args: string, ctx: any) => doSignOut(ctx),
546
631
  });
632
+ pi.registerCommand?.("signin", { description: "", handler: (_a: string, ctx: any) => doSignIn(ctx) });
633
+ pi.registerCommand?.("signout", { description: "", handler: (_a: string, ctx: any) => doSignOut(ctx) });
547
634
  pi.registerCommand?.("privateer", {
548
- description: "Privateer account: /privateer [status | signin | signout]",
635
+ description: "Privateer account: /privateer [status | login | logout]",
549
636
  handler: (args: string, ctx: any) => {
550
637
  const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
551
- if (sub === "signin" || sub === "login") return doSignIn(ctx);
552
- if (sub === "signout" || sub === "logout") return doSignOut(ctx);
638
+ if (sub === "login" || sub === "signin") return doSignIn(ctx);
639
+ if (sub === "logout" || sub === "signout") return doSignOut(ctx);
553
640
  return showStatus(ctx);
554
641
  },
555
642
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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",
@@ -1,8 +1,23 @@
1
1
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
2
- index e223ce1..2bdab10 100644
2
+ index e223ce1..3615d74 100644
3
3
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
4
4
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
5
- @@ -711,6 +711,15 @@ export class AgentSession {
5
+ @@ -163,6 +163,14 @@ export class AgentSession {
6
+ }
7
+ const isOAuth = this._modelRegistry.isUsingOAuth(model);
8
+ if (isOAuth) {
9
+ + // Privateer patch: the account channel has no API key that could expire —
10
+ + // the terminal is simply not signed in yet (a fresh install now boots on
11
+ + // `privateer/*`, which is the model it will run once logged in). Stock Pi's
12
+ + // wording describes a state that user was never in, and its "/login
13
+ + // privateer" bypasses the branded sign-in. auth-guidance owns the words.
14
+ + if (model.provider === "privateer") {
15
+ + throw new Error(formatNoApiKeyFoundMessage(model.provider));
16
+ + }
17
+ throw new Error(`Authentication failed for "${model.provider}". ` +
18
+ `Credentials may have expired or network is unavailable. ` +
19
+ `Run '/login ${model.provider}' to re-authenticate.`);
20
+ @@ -711,6 +719,15 @@ export class AgentSession {
6
21
  finalError: msg.errorMessage,
7
22
  });
8
23
  this._retryAttempt = 0;
@@ -18,8 +33,52 @@ index e223ce1..2bdab10 100644
18
33
  }
19
34
  if (await this._checkCompaction(msg)) {
20
35
  return true;
36
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
37
+ index 197bccc..27f6429 100644
38
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
39
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
40
+ @@ -1,21 +1,33 @@
41
+ import { join } from "node:path";
42
+ import { getDocsPath } from "../config.js";
43
+ const UNKNOWN_PROVIDER = "unknown";
44
+ +// Privateer patch: speak Privateer, and stop printing absolute node_modules doc paths.
45
+ +//
46
+ +// Stock Pi answered every auth failure with four lines, two of them full paths into
47
+ +// node_modules/@earendil-works/pi-coding-agent/docs/. On a terminal that isn't signed
48
+ +// in, that wall repeats on every prompt and buries the one sentence that matters. It
49
+ +// also told a Privateer user to go find a provider API key when their subscription
50
+ +// already covers the model. Same information, one actionable line.
51
+ export function getProviderLoginHelp() {
52
+ - return [
53
+ - "Use /login to log into a provider via OAuth or API key. See:",
54
+ - ` ${join(getDocsPath(), "providers.md")}`,
55
+ - ` ${join(getDocsPath(), "models.md")}`,
56
+ - ].join("\n");
57
+ + return "Run /login to connect your Privateer account, or /login keys to use your own provider API key.";
58
+ }
59
+ export function formatNoModelsAvailableMessage() {
60
+ return `No models available. ${getProviderLoginHelp()}`;
61
+ }
62
+ export function formatNoModelSelectedMessage() {
63
+ - return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`;
64
+ + return `No model selected. ${getProviderLoginHelp()}\n\nThen use /models to select a model.`;
65
+ }
66
+ export function formatNoApiKeyFoundMessage(provider) {
67
+ + // The account channel: there is no API key to find — you're just not signed in (or
68
+ + // the session didn't arm). Naming the real problem is the whole fix here.
69
+ + if (provider === "privateer") {
70
+ + return "This terminal isn't signed in to Privateer, so it can't run your subscription models.\n\nRun /login — it takes one approval in the Privateer app, and no API key.";
71
+ + }
72
+ const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider;
73
+ return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`;
74
+ }
75
+ +// Kept so the module's imports stay meaningful for anything that still wants the docs.
76
+ +export function getProviderDocsPaths() {
77
+ + return [join(getDocsPath(), "providers.md"), join(getDocsPath(), "models.md")];
78
+ +}
79
+ //# sourceMappingURL=auth-guidance.js.map
21
80
  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
22
- index 5d65200..a997ad7 100644
81
+ index 5d65200..fef6005 100644
23
82
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
24
83
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
25
84
  @@ -2042,7 +2042,17 @@ export class InteractiveMode {
@@ -41,3 +100,51 @@ index 5d65200..a997ad7 100644
41
100
  return;
42
101
  }
43
102
  if (text === "/export" || text.startsWith("/export ")) {
103
+ @@ -2105,14 +2115,44 @@ export class InteractiveMode {
104
+ this.editor.setText("");
105
+ return;
106
+ }
107
+ - if (text === "/login") {
108
+ - this.showOAuthSelector("login");
109
+ + if (text === "/login" || text.startsWith("/login ")) {
110
+ this.editor.setText("");
111
+ + // Privateer redirect: a bare /login IS the account sign-in. Pi's built-in
112
+ + // opens a two-step menu ("Use a subscription" → a list of 20+ providers)
113
+ + // that buries the one option a Privateer user wants, and — because it
114
+ + // only auto-selects a model when the current one is UNKNOWN — a
115
+ + // successful login through it left the terminal on its launch model and
116
+ + // the next prompt died on "No API key found". The extension's own flow
117
+ + // signs in, arms the account channel, and selects the model.
118
+ + // `/login <anything>` (documented as `/login keys`) still opens Pi's own
119
+ + // selector, so BYO provider keys stay reachable; and if the extension
120
+ + // isn't loaded we fall back to Pi's selector for both.
121
+ + if (text === "/login" && this.isExtensionCommand("/privateer")) {
122
+ + await this.session.prompt("/privateer login");
123
+ + }
124
+ + else {
125
+ + this.showOAuthSelector("login");
126
+ + }
127
+ return;
128
+ }
129
+ if (text === "/logout") {
130
+ - this.showOAuthSelector("logout");
131
+ this.editor.setText("");
132
+ + // Privateer redirect: /logout must actually log you out. Pi's built-in
133
+ + // only clears Pi's authStorage, which leaves the Privateer machine
134
+ + // login in ~/.privateer/credentials.json untouched — so on a signed-in
135
+ + // machine it reported "No stored credentials to remove" and changed
136
+ + // nothing. Route to the account logout instead, which revokes this
137
+ + // machine's whole token family (login + every terminal spawned from it)
138
+ + // and wipes local state. Target is `/privateer logout` rather than the
139
+ + // extension's own `/logout` so this can never re-enter the branch it
140
+ + // was dispatched from. Falls back to Pi's selector when the extension
141
+ + // isn't loaded.
142
+ + if (this.isExtensionCommand("/privateer")) {
143
+ + await this.session.prompt("/privateer logout");
144
+ + }
145
+ + else {
146
+ + this.showOAuthSelector("logout");
147
+ + }
148
+ return;
149
+ }
150
+ if (text === "/new") {
@@ -144,7 +144,10 @@ export function dropOwnedSession(pid: number): void {
144
144
  writeRegistry(reg);
145
145
  }
146
146
 
147
- // Test seam: wipe the registry file.
147
+ // Wipe the registry. Used by logout(), where the server has just revoked this
148
+ // machine's whole token family: every entry now names a dead session, and leaving
149
+ // them behind would offer the next login a list of orphans to "reclaim" that can
150
+ // only fail. Also a test seam.
148
151
  export function clearOwnedSessions(): void {
149
152
  try {
150
153
  rmSync(accountSessionsPath(), { force: true });
@@ -21,6 +21,7 @@ import {
21
21
  forgetOwnedSession,
22
22
  orphanedSessions,
23
23
  dropOwnedSession,
24
+ clearOwnedSessions,
24
25
  } from "./accountSessions.ts";
25
26
  import { isAccountCapCode } from "../engine/errors.ts";
26
27
  import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
@@ -94,9 +95,37 @@ export function defaultDeviceLabel(): string {
94
95
  }
95
96
  }
96
97
 
97
- // ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
98
+ // ── Cross-instance state ─────────────────────────────────────────────────────
99
+ //
100
+ // Pi loads every extension with a FRESH jiti instance (`moduleCache: false`, see
101
+ // core/extensions/loader.js), so privateer-brand and privateer-account each get their
102
+ // OWN copy of this module — separate credential cache, separate listener sets.
103
+ //
104
+ // That silently broke /login. The account provider's OAuth login() ran inside the
105
+ // privateer-account copy and called notifySignedIn() there, while the UI's listener
106
+ // (the one that switches the live session onto a model the account can actually
107
+ // serve) was registered on the privateer-brand copy. The signal never crossed, so a
108
+ // successful sign-in left the terminal pinned to its keyless launch model and the
109
+ // very next prompt died with "No API key found for openrouter" — exactly the state
110
+ // the login was supposed to fix. The same split let a /logout in one copy leave a
111
+ // stale `user` memoized in another.
112
+ //
113
+ // So anything that must be observed ACROSS extensions lives on globalThis, keyed by
114
+ // a registered Symbol — one bus, however many module instances jiti creates.
115
+ const SHARED = Symbol.for("privateer.auth.shared");
116
+
117
+ interface SharedAuthState {
118
+ cache: Credentials | null;
119
+ signedIn: Set<SignedInListener>;
120
+ expired: Set<SessionExpiredListener>;
121
+ }
98
122
 
99
- let _cache: Credentials | null | undefined;
123
+ function shared(): SharedAuthState {
124
+ const g = globalThis as { [SHARED]?: SharedAuthState };
125
+ return (g[SHARED] ??= { cache: null, signedIn: new Set(), expired: new Set() });
126
+ }
127
+
128
+ // ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
100
129
 
101
130
  // Per-terminal child session (see spawnChildSession). Held in memory ONLY — it
102
131
  // is never written to the shared credentials file, so each running terminal
@@ -144,15 +173,16 @@ export function loadCredentials(): Credentials | null {
144
173
  // memoized the pre-login "absent" as null, that instance would report "not signed
145
174
  // in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
146
175
  // disk on each miss lets a later call see what a sign-in just wrote.
147
- if (_cache) return _cache;
176
+ const state = shared();
177
+ if (state.cache) return state.cache;
148
178
  const path = credentialsPath();
149
179
  if (!existsSync(path)) return null;
150
180
  try {
151
- _cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
181
+ state.cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
152
182
  } catch {
153
183
  return null;
154
184
  }
155
- return _cache;
185
+ return state.cache;
156
186
  }
157
187
 
158
188
  export function saveCredentials(creds: Credentials): void {
@@ -162,7 +192,7 @@ export function saveCredentials(creds: Credentials): void {
162
192
  const path = credentialsPath();
163
193
  writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
164
194
  tryChmod(path, 0o600);
165
- _cache = creds;
195
+ shared().cache = creds;
166
196
  }
167
197
 
168
198
  export function clearCredentials(): void {
@@ -174,7 +204,7 @@ export function clearCredentials(): void {
174
204
  // Drop the pinned account signing key too — it belongs to the account that just
175
205
  // signed out; a different account must re-pin its own at link.
176
206
  clearAccountSignKey();
177
- _cache = null;
207
+ shared().cache = null;
178
208
  _child = null;
179
209
  _account = null;
180
210
  }
@@ -204,15 +234,15 @@ function tryChmod(path: string, mode: number): void {
204
234
  // stops working; the UI subscribes to announce the sign-out prominently.
205
235
 
206
236
  type SessionExpiredListener = () => void;
207
- const _expiredListeners = new Set<SessionExpiredListener>();
208
237
 
209
238
  export function onSessionExpired(listener: SessionExpiredListener): () => void {
210
- _expiredListeners.add(listener);
211
- return () => _expiredListeners.delete(listener);
239
+ const listeners = shared().expired;
240
+ listeners.add(listener);
241
+ return () => listeners.delete(listener);
212
242
  }
213
243
 
214
244
  function notifySessionExpired(): void {
215
- for (const listener of _expiredListeners) {
245
+ for (const listener of shared().expired) {
216
246
  try {
217
247
  listener();
218
248
  } catch {
@@ -248,17 +278,19 @@ export function handleServerRevoke(): void {
248
278
  // A listener here refreshes the UI regardless of which path the user took.
249
279
 
250
280
  type SignedInListener = () => void;
251
- const _signedInListeners = new Set<SignedInListener>();
252
281
 
253
282
  export function onSignedIn(listener: SignedInListener): () => void {
254
- _signedInListeners.add(listener);
255
- return () => _signedInListeners.delete(listener);
283
+ const listeners = shared().signedIn;
284
+ listeners.add(listener);
285
+ return () => listeners.delete(listener);
256
286
  }
257
287
 
258
288
  // Emit the sign-in signal. Exported so the account OAuth provider can announce a
259
289
  // completed subscription login on the already-linked path (see the note above).
290
+ // Listeners MUST be idempotent: a device-code login fires this once the credentials
291
+ // land and again once the account channel is armed (see privateerOAuthProvider.login).
260
292
  export function notifySignedIn(): void {
261
- for (const listener of _signedInListeners) {
293
+ for (const listener of shared().signedIn) {
262
294
  try {
263
295
  listener();
264
296
  } catch {
@@ -602,15 +634,55 @@ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
602
634
  // ── Logout ───────────────────────────────────────────────────────────────────
603
635
 
604
636
  /**
605
- * Log out this terminal: revoke its session server-side (best effort) and wipe
606
- * local credentials. Other devices/sessions are untouched.
637
+ * Log out this MACHINE: revoke the machine login and every terminal session
638
+ * spawned from it, then wipe all local auth state. Other devices (the phone app,
639
+ * another laptop) keep their own logins — each has its own token family.
640
+ *
641
+ * Two things this deliberately does NOT do, both of which it used to:
642
+ *
643
+ * 1. It does not POST /auth/logout. That endpoint calls revokeAllUserSessions —
644
+ * the entire ACCOUNT, every device including the app — while this function's
645
+ * contract (and its old doc comment) promised the opposite. Signing out of one
646
+ * terminal must not sign you out of your phone.
647
+ *
648
+ * 2. It does not go through apiRequest/authedFetch. Those authenticate with a CHILD
649
+ * session and spawn one if absent — so at the per-machine child cap the spawn
650
+ * throws 429 and the logout never reaches the server AT ALL. That was a deadlock:
651
+ * the cap blocked the one call that clears the cap. We authenticate with the
652
+ * PARENT instead, which is never subject to the cap.
653
+ *
654
+ * The parent's stored access token is usually expired (it is minted once at /login
655
+ * and never rotated — the refresh token is the liveness proof), so we rotate for a
656
+ * fresh one first. Rotation is free here precisely because we are destroying the
657
+ * credential either way: nothing downstream needs the token we burn. rotateSession
658
+ * is the no-ownership-side-effects variant, so this cannot clobber the registry
659
+ * entry of a session we are about to revoke wholesale anyway.
660
+ *
661
+ * DELETE /auth/session/current then revokes the parent's family, which the server
662
+ * cascades to every row with `parentFamilyId === familyId` — i.e. all this machine's
663
+ * terminals, including the orphans left by terminals that died without their
664
+ * shutdown hook. That cascade is what makes an accumulated cap self-clearing:
665
+ * logout, log back in, and the machine starts from zero live children.
666
+ *
667
+ * Local state is wiped unconditionally at the end, whatever the network did. A
668
+ * logout that can't reach the server must still leave you logged out locally —
669
+ * the rows it failed to revoke age out on their TTL.
607
670
  */
608
671
  export async function logout(): Promise<void> {
609
- try {
610
- await apiRequest("/auth/logout", { method: "POST" });
611
- } catch {
612
- /* best effort clear locally regardless */
672
+ const parent = loadCredentials();
673
+ if (parent) {
674
+ try {
675
+ const fresh = await rotateSession(parent.refreshToken);
676
+ await deleteSession(fresh.access, 5000);
677
+ } catch {
678
+ /* offline, or the login was already dead server-side — wipe locally anyway */
679
+ }
613
680
  }
681
+ // In-memory sessions are gone with the family above; drop the handles so nothing
682
+ // tries to revoke them individually on the way out.
683
+ _child = null;
684
+ _account = null;
685
+ clearOwnedSessions(); // every entry named a session the cascade just killed
614
686
  clearCredentials();
615
687
  }
616
688
 
@@ -622,7 +694,7 @@ export async function logout(): Promise<void> {
622
694
  // = the JWT's exp (so Pi refreshes just before the server would reject it). These are
623
695
  // independent of authedFetch's in-memory _child (Pi owns this credential's lifecycle).
624
696
 
625
- interface AccountCredential {
697
+ export interface AccountCredential {
626
698
  access: string;
627
699
  refresh: string;
628
700
  expires: number; // ms epoch
package/src/cli/chat.ts CHANGED
@@ -478,8 +478,8 @@ async function main() {
478
478
  });
479
479
  console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
480
480
  // Move the live session onto a confidential model right away, so the next prompt
481
- // doesn't dead-end on the keyless launch model ("No API key found for openrouter").
482
- // resolveSignedInModel prefers Tinfoil GLM 5.2, else the account's NEAR channel;
481
+ // doesn't dead-end on the launch model's missing key. resolveSignedInModel picks
482
+ // Tinfoil GLM 5.2 — direct with a Tinfoil key, over the subscription otherwise;
483
483
  // PRIVATEER_MODEL (a deliberate override) is respected and left alone.
484
484
  if (!process.env.PRIVATEER_MODEL?.trim()) {
485
485
  const target = resolveSignedInModel();
@@ -326,7 +326,7 @@ export class Daemon {
326
326
  // The account signed this daemon out server-side (revoked from the app's Linked
327
327
  // Devices). Beyond ending remote access (onTerminate), this wipes the machine
328
328
  // login: drop the relay and clear credentials, so routines/tasks stop cleanly
329
- // instead of dead-ending on a 401 each run. Stays idle until you /signin on this
329
+ // instead of dead-ending on a 401 each run. Stays idle until you /login on this
330
330
  // machine and restart the daemon (the relayTerminated guard, as with onTerminate).
331
331
  onRevoked: () => {
332
332
  this.relayTerminated = true;
@@ -334,7 +334,7 @@ export class Daemon {
334
334
  this.relay?.stop();
335
335
  this.relay = undefined;
336
336
  handleServerRevoke();
337
- log("account signed out from the app (session revoked) — cleared credentials; idle until you run /signin on this machine and restart the daemon");
337
+ log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the daemon");
338
338
  },
339
339
  onStatus: (text) => log(`relay: ${text}`),
340
340
  onDisconnected: () => {
@@ -11,6 +11,7 @@
11
11
  // only a first-ever machine login runs the device-code flow.
12
12
 
13
13
  import {
14
+ type AccountCredential,
14
15
  serverBaseUrl,
15
16
  hasCredentials,
16
17
  runDeviceLogin,
@@ -20,16 +21,18 @@ import {
20
21
  notifySignedIn,
21
22
  } from "../auth/privateer.ts";
22
23
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
23
- import { ACCOUNT_DEFAULT_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
24
+ import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
24
25
 
25
26
  // Seed/fallback catalog: registered synchronously so the account provider has real
26
27
  // models the instant it loads (before the live /api/models fetch resolves) — in
27
- // particular the signed-in default, near/zai-org/GLM-5.1-FP8, resolves at startup
28
- // without a "model not found" warning. The first entry is that default: a NEAR
29
- // confidential-compute (TEE, attestable) model the strongest privacy tier. Also the
30
- // fallback list if the live listing can't be reached.
28
+ // particular the default, tinfoil/glm-5-2, resolves at startup without a "model not
29
+ // found" warning, which matters more than ever now that a signed-OUT terminal also
30
+ // launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
31
+ // rest are the familiar names. Also the fallback list if the live listing is
32
+ // unreachable.
31
33
  const DEFAULT_MODELS = [
32
34
  ACCOUNT_DEFAULT_MODEL_ID,
35
+ ACCOUNT_NEAR_MODEL_ID,
33
36
  "anthropic/claude-sonnet-4.6",
34
37
  "openai/gpt-5.5",
35
38
  "deepseek/deepseek-v4-flash",
@@ -169,37 +172,62 @@ export const privateerOAuthProvider = {
169
172
  }
170
173
  }
171
174
  if (cb.signal?.aborted) throw new Error("Login cancelled");
172
- const creds = await acquireAccountCredential();
175
+ // Go through the process-wide, single-flighted accessor rather than acquiring
176
+ // directly. The device flow above already fired notifySignedIn, whose listeners arm
177
+ // the account channel — so a bare acquire here would race that one and mint a SECOND
178
+ // server-side session (a duplicate row in Linked Devices, and a step closer to
179
+ // 429 CHILD_SESSION_CAP). Sharing the in-flight promise makes it exactly one.
180
+ const creds = await accountCredential();
173
181
  // Seed Pi's saved model default to the account channel, so the next launch resolves
174
182
  // to a billable subscription model instead of falling through to a keyless built-in
175
183
  // (the "No API key found for openrouter" trap). No-op if the user already has a
176
184
  // chosen default. See providers/defaultModel.ts.
177
185
  ensurePiDefaultModel();
178
- // The fresh path already fired notifySignedIn (pollForToken); fire here for the
179
- // already-linked path so the header re-renders to "connected" on this terminal too.
180
- if (wasLinked) notifySignedIn();
186
+ // Announce the completed login ALWAYS, on both paths, and only now that the
187
+ // account channel actually holds a credential.
188
+ //
189
+ // The fresh path fires this once already, from pollForToken, the instant
190
+ // credentials.json is written. That's the right moment for the header, and the
191
+ // wrong one for the model: the listener that moves the live session onto an
192
+ // account model would run while this spawn was still in flight and find no key.
193
+ // Firing again here is what makes the switch land. Listeners are documented as
194
+ // idempotent (see notifySignedIn), and the model switch no-ops when it's already
195
+ // on target, so the double signal costs nothing.
196
+ notifySignedIn();
181
197
  return creds;
182
198
  },
183
199
  async refreshToken(creds: { refresh: string }) {
200
+ let next: AccountCredential;
184
201
  try {
185
- return await refreshAccountCredentials(creds.refresh);
202
+ next = await refreshAccountCredentials(creds.refresh);
186
203
  } catch {
187
204
  // Child token expired/reused → get another. acquire (not spawn) so a terminal
188
205
  // that already holds the device's last session slot can reclaim an orphan
189
206
  // instead of being refused a fresh one mid-session.
190
- return acquireAccountCredential();
207
+ next = await acquireAccountCredential();
191
208
  }
209
+ // Keep the process memo on the CURRENT token: the one it replaced is dead, and
210
+ // handing a dead token to a later arm() would 401 on the first prompt.
211
+ rememberAccountCredential(next);
212
+ return next;
192
213
  },
193
214
  getApiKey(creds: { access: string }): string {
194
215
  return creds.access;
195
216
  },
196
217
  };
197
218
 
198
- // Which privacy channel an account model routes through: NEAR confidential-compute
199
- // (TEE, attestable) for `near/`-prefixed ids, else a server-side ZDR channel.
200
- // Ported from tree-cli resolve.ts.
219
+ // Confidential-compute prefixes in the account catalog: every model the server serves
220
+ // out of a TEE. `near/` is the one we can attest end to end from here (the server
221
+ // proxies a nonce'd quote); `tinfoil/` and `phala/` are equally real enclaves whose
222
+ // attestation we cannot bind to THIS connection through the proxy — see accountPosture.
223
+ const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
224
+
225
+ // Which privacy channel an account model routes through: confidential compute (TEE)
226
+ // for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
227
+ // resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
228
+ // default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
201
229
  export function privateerChannel(modelId: string): "tee" | "zdr" {
202
- return modelId.startsWith("near/") ? "tee" : "zdr";
230
+ return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
203
231
  }
204
232
 
205
233
  export interface AccountPosture {
@@ -220,6 +248,16 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
220
248
  if (privateerChannel(modelId) === "zdr") {
221
249
  return { tier: "zdr-policy" };
222
250
  }
251
+ // Honest labelling for the non-NEAR enclaves. Tinfoil and Phala publish real
252
+ // attestations, but the server proxies the inference, so from here we cannot bind a
253
+ // quote to the connection actually carrying our tokens — only the account's word
254
+ // that it did. That's `tee-unverified` (yellow "confidential compute, unconfirmed"),
255
+ // never the green tee-verified we reserve for a quote we checked ourselves. A user
256
+ // who wants the verified shield sets TINFOIL_API_KEY and runs `tinfoil/*` direct,
257
+ // where pi-privacy attests the enclave client-side.
258
+ if (!modelId.startsWith("near/")) {
259
+ return { tier: "tee-unverified" };
260
+ }
223
261
  try {
224
262
  const res = await authedFetch(
225
263
  `${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
@@ -286,22 +324,67 @@ export function makeAccountProvider() {
286
324
  // all, and the first prompt dead-ends on "No API key found for privateer." — even
287
325
  // though the banner says "connected". The REPL (cli/chat.ts) and the daemon
288
326
  // already spawn one at startup; this gives the TUI the same seed.
289
- pi.on?.("session_start", (_e, ctx) => void ensureAccountCredential(ctx));
327
+ pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
290
328
  };
291
329
  }
292
330
 
293
- // One spawn per PROCESS. session_start also fires for new/resume/fork/reload — all of
294
- // which keep this process (and its account session) alive — so re-spawning there would
295
- // leak a device row per event. A fresh process always spawns: a run that crashed
296
- // without its shutdown hook can leave a REVOKED credential persisted in auth.json with
297
- // a still-valid-looking `expires`, which Pi would happily reuse and 401 on.
331
+ // ── Arming the account channel ───────────────────────────────────────────────
332
+ //
333
+ // ONE account session per PROCESS. Every mint is expensive and visible: it's a row in
334
+ // the app's Linked Devices list, and the server caps how many a device may hold
335
+ // (429 CHILD_SESSION_CAP). session_start alone fires for new/resume/fork/reload, and a
336
+ // mid-session /login wants the channel armed too — so the credential is minted once
337
+ // and then remembered, and later callers reuse it instead of stacking another row.
338
+ //
339
+ // Both the memo and its in-flight promise live on globalThis rather than in module
340
+ // scope, because jiti gives each extension its OWN instance of this file (see the note
341
+ // in auth/privateer.ts). privateer-account seeds at launch and privateer-brand arms
342
+ // after a sign-in; module-scoped state would let each mint its own session.
298
343
  //
299
- // The flag lives on globalThis, not in module scope, because jiti gives each extension
300
- // that imports this file its OWN module instance (see the note in auth/privateer.ts):
301
- // privateer-account and privateer-brand which hot-registers the provider on /signin —
302
- // would otherwise hold separate flags and each spawn a session.
303
- const SEEDED = Symbol.for("privateer.accountCredentialSeeded");
304
- type SeedFlag = { [SEEDED]?: boolean };
344
+ // A fresh PROCESS always mints: a run that crashed without its shutdown hook can leave
345
+ // a REVOKED credential persisted in auth.json with a still-valid-looking `expires`,
346
+ // which Pi would happily reuse and 401 on.
347
+ const ARMED = Symbol.for("privateer.accountCredential");
348
+ type ArmedSlot = {
349
+ [ARMED]?: { cred?: AccountCredential; inFlight?: Promise<AccountCredential> };
350
+ };
351
+
352
+ function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
353
+ const g = globalThis as ArmedSlot;
354
+ return (g[ARMED] ??= {});
355
+ }
356
+
357
+ // Record a credential this process minted so nothing mints a second one. Exported for
358
+ // the OAuth login path, which acquires its credential for Pi to own and would
359
+ // otherwise leave the next arm() with nothing to reuse.
360
+ export function rememberAccountCredential(cred: AccountCredential): void {
361
+ armSlot().cred = cred;
362
+ }
363
+
364
+ // The remembered credential, if it's still usable. A minute of headroom: handing back
365
+ // one that expires mid-request just trades a spawn for a 401.
366
+ function liveAccountCredential(): AccountCredential | undefined {
367
+ const cred = armSlot().cred;
368
+ return cred && cred.expires > Date.now() + 60_000 ? cred : undefined;
369
+ }
370
+
371
+ // Get this process's account credential, minting one only if we don't already hold a
372
+ // live one. Single-flighted, so two callers racing (session_start and a sign-in) share
373
+ // one spawn rather than opening two sessions.
374
+ async function accountCredential(): Promise<AccountCredential> {
375
+ const live = liveAccountCredential();
376
+ if (live) return live;
377
+ const slot = armSlot();
378
+ slot.inFlight ??= acquireAccountCredential()
379
+ .then((cred) => {
380
+ slot.cred = cred;
381
+ return cred;
382
+ })
383
+ .finally(() => {
384
+ slot.inFlight = undefined;
385
+ });
386
+ return slot.inFlight;
387
+ }
305
388
 
306
389
  // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
307
390
  // path privateer-brand uses to DROP the credential on sign-out).
@@ -311,23 +394,35 @@ type SeedContext = {
311
394
  ui?: { notify?: (message: string, level: string) => void };
312
395
  };
313
396
 
314
- async function ensureAccountCredential(ctx: unknown): Promise<void> {
315
- const flag = globalThis as SeedFlag;
316
- if (flag[SEEDED] || !hasCredentials()) return;
317
- flag[SEEDED] = true;
397
+ // Put a working account credential into Pi's auth store, so `privateer/*` models can
398
+ // actually run. Called at session_start (the launch seed) and again right after a
399
+ // sign-in (see the brand extension) — a mid-session /login has to arm the channel
400
+ // itself, because Pi writes an OAuth credential only for a login IT drove, never for
401
+ // our own /login device-code command.
402
+ //
403
+ // Returns true when the channel is armed. `notify` controls whether a failure is
404
+ // announced: the launch seed says so out loud, while a caller that reports the outcome
405
+ // itself (the sign-in path) passes false so the user doesn't read it twice.
406
+ export async function armAccountCredential(
407
+ ctx: unknown,
408
+ opts: { notify?: boolean } = {},
409
+ ): Promise<boolean> {
410
+ if (!hasCredentials()) return false;
318
411
  const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
319
- if (typeof store?.set !== "function") return;
412
+ if (typeof store?.set !== "function") return false;
320
413
  try {
321
- const creds = await acquireAccountCredential();
322
- store.set("privateer", { type: "oauth", ...creds });
414
+ store.set("privateer", { type: "oauth", ...(await accountCredential()) });
415
+ return true;
323
416
  } catch (e) {
324
417
  // The account channel is NOT armed: a dead machine login (401 → credentials cleared
325
418
  // + onSessionExpired), the terminal cap (429), or a network blip. Say so now — the
326
419
  // banner still reads "connected" (it only knows about the local credentials file),
327
420
  // so staying silent leaves the user to discover it as a bare "No API key found for
328
- // privateer" on their first prompt. Cleared so a later attempt can retry.
329
- flag[SEEDED] = false;
421
+ // privateer" on their first prompt.
330
422
  const c = ctx as SeedContext;
331
- if (c?.hasUI) c.ui?.notify?.(`Privateer account channel unavailable ${(e as Error).message}`, "error");
423
+ if (opts.notify !== false && c?.hasUI) {
424
+ c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
425
+ }
426
+ return false;
332
427
  }
333
428
  }
@@ -15,24 +15,35 @@ import { join } from "node:path";
15
15
  import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
17
17
 
18
- // The signed-in default: a NEAR confidential-compute (TEE, attestable) model the
19
- // strongest privacy tier the account channel offers, and the same id the app shows
20
- // first. Kept here as the one definition; providers/account.ts imports it so its seed
21
- // catalog can't drift.
22
- export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
18
+ // Tinfoil's most capable chat model, and Privateer's default everywhere. Tinfoil runs
19
+ // GLM 5.2 inside an attestable TEE (the serving enclave's quote is published and the
20
+ // live TLS key is bound to it), which is the strongest privacy tier we offer so the
21
+ // most capable model on that tier is what a privacy-first agent should boot on.
22
+ // One definition, three consumers: this resolver, providers/account.ts's seed catalog,
23
+ // and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
24
+ export const TINFOIL_MODEL_ID = "tinfoil/glm-5-2";
25
+
26
+ // Same model, reached two ways:
27
+ // - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
28
+ // TINFOIL_API_KEY, where pi-privacy can CLIENT-attest the enclave live.
29
+ // - ACCOUNT_DEFAULT_SPEC — through the Privateer subscription (the `privateer`
30
+ // provider proxies it), so a signed-in user needs no BYO key at all.
31
+ // The direct route wins when a key is present; otherwise being signed in is enough.
32
+ export const TINFOIL_DEFAULT_SPEC = TINFOIL_MODEL_ID;
33
+ export const ACCOUNT_DEFAULT_MODEL_ID = TINFOIL_MODEL_ID;
23
34
  export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
24
35
 
25
- // Tinfoil's GLM 5.2CLIENT-side-attested TEE inference (the live TLS key is bound to
26
- // the enclave's quote), the strongest privacy tier we offer, stronger than the account's
27
- // server-proxied NEAR channel. Preferred whenever a Tinfoil key is present. Kept as the
28
- // one definition so bin/privateer-tui and this resolver agree. See extensions/privateer-
29
- // privacy.ts, which registers `tinfoil/glm-5-2` (and friends) on the tinfoil provider.
30
- export const TINFOIL_DEFAULT_SPEC = "tinfoil/glm-5-2";
31
-
32
- // Last-resort BYO default, preserved from the pre-resolver code so a user who set an
33
- // OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
34
- // either, this still surfaces the familiar "No API key found for openrouter" a clear
35
- // signal to run /login or set a key, which is better than an empty/undefined model.
36
+ // The account channel's NEAR confidential-compute model no longer the default, but
37
+ // still the one account model we can attest end-to-end through the server proxy, so
38
+ // it stays first in the seed catalog after the default. See providers/account.ts.
39
+ export const ACCOUNT_NEAR_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
40
+
41
+ // The legacy BYO default, kept ONLY for a user who set an OpenRouter key and isn't
42
+ // signed in — it's what their key actually pays for. It is deliberately no longer the
43
+ // keyless fallback: landing a signed-out, keyless terminal on OpenRouter is what
44
+ // produced the "No API key found for openrouter" dead end that /login couldn't
45
+ // explain. With no key and no login we now point at the account channel instead, so
46
+ // the error names Privateer and /login is visibly the fix.
36
47
  export const LEGACY_BYO_FALLBACK = "openrouter/openai/gpt-4o-mini";
37
48
 
38
49
  // BYO providers we can positively detect from the environment, in preference order.
@@ -56,14 +67,15 @@ export interface ResolveDefaultModelOptions {
56
67
 
57
68
  // Resolve the model spec ("provider/id") to use when no model is named. Pure and
58
69
  // synchronous (only reads env + the credentials file), so it's safe to call from any
59
- // entry point at startup. Precedence (mirrors bin/privateer-tui's launch logic, so the
60
- // launcher, the REPL, and the next-launch seed all agree):
70
+ // entry point at startup. Precedence (mirrors bin/privateer-launch.mjs's launch logic,
71
+ // so the launcher, the REPL, and the next-launch seed all agree):
61
72
  // 1. explicit user choice (config/channel) — deliberate, always wins
62
73
  // 2. PRIVATEER_MODEL env — dev/global override
63
74
  // 3. Tinfoil key present → Tinfoil GLM 5.2 — strongest (client-attested) privacy
64
- // 4. signed into Privateer → the account default subscription users, no BYO key
75
+ // 4. signed into Privateer → the same model over the subscription
65
76
  // 5. a BYO provider whose key is present — anthropic, openai, openrouter
66
- // 6. LEGACY_BYO_FALLBACK familiar "add a key" signal
77
+ // 6. nothing at all → the account default anyway so the failure names Privateer
78
+ // and /login is the visible fix, instead of a keyless OpenRouter dead end
67
79
  export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
68
80
  const env = opts.env ?? process.env;
69
81
 
@@ -84,15 +96,19 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
84
96
  if (env[keyName]?.trim()) return spec;
85
97
  }
86
98
 
87
- return LEGACY_BYO_FALLBACK;
99
+ // No key, no login. Point at the account channel regardless: it's the model this
100
+ // terminal will run the moment they /login, so signing in needs no model switch at
101
+ // all, and until then the error reads "No API key found for privateer" — which our
102
+ // guidance turns into "you're not signed in · run /login".
103
+ return ACCOUNT_DEFAULT_SPEC;
88
104
  }
89
105
 
90
106
  // The confidential model to switch the LIVE session onto the moment a user signs in.
91
- // A terminal launched with no credentials is pinned by `--model` to the keyless
92
- // OpenRouter fallback; without an in-session switch it stays there and the first prompt
93
- // after /login dead-ends on "No API key found for openrouter". This resolves the model
94
- // sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2 when a key is present, otherwise
95
- // the account's NEAR confidential channel (billable to the subscription, no BYO key).
107
+ // A terminal launched with a BYO key (or an explicit --model) is pinned to whatever it
108
+ // resolved at launch; without an in-session switch a mid-session /login changes nothing
109
+ // visible and the user is left wondering what signing in bought them. This resolves the
110
+ // model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
111
+ // is present and over the subscription otherwise no BYO key needed.
96
112
  // PRIVATEER_MODEL still wins — a deliberate override is never stomped.
97
113
  export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
98
114
  return resolveDefaultModel({ env, signedIn: true });