privateer-agent 0.12.1 → 0.12.3

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/README.md CHANGED
@@ -275,8 +275,11 @@ a guarantee.
275
275
 
276
276
  ## Privateer account (billed inference)
277
277
 
278
- Instead of bringing your own key, run **`/signin`** to sign into a Privateer account — an
279
- app-brokered device flow where you approve a short code in the Privateer app, so wallet and
278
+ Instead of bringing your own key, run **`/signin`** to sign into a Privateer account. Your
279
+ browser opens straight onto an **Authorize this terminal?** page on privateer.pro check the
280
+ code on the page matches the one in your terminal and click Authorize; the terminal signs
281
+ itself in moments later. (Over SSH or on a headless box the terminal prints the link and code
282
+ to approve from the app instead — set `PRIVATEER_NO_BROWSER=1` to always do that.) Wallet and
280
283
  email accounts work identically and no password or key ever touches the terminal. Inference
281
284
  is then billed to your subscription and defaults to a **NEAR TEE** model. Sign out any time
282
285
  with `/signout`; manage linked terminals from the app.
@@ -293,10 +296,11 @@ and a management surface for it.
293
296
 
294
297
  ### Linking a terminal
295
298
 
296
- 1. Run **`privateer`** and **`/signin`**. It prints a short device code.
297
- 2. Open the app → **Link a terminal**, enter the code (or tap the deep link). No password or
298
- wallet key ever touches the terminal, and the app pins the terminal's public key on first
299
- link.
299
+ 1. Run **`privateer`** and **`/signin`**. Your browser opens an authorize page — check the
300
+ code matches the terminal's and click **Authorize**. (No browser handy? The terminal also
301
+ prints the link and code: open the app **Link a terminal** and enter it there.) No
302
+ password or wallet key ever touches the terminal, and the app pins the terminal's public
303
+ key on first link.
300
304
  3. In the terminal, turn on **`/remote-access`** (off by default). The terminal now shows
301
305
  **Online** in the app.
302
306
 
@@ -35,6 +35,7 @@ import {
35
35
  verificationLink,
36
36
  } from "../src/providers/account.ts";
37
37
  import { resolveSignedInModel, savedPiDefaultSpec } from "../src/providers/defaultModel.ts";
38
+ import { canOpenBrowser, openInBrowser } from "../src/util/openBrowser.ts";
38
39
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
39
40
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
40
41
 
@@ -382,13 +383,22 @@ export default function privateerBrand(pi: any): void {
382
383
  // user copies into a browser. See providers/account.ts verificationLink.
383
384
  const uri = clean(verificationLink(code.verification_uri_complete ?? code.verification_uri));
384
385
  const userCode = clean(code.user_code);
386
+ // Browser-first sign-in: the URL carries the code, so the page opens straight
387
+ // onto the Authorize screen — the user clicks, never types. Best-effort and
388
+ // fire-and-forget: the copy below decides its wording SYNCHRONOUSLY off
389
+ // canOpenBrowser (SSH/headless → the old app-approve copy), and the printed
390
+ // link stays either way, so a launcher that silently fails costs nothing.
391
+ const opening = Boolean(uri) && canOpenBrowser();
392
+ if (opening) void openInBrowser(uri);
385
393
  ctx?.ui?.setWidget?.(
386
394
  "privateer-signin",
387
395
  [
388
396
  `${p.INK}⚓ Sign in to Privateer${p.RESET}`,
389
- `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
390
- ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
391
- uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
397
+ opening
398
+ ? `${p.DIM}Authorize this terminal in the browser window that just opened.${p.RESET}`
399
+ : `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
400
+ ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}${opening ? `${p.DIM} — check it matches the one in your browser${p.RESET}` : ""}`,
401
+ uri ? `${p.DIM} ${opening ? "no browser? open" : "or open"} ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
392
402
  `${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}`,
393
403
  ].filter(Boolean),
394
404
  { placement: "aboveEditor" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -451,8 +451,14 @@ export async function runDeviceLogin(opts: {
451
451
  // Pass the server's own message through so the user learns what to actually do.
452
452
  async function spawnFailure(res: Response): Promise<Error> {
453
453
  if (res.status === 401) {
454
- clearCredentials();
455
- notifySessionExpired();
454
+ // Announce only the signed-in → signed-out transition: concurrent launch-time
455
+ // spawns (warmSession + the account-channel seed) can both 401 on the same dead
456
+ // login, and whichever lands second must stay silent. Same guard as
457
+ // handleServerRevoke.
458
+ if (hasCredentials()) {
459
+ clearCredentials();
460
+ notifySessionExpired();
461
+ }
456
462
  return new Error("Your Privateer session expired. Run /login to sign in again.");
457
463
  }
458
464
  let message: string | undefined;
@@ -25,6 +25,7 @@ import {
25
25
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { join } from "node:path";
27
27
  import { globalDir } from "../config/paths.ts";
28
+ import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
28
29
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
29
30
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
30
31
  import {
@@ -55,6 +56,10 @@ const DEFAULT_MODELS = [
55
56
  "anthropic/claude-sonnet-5",
56
57
  "openai/gpt-5.6-sol",
57
58
  "deepseek/deepseek-v4-flash",
59
+ // 2026-08 releases, confirmed live on OpenRouter (ZDR-covered ids reach the
60
+ // account catalog automatically; these seeds just make them resolve at launch).
61
+ "moonshotai/kimi-k3",
62
+ "z-ai/glm-5.2",
58
63
  ];
59
64
 
60
65
  function seedModel(id: string) {
@@ -398,15 +403,21 @@ export const privateerOAuthProvider = {
398
403
  try {
399
404
  await runDeviceLogin({
400
405
  signal: cb.signal,
401
- onCode: (code) =>
406
+ onCode: (code) => {
407
+ // Absolute url — the server's value is scheme-less and Pi renders this as
408
+ // a terminal hyperlink. See verificationLink.
409
+ const uri = verificationLink(code.verification_uri_complete ?? code.verification_uri);
410
+ // Browser-first: the URL carries the code, so the page lands on the
411
+ // Authorize screen and the user just clicks. Best-effort fire-and-forget —
412
+ // Pi's dialog keeps showing the code + link as the fallback either way.
413
+ if (uri && canOpenBrowser()) void openInBrowser(uri);
402
414
  cb.onDeviceCode?.({
403
415
  userCode: code.user_code,
404
- // Absolute url — the server's value is scheme-less and Pi renders this as
405
- // a terminal hyperlink. See verificationLink.
406
- verificationUri: verificationLink(code.verification_uri_complete ?? code.verification_uri),
416
+ verificationUri: uri,
407
417
  intervalSeconds: code.interval,
408
418
  expiresInSeconds: code.expires_in,
409
- }),
419
+ });
420
+ },
410
421
  });
411
422
  } catch (e) {
412
423
  // Normalize the cancel message to exactly "Login cancelled" (no period):
@@ -809,7 +820,10 @@ export async function armAccountCredential(
809
820
  // so staying silent leaves the user to discover it as a bare "No API key found for
810
821
  // privateer" on their first prompt.
811
822
  const c = ctx as SeedContext;
812
- if (opts.notify !== false && c?.hasUI) {
823
+ // If the credentials vanished during the call, the spawn hit a 401 and
824
+ // onSessionExpired already announced the sign-out — a second line here would
825
+ // just repeat it (we returned early above if we STARTED signed out).
826
+ if (opts.notify !== false && c?.hasUI && hasCredentials()) {
813
827
  c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
814
828
  }
815
829
  return false;
@@ -59,7 +59,7 @@ export const PROVIDERS: ProviderEntry[] = [
59
59
  api: "openai-completions",
60
60
  keyEnv: "${DASHSCOPE_API_KEY}",
61
61
  compat: { thinkingFormat: "qwen" },
62
- seedModels: ["qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
62
+ seedModels: ["qwen3.8-max-preview", "qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
63
63
  },
64
64
  { id: "ollama", source: "pi-privacy" },
65
65
  { id: "nearai", source: "pi-privacy" },
@@ -36,6 +36,13 @@ export interface ModelsJson {
36
36
  providers: Record<string, ModelsJsonProvider>;
37
37
  }
38
38
 
39
+ // Per-id context-window overrides for seeds whose real window is far from the
40
+ // Appendix A.4 default (kept until the live listing refines them). Qwen3.8-Max
41
+ // serves ~1M tokens (983,616 advertised at launch, 2026-08-03).
42
+ const SEED_CONTEXT: Record<string, number> = {
43
+ "qwen3.8-max-preview": 1_000_000,
44
+ };
45
+
39
46
  function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonModel {
40
47
  return {
41
48
  id,
@@ -43,7 +50,7 @@ function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonMode
43
50
  reasoning: false,
44
51
  input: ["text"],
45
52
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
46
- contextWindow: 128000,
53
+ contextWindow: SEED_CONTEXT[id] ?? 128000,
47
54
  maxTokens: 16384,
48
55
  ...(compat ? { compat } : {}),
49
56
  };
@@ -0,0 +1,90 @@
1
+ // Best-effort "open this URL in the user's default browser", for the sign-in flow.
2
+ //
3
+ // The device-code login used to only PRINT the verification link and wait; opening
4
+ // the browser ourselves turns /login into the one-click authorize flow (the page the
5
+ // server sends carries the code in its query string, so the user just clicks
6
+ // Authorize — no typing). Everything here is best-effort by design: the printed link
7
+ // stays in the widget as the fallback, so a failed or skipped open costs nothing.
8
+ //
9
+ // Two separate questions, two exports:
10
+ // canOpenBrowser() — SHOULD we try? Decides the widget copy up front ("check the
11
+ // code matches your browser" vs "approve in the Privateer app"), so it must be
12
+ // synchronous and conservative: an SSH session or a display-less Linux box would
13
+ // open the browser on the WRONG machine or not at all.
14
+ // openInBrowser() — actually try, detached, never throwing. The spawned launcher
15
+ // is unref()'d so a lingering handler can't hold the CLI's event loop open.
16
+
17
+ import { spawn } from "node:child_process";
18
+
19
+ export function canOpenBrowser(
20
+ env: Record<string, string | undefined> = process.env,
21
+ platform: NodeJS.Platform = process.platform,
22
+ ): boolean {
23
+ if (env.PRIVATEER_NO_BROWSER?.trim()) return false; // explicit escape hatch
24
+ // Remote shell: `open`/`xdg-open` would run on the far machine, not where the
25
+ // user's browser is. SSH_TTY covers interactive sessions; SSH_CONNECTION also
26
+ // survives `ssh host command` and some su/sudo transitions.
27
+ if (env.SSH_TTY || env.SSH_CONNECTION) return false;
28
+ // Headless Linux/BSD: no display server → nothing for xdg-open to hand the URL to.
29
+ if (platform !== "darwin" && platform !== "win32" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false;
30
+ return true;
31
+ }
32
+
33
+ // Only well-formed http(s) URLs are ever handed to a launcher — anything else
34
+ // (including a scheme-less server value that slipped past verificationLink) is
35
+ // refused rather than "fixed" here, so this can't be talked into opening file:// or
36
+ // custom-scheme handlers.
37
+ export function browsableUrl(raw: string | undefined): string | undefined {
38
+ const s = (raw ?? "").trim();
39
+ if (!s) return undefined;
40
+ try {
41
+ const u = new URL(s);
42
+ if (u.protocol !== "https:" && u.protocol !== "http:") return undefined;
43
+ return u.href;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function openInBrowser(rawUrl: string): Promise<boolean> {
50
+ const url = browsableUrl(rawUrl);
51
+ if (!url) return Promise.resolve(false);
52
+
53
+ let cmd: string;
54
+ let args: string[];
55
+ if (process.platform === "darwin") {
56
+ cmd = "open";
57
+ args = [url];
58
+ } else if (process.platform === "win32") {
59
+ // `start` is a cmd built-in; the empty "" is its window-title slot so the URL
60
+ // isn't eaten as the title. cmd re-parses its arguments, so escape the one URL
61
+ // metacharacter cmd cares about (& splits commands); browsableUrl already
62
+ // guarantees there's no whitespace or quotes to break out with.
63
+ cmd = "cmd";
64
+ args = ["/c", "start", "", url.replace(/&/g, "^&")];
65
+ } else {
66
+ cmd = "xdg-open";
67
+ args = [url];
68
+ }
69
+
70
+ return new Promise((resolve) => {
71
+ let settled = false;
72
+ const done = (ok: boolean) => {
73
+ if (!settled) {
74
+ settled = true;
75
+ resolve(ok);
76
+ }
77
+ };
78
+ try {
79
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
80
+ child.once("error", () => done(false)); // launcher missing (e.g. no xdg-open)
81
+ child.once("exit", (code) => done(code === 0));
82
+ child.unref();
83
+ // Some launchers block until the browser exits; don't make the login widget
84
+ // wait on that — after a beat, assume the hand-off worked.
85
+ setTimeout(() => done(true), 2000).unref?.();
86
+ } catch {
87
+ done(false);
88
+ }
89
+ });
90
+ }