privateer-agent 0.6.6 → 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.
@@ -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.6",
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") {
@@ -0,0 +1,157 @@
1
+ // Which account-provider inference sessions this machine has spawned, and which
2
+ // terminal owns each one.
3
+ //
4
+ // The problem this solves: every launch used to spawn a NEW server-side session, and
5
+ // only a CLEAN exit revoked it (session_shutdown → revokeLocalSessions). A terminal
6
+ // that dies without running its shutdown hook — SIGKILL, a closed window, a crash,
7
+ // `kill` — leaves its session row alive server-side for the rest of its ~24h TTL. Do
8
+ // that a few times and the next spawn is refused with
9
+ // `429 CHILD_SESSION_CAP: Too many active terminals for this device`, which takes the
10
+ // whole account channel down until the rows age out.
11
+ //
12
+ // The fix is to reclaim an orphan instead of stacking another row on top of it. That
13
+ // needs one bit the credential itself can't tell us: is the terminal that owns it
14
+ // still RUNNING? A live terminal's session must never be touched — adopting it rotates
15
+ // its refresh token out from under it and kills a working session (they rotate in
16
+ // isolation, one per terminal, by design). So each entry records the owning pid, and
17
+ // a session counts as orphaned only once that pid is gone.
18
+ //
19
+ // pid liveness is signal-0. The failure mode is asymmetric and we lean on that: a
20
+ // RECYCLED pid makes a dead owner look alive, so we skip a reclaimable session and
21
+ // spawn a fresh one — the old behaviour, no harm. The dangerous direction (a live
22
+ // process reported dead) can't happen: a running pid never reports ESRCH.
23
+
24
+ import { existsSync, mkdirSync, readFileSync, rmSync, renameSync, writeFileSync, chmodSync } from "node:fs";
25
+ import { dirname } from "node:path";
26
+ import { accountSessionsPath, globalDir } from "../config/paths.ts";
27
+
28
+ // One spawned session, keyed in the file by the pid of its owning terminal. `refresh`
29
+ // is what lets a later launch adopt or revoke it; `expires` is its access token's exp
30
+ // (see jwtExpMs), used only to prune entries that are dead server-side anyway.
31
+ export interface OwnedSession {
32
+ pid: number;
33
+ refresh: string;
34
+ expires: number;
35
+ }
36
+
37
+ type Registry = Record<string, { refresh?: unknown; expires?: unknown }>;
38
+
39
+ function tryChmod(path: string, mode: number): void {
40
+ try {
41
+ chmodSync(path, mode);
42
+ } catch {
43
+ /* best effort — a restrictive umask or an odd filesystem */
44
+ }
45
+ }
46
+
47
+ function readRegistry(): Registry {
48
+ const path = accountSessionsPath();
49
+ if (!existsSync(path)) return {};
50
+ try {
51
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
52
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Registry) : {};
53
+ } catch {
54
+ return {}; // corrupt/truncated — start clean rather than wedging every launch
55
+ }
56
+ }
57
+
58
+ // Write via temp + rename so a concurrent reader never sees a half-written file. Two
59
+ // terminals racing can still lose one entry (last writer wins); the cost is one
60
+ // unreclaimable orphan, not a broken launch, so a lock file isn't worth it here.
61
+ function writeRegistry(reg: Registry): void {
62
+ const path = accountSessionsPath();
63
+ try {
64
+ mkdirSync(dirname(path), { recursive: true });
65
+ tryChmod(globalDir(), 0o700);
66
+ const tmp = `${path}.${process.pid}.tmp`;
67
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
68
+ tryChmod(tmp, 0o600);
69
+ renameSync(tmp, path);
70
+ } catch {
71
+ /* best effort — losing the registry costs reclamation, never correctness */
72
+ }
73
+ }
74
+
75
+ // Is a pid still running? EPERM means it exists but belongs to another user, which is
76
+ // still "alive" — and alive is the safe answer (we skip reclamation rather than risk
77
+ // hijacking a live terminal's session).
78
+ function isAlive(pid: number): boolean {
79
+ if (!Number.isInteger(pid) || pid <= 0) return false;
80
+ try {
81
+ process.kill(pid, 0);
82
+ return true;
83
+ } catch (e) {
84
+ return (e as NodeJS.ErrnoException).code === "EPERM";
85
+ }
86
+ }
87
+
88
+ function parseEntry(pid: string, raw: { refresh?: unknown; expires?: unknown }): OwnedSession | null {
89
+ const n = Number(pid);
90
+ if (!Number.isInteger(n) || typeof raw?.refresh !== "string" || !raw.refresh) return null;
91
+ return { pid: n, refresh: raw.refresh, expires: typeof raw.expires === "number" ? raw.expires : 0 };
92
+ }
93
+
94
+ // Claim (or re-claim) a session for THIS process. Called wherever the account
95
+ // credential is minted or rotated — spawnAccountCredentials and
96
+ // refreshAccountCredentials — so the registry always holds the token that would
97
+ // actually work, including the rotations Pi drives on its own.
98
+ export function recordOwnedSession(cred: { refresh: string; expires: number }): void {
99
+ const reg = readRegistry();
100
+ reg[String(process.pid)] = { refresh: cred.refresh, expires: cred.expires };
101
+ writeRegistry(reg);
102
+ }
103
+
104
+ // Drop this process's entry — the session is being revoked (clean exit, /signout), so
105
+ // it is about to stop existing server-side. Leaving it behind would advertise a dead
106
+ // session as a reclaimable orphan to the next launch.
107
+ export function forgetOwnedSession(): void {
108
+ const reg = readRegistry();
109
+ if (!(String(process.pid) in reg)) return;
110
+ delete reg[String(process.pid)];
111
+ writeRegistry(reg);
112
+ }
113
+
114
+ // Sessions whose owning terminal is gone: candidates to adopt or revoke. Prunes
115
+ // entries that are unusable anyway (malformed, or past their expiry) as a side
116
+ // effect, so the file can't grow without bound. Our own pid is never a candidate.
117
+ export function orphanedSessions(now = Date.now()): OwnedSession[] {
118
+ const reg = readRegistry();
119
+ const orphans: OwnedSession[] = [];
120
+ let pruned = false;
121
+
122
+ for (const [pid, raw] of Object.entries(reg)) {
123
+ const entry = parseEntry(pid, raw);
124
+ if (!entry || (entry.expires > 0 && entry.expires <= now)) {
125
+ delete reg[pid]; // malformed, or dead server-side — nothing to reclaim
126
+ pruned = true;
127
+ continue;
128
+ }
129
+ if (entry.pid === process.pid || isAlive(entry.pid)) continue; // ours, or a live terminal's
130
+ orphans.push(entry);
131
+ }
132
+
133
+ if (pruned) writeRegistry(reg);
134
+ return orphans;
135
+ }
136
+
137
+ // Forget one orphan, once it has been definitively handled (adopted, or confirmed dead
138
+ // server-side). A entry whose refresh merely FAILED TO REACH the server is deliberately
139
+ // kept: dropping it on a network blip would leak that row until its TTL.
140
+ export function dropOwnedSession(pid: number): void {
141
+ const reg = readRegistry();
142
+ if (!(String(pid) in reg)) return;
143
+ delete reg[String(pid)];
144
+ writeRegistry(reg);
145
+ }
146
+
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.
151
+ export function clearOwnedSessions(): void {
152
+ try {
153
+ rmSync(accountSessionsPath(), { force: true });
154
+ } catch {
155
+ /* nothing to remove */
156
+ }
157
+ }