privateer-agent 0.3.3 → 0.3.5

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/bin/privateer-tui CHANGED
@@ -18,6 +18,15 @@ while [ -h "$SOURCE" ]; do
18
18
  done
19
19
  REPO="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
20
20
 
21
+ # `privateer update` — pull the latest release from npm and exit. Handled here in the
22
+ # launcher (not the TUI) so it works even when a broken install won't boot. npm rewrites
23
+ # the global bin in place; replacing it while this process runs is safe on unix.
24
+ if [ "${1:-}" = "update" ]; then
25
+ echo "Updating privateer-agent to the latest release…"
26
+ npm install -g privateer-agent@latest
27
+ exit $?
28
+ fi
29
+
21
30
  pick_node() {
22
31
  if command -v node >/dev/null 2>&1 \
23
32
  && node -e 'process.exit((+process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' 2>/dev/null; then
@@ -82,6 +91,23 @@ if (s.lastChangelogVersion === undefined) { s.lastChangelogVersion = "9999.0.0";
82
91
  if (m) fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
83
92
  ' "$AGENT_DIR/settings.json" 2>/dev/null || true
84
93
 
94
+ # Passive update check: refresh the cached "latest npm version" at most ~daily, in the
95
+ # BACKGROUND so it never blocks or breaks launch (offline-safe — a failed fetch just
96
+ # leaves the stale cache in place). The banner (privateer-brand) reads this cache and
97
+ # shows a one-line "↑ vX available · run privateer update" notice when we're behind. We
98
+ # never auto-install — the user stays in control of when new code lands (the whole point
99
+ # of an attestable tool). Detached with </dev/null &, so it outlives our exec into node.
100
+ UPDATE_CACHE="${PRIVATEER_HOME:-$HOME/.privateer}/update-check.json"
101
+ if [ -z "$(find "$UPDATE_CACHE" -mtime -1 2>/dev/null)" ]; then
102
+ (
103
+ latest="$(npm view privateer-agent version 2>/dev/null || true)"
104
+ case "$latest" in
105
+ [0-9]*) printf '{"latest":"%s"}\n' "$latest" > "$UPDATE_CACHE.tmp" 2>/dev/null \
106
+ && mv -f "$UPDATE_CACHE.tmp" "$UPDATE_CACHE" 2>/dev/null ;;
107
+ esac
108
+ ) </dev/null >/dev/null 2>&1 &
109
+ fi
110
+
85
111
  # Default model: when signed in to a Privateer account, default to GLM 5.1 on the
86
112
  # account's NEAR confidential-compute (TEE) channel — attestable, strongest privacy
87
113
  # tier. Otherwise (BYO key, no account) fall back to a cheap OpenRouter model. An
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { readFileSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
+ import { join } from "node:path";
24
25
  import * as priv from "../src/auth/privateer.ts";
25
26
  import { makeAccountProvider } from "../src/providers/account.ts";
26
27
 
@@ -57,7 +58,7 @@ const ANCHOR = [
57
58
  " .-----. ", // lock body top (the shackle's base)
58
59
  " | o | ", // lock body + keyhole
59
60
  " '--+--' ", // lock body base, shank exits
60
- " /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
61
+ " /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
61
62
  " \\ | / ", // arms
62
63
  " \\_|_/ ", // flukes
63
64
  ];
@@ -105,6 +106,34 @@ function accountLine(modelProvider?: string): string {
105
106
  return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
106
107
  }
107
108
 
109
+ // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
110
+ // enough for our npm releases; anything unparseable sorts as 0 and is treated as older.
111
+ function isNewer(a: string, b: string): boolean {
112
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
113
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
114
+ for (let i = 0; i < 3; i++) {
115
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
116
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ // The "update available" banner line, or "" when we're current / offline / unchecked.
122
+ // Reads the cache the launcher refreshes in the background (see bin/privateer-tui) —
123
+ // never fetches here, so the banner stays synchronous and never blocks on the network.
124
+ function updateNotice(): string {
125
+ try {
126
+ const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
127
+ const { latest } = JSON.parse(readFileSync(join(home, "update-check.json"), "utf8"));
128
+ if (typeof latest === "string" && isNewer(latest, VERSION)) {
129
+ return `${YELLOW}↑ v${latest} available${DIM} · run ${RESET}${OCEAN_LIGHT}privateer update${RESET}`;
130
+ }
131
+ } catch {
132
+ // no cache yet, unreadable, or malformed — show nothing.
133
+ }
134
+ return "";
135
+ }
136
+
108
137
  // Compose the framed banner: anchor column + text column, inside a rounded accent box.
109
138
  function renderBanner(width: number, modelProvider?: string): string[] {
110
139
  // Leading blanks drop the text block so the wordmark sits beside the lock body and
@@ -119,8 +148,11 @@ function renderBanner(width: number, modelProvider?: string): string[] {
119
148
  `${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
120
149
  `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
121
150
  ];
122
- // Build the body rows (anchor + gutter + text).
151
+ // Build the body rows (anchor + gutter + text). A pending-update notice, if any, gets
152
+ // its own row under the block, indented to sit beneath the text column.
123
153
  const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
154
+ const notice = updateNotice();
155
+ if (notice) rows.push(` ${notice}`);
124
156
  const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
125
157
  const inner = Math.min(cap, Math.max(...rows.map(vlen)));
126
158
  const bar = "─".repeat(inner + 2);
@@ -163,6 +195,31 @@ export default function privateerBrand(pi: any): void {
163
195
  ctx?.ui?.setStatus?.("account", accountBadge());
164
196
  };
165
197
 
198
+ // /update — run the global npm install in a child process and report the outcome via
199
+ // notify (the TUI keeps running the OLD code; npm swaps the global bin's inode in
200
+ // place, so replacing it under us is safe and the new version loads on next launch).
201
+ async function doUpdate(ctx: any): Promise<void> {
202
+ ctx?.ui?.notify?.("Updating Privateer — running npm install -g privateer-agent@latest…", "info");
203
+ try {
204
+ const { execFile } = await import("node:child_process");
205
+ const stderr: string = await new Promise((resolve, reject) => {
206
+ execFile(
207
+ "npm",
208
+ ["install", "-g", "privateer-agent@latest"],
209
+ { timeout: 180_000 },
210
+ (err, _out, errOut) => (err ? reject(new Error(String(errOut || err.message).trim())) : resolve(String(errOut || ""))),
211
+ );
212
+ });
213
+ void stderr;
214
+ ctx?.ui?.notify?.("Updated. Restart `privateer` to run the new version.", "info");
215
+ } catch (e) {
216
+ ctx?.ui?.notify?.(
217
+ `Update failed: ${(e as Error).message || e}. Try manually: npm install -g privateer-agent@latest`,
218
+ "error",
219
+ );
220
+ }
221
+ }
222
+
166
223
  async function doSignIn(ctx: any): Promise<void> {
167
224
  if (priv.hasCredentials()) {
168
225
  const u = priv.currentUser();
@@ -251,6 +308,10 @@ export default function privateerBrand(pi: any): void {
251
308
  ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
252
309
  });
253
310
 
311
+ pi.registerCommand?.("update", {
312
+ description: "Update Privateer to the latest release (npm i -g privateer-agent@latest)",
313
+ handler: (_args: string, ctx: any) => doUpdate(ctx),
314
+ });
254
315
  pi.registerCommand?.("signin", {
255
316
  description: "Sign in to your Privateer account (device-code flow)",
256
317
  handler: (_args: string, ctx: any) => doSignIn(ctx),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -66,18 +66,34 @@ export async function fetchAccountModels(): Promise<string[]> {
66
66
  export const privateerOAuthProvider = {
67
67
  name: "Privateer account",
68
68
  usesCallbackServer: false,
69
- async login(cb: { onDeviceCode?: (info: unknown) => void }) {
69
+ // Pi's login dialog passes `signal` (its cancel AbortController) alongside the
70
+ // callbacks. We MUST thread it into runDeviceLogin — otherwise escape/ctrl+c
71
+ // aborts the dialog's signal but our poll loop never sees it, the login()
72
+ // promise never settles, and Pi never restores the editor: the "Waiting for
73
+ // authentication…" screen hangs with no way out. See auth/privateer.ts
74
+ // pollForToken, which checks the signal and rejects with "Login cancelled.".
75
+ async login(cb: { onDeviceCode?: (info: unknown) => void; signal?: AbortSignal }) {
70
76
  if (!hasCredentials()) {
71
- await runDeviceLogin({
72
- onCode: (code) =>
73
- cb.onDeviceCode?.({
74
- userCode: code.user_code,
75
- verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
76
- intervalSeconds: code.interval,
77
- expiresInSeconds: code.expires_in,
78
- }),
79
- });
77
+ try {
78
+ await runDeviceLogin({
79
+ signal: cb.signal,
80
+ onCode: (code) =>
81
+ cb.onDeviceCode?.({
82
+ userCode: code.user_code,
83
+ verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
84
+ intervalSeconds: code.interval,
85
+ expiresInSeconds: code.expires_in,
86
+ }),
87
+ });
88
+ } catch (e) {
89
+ // Normalize the cancel message to exactly "Login cancelled" (no period):
90
+ // Pi's login dialog only suppresses its "Failed to login…" error toast for
91
+ // that exact string, so a cancel should exit quietly, not flash an error.
92
+ if (cb.signal?.aborted) throw new Error("Login cancelled");
93
+ throw e;
94
+ }
80
95
  }
96
+ if (cb.signal?.aborted) throw new Error("Login cancelled");
81
97
  return spawnAccountCredentials();
82
98
  },
83
99
  async refreshToken(creds: { refresh: string }) {