privateer-agent 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,12 +14,17 @@ import {
14
14
  type AccountCredential,
15
15
  serverBaseUrl,
16
16
  hasCredentials,
17
+ currentUser,
18
+ logout,
17
19
  runDeviceLogin,
18
20
  authedFetch,
19
21
  acquireAccountCredential,
20
22
  refreshAccountCredentials,
21
23
  notifySignedIn,
22
24
  } from "../auth/privateer.ts";
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { globalDir } from "../config/paths.ts";
23
28
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
24
29
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
25
30
  import {
@@ -36,7 +41,8 @@ import {
36
41
  // found" warning, which matters more than ever now that a signed-OUT terminal also
37
42
  // launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
38
43
  // rest are the familiar names. Also the fallback list if the live listing is
39
- // unreachable.
44
+ // unreachable. It is the FLOOR of the synchronous seed, not the whole of it — a launch
45
+ // that has seen the live catalog before seeds from the cache too (see seedCatalogIds).
40
46
  const DEFAULT_MODELS = [
41
47
  ACCOUNT_DEFAULT_MODEL_ID,
42
48
  ACCOUNT_NEAR_MODEL_ID,
@@ -58,6 +64,72 @@ function seedModel(id: string) {
58
64
  };
59
65
  }
60
66
 
67
+ // ── Catalog cache ────────────────────────────────────────────────────────────
68
+ //
69
+ // The live catalog (241 models and counting) can only be registered once the network
70
+ // fetch resolves, and a registration made after extension load does NOT reach the model
71
+ // registry immediately: pi queues it (extensions/loader.js pendingProviderRegistrations)
72
+ // and flushes it when the session BINDS. Everything that resolves a model at LAUNCH runs
73
+ // before that — Pi's findInitialModel (saved settings default) and its session-model
74
+ // restore both call modelRegistry.find() while only the synchronous seed exists. So a
75
+ // model outside DEFAULT_MODELS was un-resolvable at launch and Pi fell through to "first
76
+ // model with configured auth", i.e. a BYO provider — measurably `openrouter/*` on a
77
+ // machine with an OpenRouter key. That is the dead end defaultModel.ts exists to prevent,
78
+ // re-entered through the back door.
79
+ //
80
+ // So: remember the ids the live catalog last returned, and seed from them SYNCHRONOUSLY
81
+ // on the next launch. The live fetch still re-registers the authoritative list moments
82
+ // later, so a model the server drops disappears on the next launch rather than lingering.
83
+ //
84
+ // Deliberately ids ONLY. Privacy tiers are never cached: the tier drives the shield in
85
+ // /models, and a stale privacy claim is exactly the thing not to render from disk. The
86
+ // picker keeps fetching them live (accountCatalogLoaded stays false until it does).
87
+ const CATALOG_CACHE_MAX = 2000; // bound what a corrupted/hostile file can register
88
+
89
+ function catalogCachePath(): string {
90
+ return join(globalDir(), "account-models.json");
91
+ }
92
+
93
+ // Best effort in both directions: this cache is an optimization, and a launch must never
94
+ // fail because it couldn't be read or written.
95
+ function saveCachedCatalogIds(ids: string[]): void {
96
+ try {
97
+ mkdirSync(globalDir(), { recursive: true });
98
+ const payload = { v: 1, fetchedAt: new Date().toISOString(), ids: ids.slice(0, CATALOG_CACHE_MAX) };
99
+ writeFileSync(catalogCachePath(), JSON.stringify(payload) + "\n", "utf8");
100
+ } catch {
101
+ /* unwritable home — we just seed from DEFAULT_MODELS next launch */
102
+ }
103
+ }
104
+
105
+ export function loadCachedCatalogIds(): string[] {
106
+ try {
107
+ const path = catalogCachePath();
108
+ if (!existsSync(path)) return [];
109
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { ids?: unknown };
110
+ if (!Array.isArray(parsed.ids)) return [];
111
+ return parsed.ids.filter((id): id is string => typeof id === "string" && id.length > 0).slice(0, CATALOG_CACHE_MAX);
112
+ } catch {
113
+ return []; // absent, unreadable, or garbage — the seed list still works
114
+ }
115
+ }
116
+
117
+ // The ids to register synchronously at load. DEFAULT_MODELS FIRST and always: the account
118
+ // default has to be index 0 both because it must always resolve and because Pi clones the
119
+ // provider's first/default model when it synthesizes a custom model id
120
+ // (model-resolver.js buildFallbackModel).
121
+ export function seedCatalogIds(): string[] {
122
+ const ids = [...DEFAULT_MODELS];
123
+ const seen = new Set(ids);
124
+ for (const id of loadCachedCatalogIds()) {
125
+ if (!seen.has(id)) {
126
+ seen.add(id);
127
+ ids.push(id);
128
+ }
129
+ }
130
+ return ids;
131
+ }
132
+
61
133
  // One catalog entry with its server-asserted baseline privacy tier.
62
134
  export interface AccountModelInfo {
63
135
  id: string;
@@ -129,6 +201,9 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
129
201
  const parsed = (data.models ?? [])
130
202
  .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
131
203
  .filter((x): x is AccountModelInfo => !!x);
204
+ // Cache only a real LIVE listing — never the fallback, which would freeze the six
205
+ // seed ids on disk and read back as though it were the catalog.
206
+ if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
132
207
  infos = parsed.length ? parsed : fallback();
133
208
  }
134
209
  } catch {
@@ -144,6 +219,23 @@ export async function fetchAccountModels(): Promise<string[]> {
144
219
  return (await fetchAccountCatalog()).map((m) => m.id);
145
220
  }
146
221
 
222
+ // The device-code verification link, as an ABSOLUTE url.
223
+ //
224
+ // The server sends it scheme-less ("www.privateer.pro/settings/link-terminal?code=…"),
225
+ // and both surfaces that show it treat it as a URL: Pi's login dialog wraps it in an
226
+ // OSC-8 terminal hyperlink, and our own /login widget prints it for the user to open.
227
+ // Without a scheme an OSC-8 target isn't a valid URI, so terminals decline to linkify
228
+ // it — the one link in the sign-in flow becomes unclickable text. Prefix https:// when
229
+ // the value has no scheme of its own, and leave a well-formed (or empty) value alone.
230
+ // Only http/https are honoured: a scheme-looking prefix we don't expect is treated as a
231
+ // hostname rather than passed through to a terminal as a clickable link.
232
+ export function verificationLink(raw: string | undefined): string {
233
+ const uri = (raw ?? "").trim();
234
+ if (!uri) return "";
235
+ if (/^https?:\/\//i.test(uri)) return uri;
236
+ return `https://${uri.replace(/^\/+/, "")}`;
237
+ }
238
+
147
239
  // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
148
240
  // the provider name). login/refreshToken/getApiKey are the whole contract.
149
241
  export const privateerOAuthProvider = {
@@ -155,12 +247,40 @@ export const privateerOAuthProvider = {
155
247
  // promise never settles, and Pi never restores the editor: the "Waiting for
156
248
  // authentication…" screen hangs with no way out. See auth/privateer.ts
157
249
  // pollForToken, which checks the signal and rejects with "Login cancelled.".
158
- async login(cb: { onDeviceCode?: (info: unknown) => void; signal?: AbortSignal }) {
250
+ async login(cb: {
251
+ onDeviceCode?: (info: unknown) => void;
252
+ onSelect?: (prompt: { message: string; options: { id: string; label: string }[] }) => Promise<string | undefined>;
253
+ signal?: AbortSignal;
254
+ }) {
159
255
  // Fresh machine? The device-code flow below fires notifySignedIn itself (via
160
256
  // pollForToken). Already linked? No device code runs — so we announce the
161
257
  // completed subscription login ourselves at the end, or the header/badge would
162
258
  // keep showing "not signed in" until the next launch.
163
- const wasLinked = hasCredentials();
259
+ //
260
+ // Already linked AND the user wants a DIFFERENT account is the third case, and it
261
+ // used to be unreachable: login() short-circuited on hasCredentials(), so choosing
262
+ // "Privateer account" while linked silently re-armed the account already signed in
263
+ // and reported success — there was no way to switch accounts from here at all. Pi
264
+ // hands us an `onSelect` callback for exactly this, so ask. Switching means signing
265
+ // this machine out first (logout revokes the machine's whole token family), which
266
+ // the prompt says plainly; the device flow then runs as if fresh.
267
+ let wasLinked = hasCredentials();
268
+ if (wasLinked && cb.onSelect) {
269
+ const user = currentUser();
270
+ const who = user?.email ?? user?.id ?? "this account";
271
+ const choice = await cb.onSelect({
272
+ message: `Already signed in as ${who}.`,
273
+ options: [
274
+ { id: "keep", label: `Stay signed in as ${who}` },
275
+ { id: "switch", label: "Sign in as a different account (signs this machine out first)" },
276
+ ],
277
+ });
278
+ if (choice === undefined) throw new Error("Login cancelled"); // selector dismissed
279
+ if (choice === "switch") {
280
+ await logout(); // revokes this machine's sessions and wipes local auth state
281
+ wasLinked = false; // fall through to the device flow as a fresh machine
282
+ }
283
+ }
164
284
  if (!wasLinked) {
165
285
  try {
166
286
  await runDeviceLogin({
@@ -168,7 +288,9 @@ export const privateerOAuthProvider = {
168
288
  onCode: (code) =>
169
289
  cb.onDeviceCode?.({
170
290
  userCode: code.user_code,
171
- verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
291
+ // Absolute url the server's value is scheme-less and Pi renders this as
292
+ // a terminal hyperlink. See verificationLink.
293
+ verificationUri: verificationLink(code.verification_uri_complete ?? code.verification_uri),
172
294
  intervalSeconds: code.interval,
173
295
  expiresInSeconds: code.expires_in,
174
296
  }),
@@ -207,9 +329,19 @@ export const privateerOAuthProvider = {
207
329
  return creds;
208
330
  },
209
331
  async refreshToken(creds: { refresh: string }) {
332
+ // Rotate THIS process's own session, never another terminal's. Pi keeps one
333
+ // credential per provider in auth.json and that file is machine-global, so the
334
+ // credential handed to us here can belong to a different, still-running terminal
335
+ // (see the ownership note above rememberAccountCredential). Rotating that one
336
+ // would take over its session and invalidate the copy it still holds — the exact
337
+ // reuse hazard the child-session split exists to avoid (auth/privateer.ts). When
338
+ // the incoming token isn't ours, rotate ours instead: Pi gets a valid credential
339
+ // either way, and the other terminal keeps its own.
340
+ const mine = armSlot().cred?.refresh;
341
+ const refresh = mine && creds.refresh !== mine ? mine : creds.refresh;
210
342
  let next: AccountCredential;
211
343
  try {
212
- next = await refreshAccountCredentials(creds.refresh);
344
+ next = await refreshAccountCredentials(refresh);
213
345
  } catch {
214
346
  // Child token expired/reused → get another. acquire (not spawn) so a terminal
215
347
  // that already holds the device's last session slot can reclaim an orphan
@@ -297,6 +429,51 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
297
429
  }
298
430
  }
299
431
 
432
+ // A model entry, with a per-model baseUrl override once the EHBP shim is listening:
433
+ // `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
434
+ // instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
435
+ // baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
436
+ // the cleartext path — and the badge stays honestly `tee-unverified` (see accountPosture).
437
+ function modelEntry(id: string) {
438
+ const base = seedModel(id);
439
+ const provider = sealedEnabled() ? sealedProviderFor(id) : null;
440
+ const shim = sealedShimBase();
441
+ return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
442
+ }
443
+
444
+ // The Pi provider config for the account channel, over a given set of model ids.
445
+ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
446
+ return {
447
+ name: "Privateer account",
448
+ baseUrl: `${serverBaseUrl()}/api/agent/v1`,
449
+ api: "openai-completions",
450
+ oauth: privateerOAuthProvider,
451
+ models: ids.map(modelEntry),
452
+ };
453
+ }
454
+
455
+ // Re-assert the account channel's registration from ANOTHER extension.
456
+ //
457
+ // pi-privacy also ships a `privateer` provider (its PRIVACY_PROVIDERS catalog) — the
458
+ // PUBLIC developer-key channel: baseUrl api.privateer.pro/v1, `${PRIVATEER_API_KEY}`,
459
+ // and a single seed model (near/zai-org/GLM-5.1-FP8). Pi's registerProvider FULLY
460
+ // REPLACES a provider's model list and its request config, so whichever registration
461
+ // lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
462
+ // on a typical box puts privateer-privacy after privateer-account. The account channel's
463
+ // whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
464
+ // longer resolved ("not found for provider privateer. Using custom model id") and the
465
+ // synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
466
+ //
467
+ // So privateer-privacy.ts calls this right after pi-privacy runs, exactly as it re-widens
468
+ // `tinfoil`. Idempotent and order-independent: if privacy happens to load first, the
469
+ // account extension's own registration lands afterwards with the same config, and the
470
+ // live-catalog fetch re-registers over both moments later either way.
471
+ export function registerAccountModels(pi: {
472
+ registerProvider?: (name: string, config: unknown) => void;
473
+ }): void {
474
+ pi.registerProvider?.("privateer", accountProviderConfig(seedCatalogIds()));
475
+ }
476
+
300
477
  // Extension factory: registers the account provider so `/login` can offer it.
301
478
  //
302
479
  // We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
@@ -322,30 +499,15 @@ export function makeAccountProvider() {
322
499
  on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
323
500
  }): void => {
324
501
  if (typeof pi.registerProvider !== "function") return;
325
- // A model entry, with a per-model baseUrl override for sealed models once the
326
- // EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
327
- // seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
328
- // Everything else keeps the provider baseUrl below. Until the shim is up (or when
329
- // sealed mode is off) sealed models fall back to the cleartext path — and the
330
- // badge stays honestly `tee-unverified` (see accountPosture).
331
- const modelEntry = (id: string) => {
332
- const base = seedModel(id);
333
- const provider = sealedEnabled() ? sealedProviderFor(id) : null;
334
- const shim = sealedShimBase();
335
- return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
336
- };
337
- let lastIds: string[] = DEFAULT_MODELS;
502
+ // Seed with the last live catalog when we have one (see seedCatalogIds): this is the
503
+ // list Pi resolves a saved default / a restored session model against at launch,
504
+ // before the live re-registration can reach the registry.
505
+ let lastIds: string[] = seedCatalogIds();
338
506
  const register = (ids: string[]): void => {
339
507
  lastIds = ids;
340
- pi.registerProvider!("privateer", {
341
- name: "Privateer account",
342
- baseUrl: `${serverBaseUrl()}/api/agent/v1`,
343
- api: "openai-completions",
344
- oauth: privateerOAuthProvider,
345
- models: ids.map(modelEntry),
346
- });
508
+ pi.registerProvider!("privateer", accountProviderConfig(ids));
347
509
  };
348
- register(DEFAULT_MODELS); // immediate: provider exists this tick
510
+ register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
349
511
  // Bring up the sealed shim, then re-register so sealed models pick up their shim
350
512
  // baseUrl. Registration re-runs anyway after the catalog fetch; this just makes
351
513
  // sure the switch lands even if the fetch is slow or fails.
@@ -373,6 +535,32 @@ export function makeAccountProvider() {
373
535
  // though the banner says "connected". The REPL (cli/chat.ts) and the harbor
374
536
  // already spawn one at startup; this gives the TUI the same seed.
375
537
  pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
538
+
539
+ // Two safety nets around the account channel, both OUTSIDE the request path —
540
+ // they read Pi's in-memory auth map and its finished messages, and never touch the
541
+ // inference request itself, so a healthy prompt behaves exactly as before.
542
+ //
543
+ // before_agent_start: re-arm if Pi's persisted credential vanished mid-session.
544
+ // Awaited (Pi awaits extension handlers), so the turn starts with a key rather than
545
+ // failing and asking the user to resend. See ensureAccountArmed.
546
+ pi.on?.("before_agent_start", async (_e, ctx) => {
547
+ try {
548
+ await ensureAccountArmed(ctx);
549
+ } catch {
550
+ /* the turn's own error path reports better than a diagnostic here */
551
+ }
552
+ });
553
+
554
+ // message_end: an assistant turn that ended in an auth error on the account channel
555
+ // means our session token is dead server-side. Pi has no reactive-401 refresh, so
556
+ // replace the session now instead of failing every prompt until `expires`.
557
+ // See recoverAccountSession.
558
+ pi.on?.("message_end", (e, ctx) => {
559
+ const msg = (e as { message?: { role?: string; stopReason?: string; errorMessage?: string } })?.message;
560
+ if (msg?.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;
561
+ if ((ctx as SeedContext)?.model?.provider !== "privateer") return;
562
+ void recoverAccountSession(ctx, msg.errorMessage);
563
+ });
376
564
  };
377
565
  }
378
566
 
@@ -394,7 +582,12 @@ export function makeAccountProvider() {
394
582
  // which Pi would happily reuse and 401 on.
395
583
  const ARMED = Symbol.for("privateer.accountCredential");
396
584
  type ArmedSlot = {
397
- [ARMED]?: { cred?: AccountCredential; inFlight?: Promise<AccountCredential> };
585
+ [ARMED]?: {
586
+ cred?: AccountCredential;
587
+ inFlight?: Promise<AccountCredential>;
588
+ // When we last replaced a failed session (recoverAccountSession's cooldown).
589
+ recoveredAt?: number;
590
+ };
398
591
  };
399
592
 
400
593
  function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
@@ -405,10 +598,29 @@ function armSlot(): NonNullable<ArmedSlot[typeof ARMED]> {
405
598
  // Record a credential this process minted so nothing mints a second one. Exported for
406
599
  // the OAuth login path, which acquires its credential for Pi to own and would
407
600
  // otherwise leave the next arm() with nothing to reuse.
601
+ //
602
+ // This memo is also the OWNERSHIP record for Pi's persisted credential, which matters
603
+ // because auth.json holds exactly one `privateer` entry and is shared by every
604
+ // Privateer terminal on the machine. Each terminal mints its own session at launch, so
605
+ // the last one to start wins on disk and the entry any terminal reads back may belong
606
+ // to a different, still-running terminal. Two rules follow, both keyed off this memo:
607
+ //
608
+ // 1. Only DROP the persisted entry when it's the one we minted
609
+ // (dropPersistedAccountCredential). The exit hooks used to remove it
610
+ // unconditionally, so quitting one terminal deleted a live terminal's credential.
611
+ // That terminal kept working from Pi's in-memory copy until `expires`, then Pi
612
+ // reloaded auth.json, found nothing, and every prompt dead-ended on "No API key
613
+ // found for privateer" — with nothing to re-arm it before the next session_start.
614
+ // 2. Only ROTATE our own refresh token (privateerOAuthProvider.refreshToken).
408
615
  export function rememberAccountCredential(cred: AccountCredential): void {
409
616
  armSlot().cred = cred;
410
617
  }
411
618
 
619
+ // The credential this process minted, if any. Ownership probe for the two rules above.
620
+ export function ownedAccountCredential(): AccountCredential | undefined {
621
+ return armSlot().cred;
622
+ }
623
+
412
624
  // The remembered credential, if it's still usable. A minute of headroom: handing back
413
625
  // one that expires mid-request just trades a spawn for a 401.
414
626
  function liveAccountCredential(): AccountCredential | undefined {
@@ -437,7 +649,14 @@ async function accountCredential(): Promise<AccountCredential> {
437
649
  // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
438
650
  // path privateer-brand uses to DROP the credential on sign-out).
439
651
  type SeedContext = {
440
- modelRegistry?: { authStorage?: { set?: (provider: string, cred: unknown) => void } };
652
+ modelRegistry?: {
653
+ authStorage?: {
654
+ set?: (provider: string, cred: unknown) => void;
655
+ get?: (provider: string) => unknown;
656
+ remove?: (provider: string) => void;
657
+ };
658
+ };
659
+ model?: { provider?: string; id?: string };
441
660
  hasUI?: boolean;
442
661
  ui?: { notify?: (message: string, level: string) => void };
443
662
  };
@@ -474,3 +693,120 @@ export async function armAccountCredential(
474
693
  return false;
475
694
  }
476
695
  }
696
+
697
+ // Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
698
+ // ONLY when it's the one this process minted. Every exit path pairs a session revoke
699
+ // with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
700
+ // credential on the next launch and refreshes it only on expiry, never reactively on a
701
+ // 401, so leaving a revoked token behind dead-ends the next run.
702
+ //
703
+ // The ownership check is what keeps that teardown from harming a CONCURRENT terminal.
704
+ // auth.json is machine-global with one entry per provider, so the entry on disk is
705
+ // whichever terminal armed last; an unconditional remove here deleted a live
706
+ // terminal's key and stranded it (see the note above rememberAccountCredential).
707
+ // Returns true only if the entry was actually removed.
708
+ //
709
+ // `force` is for the teardowns where ownership is irrelevant because NOTHING on this
710
+ // machine can use the entry any more: an explicit sign-out and a detected
711
+ // expiry/revocation both go through logout()/clearCredentials(), which revoke the
712
+ // machine's whole token family — every terminal's session included. Those callers have
713
+ // also already wiped the local credentials, so the ownership memo is gone by then and an
714
+ // ownership-checked drop would refuse and strand a dead entry on disk.
715
+ export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boolean } = {}): boolean {
716
+ const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
717
+ const mine = ownedAccountCredential()?.access;
718
+ // Every caller revokes this process's session immediately before calling us, so drop
719
+ // the memo whatever happens to the entry on disk — otherwise a later arm() in this
720
+ // process (a harbor run's session_start, say) could re-persist the revoked token.
721
+ armSlot().cred = undefined;
722
+ if (typeof store?.remove !== "function") return false;
723
+ try {
724
+ if (opts.force) {
725
+ /* whole family revoked — the entry is dead for every terminal, drop it */
726
+ } else if (typeof store.get === "function") {
727
+ const persisted = store.get("privateer") as { access?: unknown } | undefined;
728
+ if (!persisted) return false; // nothing persisted — nothing to drop
729
+ // Some other terminal's session. Leave it: it's the key that terminal is using.
730
+ if (typeof persisted.access === "string" && persisted.access !== mine) return false;
731
+ } else if (!mine) {
732
+ // Older Pi with no readable auth store AND we never minted anything — we have no
733
+ // basis to claim the entry, so leave it alone rather than break another terminal.
734
+ return false;
735
+ }
736
+ store.remove("privateer");
737
+ return true;
738
+ } catch {
739
+ return false; // older Pi shape / unwritable store — the server TTL is the fallback
740
+ }
741
+ }
742
+
743
+ // Make sure Pi still HAS an account credential before a turn goes out. Normally
744
+ // session_start's arm is enough and this is a free in-memory lookup. It exists for the
745
+ // case session_start can't cover: the persisted entry disappearing mid-session, which
746
+ // another terminal's exit could do (and still can, for a terminal running an older
747
+ // build without the ownership check above). Once the entry is gone Pi has no path back
748
+ // — getApiKey returns undefined and nothing re-arms until the next session_start — so
749
+ // every prompt fails on "No API key found for privateer".
750
+ //
751
+ // An entry that exists but has EXPIRED is deliberately left alone: that's Pi's own
752
+ // refresh path (refreshToken), and pre-empting it would mint a second session row.
753
+ export async function ensureAccountArmed(ctx: unknown): Promise<void> {
754
+ if (!hasCredentials()) return;
755
+ const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
756
+ if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
757
+ try {
758
+ if (store.get("privateer")) return;
759
+ } catch {
760
+ return;
761
+ }
762
+ await armAccountCredential(ctx, { notify: false });
763
+ }
764
+
765
+ // Errors that mean "the token we're presenting is no longer accepted", as opposed to a
766
+ // cap, a network blip, or a model error. Matched against the provider error text Pi
767
+ // surfaces (the OpenAI SDK prefixes the status, and our backend sends `code` +
768
+ // `message`), so a revoked or dead session is recognizable without parsing internals.
769
+ // "No API key ... privateer" covers both wordings that mean the channel wasn't armed:
770
+ // pi-ai's "No API key for provider: privateer" (thrown when getApiKey resolves to
771
+ // undefined, which is also what a swallowed refresh failure looks like — pi-ai flattens
772
+ // our reason into "Failed to refresh OAuth token") and Pi's own "No API key found for
773
+ // privateer." guidance text.
774
+ const ACCOUNT_AUTH_FAILURE =
775
+ /\b401\b|SESSION_REVOKED|Authentication required|Invalid token|Failed to refresh OAuth token|No API key\b[^\n]*privateer/i;
776
+
777
+ // Don't spin: if the account itself is gone, one replacement attempt per window is
778
+ // plenty, and the user gets a clear message instead of a retry loop.
779
+ const RECOVERY_COOLDOWN_MS = 30_000;
780
+
781
+ // Replace a dead account session after an auth failure, so the NEXT prompt works.
782
+ //
783
+ // This is the client-side answer to a gap in Pi's OAuth contract: inference goes out
784
+ // over Pi's own HTTP path (provider baseUrl + the bearer from getApiKey), which has no
785
+ // reactive-401 hook — unlike authedFetch, which refreshes and retries in place. Pi
786
+ // refreshes on `expires` alone, so a session that dies server-side early (revoked from
787
+ // the app's Linked Devices, or cascaded by another device's logout) stays dead for the
788
+ // remaining lifetime of the access token — up to ~24h — with every prompt failing.
789
+ // Detecting the failure after the fact and re-arming turns that into a single failed
790
+ // turn the user can resend.
791
+ export async function recoverAccountSession(ctx: unknown, errorMessage: string): Promise<boolean> {
792
+ if (!hasCredentials()) return false; // signed out: onSessionExpired owns that message
793
+ if (!ACCOUNT_AUTH_FAILURE.test(errorMessage)) return false;
794
+ const slot = armSlot();
795
+ const now = Date.now();
796
+ if (slot.recoveredAt !== undefined && now - slot.recoveredAt < RECOVERY_COOLDOWN_MS) return false;
797
+ slot.recoveredAt = now;
798
+ // The credential we hold is the one that just failed — forget it so the arm below
799
+ // reclaims or spawns a live session instead of handing back the dead token.
800
+ slot.cred = undefined;
801
+ const armed = await armAccountCredential(ctx, { notify: false });
802
+ const c = ctx as SeedContext;
803
+ if (c?.hasUI) {
804
+ c.ui?.notify?.(
805
+ armed
806
+ ? "Your Privateer account session had expired — opened a new one. Send that message again."
807
+ : "Your Privateer account session isn't valid any more and couldn't be renewed. Run /login to sign back in.",
808
+ armed ? "warning" : "error",
809
+ );
810
+ }
811
+ return armed;
812
+ }
@@ -20,7 +20,12 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { makePiPrivacyExtension } from "pi-privacy";
23
- import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
23
+ import {
24
+ makeAccountProvider,
25
+ privateerChannel,
26
+ rememberAccountCredential,
27
+ dropPersistedAccountCredential,
28
+ } from "../providers/account.ts";
24
29
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
25
30
  import { RemoteBridge } from "./remoteBridge.ts";
26
31
  import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
@@ -62,7 +67,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
62
67
  let initialPromptSent = false;
63
68
  let stopped = false;
64
69
  let spawnedAccount = false;
65
- let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
70
+ let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
66
71
 
67
72
  let attachTimer: ReturnType<typeof setTimeout> | undefined;
68
73
  let lifeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -77,7 +82,9 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
77
82
  // app's Linked Devices; the harbor's own child session stays alive. Best-effort.
78
83
  if (spawnedAccount) {
79
84
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
80
- try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
85
+ // Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
86
+ // must not delete an interactive terminal's entry (see providers/account.ts).
87
+ try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
81
88
  }
82
89
  deps.onClosed(termId);
83
90
  deps.log(`live task ${termId} closed`);
@@ -160,6 +167,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
160
167
  try {
161
168
  const creds = await spawnAccountCredentials();
162
169
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
170
+ rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
163
171
  spawnedAccount = true;
164
172
  } catch (e) {
165
173
  deps.log(`live task ${termId} account channel unavailable: ${(e as Error).message}`);