privateer-agent 0.3.2 → 0.3.4

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
@@ -12,6 +12,9 @@
12
12
  <a href="https://www.npmjs.com/package/privateer-agent">
13
13
  <img src="https://img.shields.io/npm/v/privateer-agent" alt="npm" />
14
14
  </a>
15
+ <a href="https://github.com/privateer-agent/privateer-agent/releases">
16
+ <img src="https://img.shields.io/badge/changelog-what's%20new-5b8def" alt="Changelog" />
17
+ </a>
15
18
  <img src="https://img.shields.io/badge/node-%E2%89%A522.19-brightgreen" alt="Node >= 22.19" />
16
19
  <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" />
17
20
  <img src="https://img.shields.io/badge/permissions-safe%20by%20default-2ea44f" alt="Safe by default" />
@@ -247,6 +250,12 @@ npm run typecheck
247
250
  npm test
248
251
  ```
249
252
 
253
+ ## Changelog
254
+
255
+ Release notes and what's new in each version live on the
256
+ [**GitHub releases page**](https://github.com/privateer-agent/privateer-agent/releases).
257
+ Privateer keeps its startup clean — the app won't dump a changelog into your terminal.
258
+
250
259
  ## License
251
260
 
252
261
  [MIT](LICENSE) © Patrick
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
@@ -59,17 +68,46 @@ shim pi-subagents "$REPO/node_modules/pi-subagents/src/extension/index.ts"
59
68
 
60
69
  CLI="$REPO/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
61
70
  export PI_CODING_AGENT_DIR="$AGENT_DIR"
71
+ # Suppress Pi's upstream "Update Available — run `pi update`" banner. It's noise on a
72
+ # Privateer install (our banner is the startup surface), it leaks the "pi" name, and
73
+ # `pi update` would fight our npm-managed install. PI_SKIP_VERSION_CHECK disables ONLY
74
+ # the version fetch — unlike PI_OFFLINE it still lets fd/rg download on first run.
75
+ export PI_SKIP_VERSION_CHECK=1
62
76
  NODE_BIN="$(pick_node)"
63
77
 
64
- # Quiet Pi's built-in startup resource listing ([Extensions]/[Context]/[Skills]/…) by
65
- # default our banner is the intended startup surface, and the extension list is noise.
66
- # Only set it when unset, so a later `--verbose` or a user's own settings toggle sticks.
78
+ # Quiet Pi's built-in startup chatter so our banner is the only greeting. Each key is
79
+ # set only when unset, so a user's own settings.json toggle (or --verbose) still wins:
80
+ # quietStartup — hide the [Extensions]/[Context]/[Skills]/… resource listing
81
+ # collapseChangelog — never dump Pi's "What's New" changelog wall into the chat
82
+ # lastChangelogVersion — sentinel so Pi finds no "new" entries to show on upgrade;
83
+ # release notes live in the npm README / GitHub releases instead
67
84
  "$NODE_BIN" -e '
68
85
  const fs = require("fs"), p = process.argv[1];
69
86
  let s = {}; try { s = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
70
- if (s.quietStartup === undefined) { s.quietStartup = true; fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n"); }
87
+ let m = false;
88
+ if (s.quietStartup === undefined) { s.quietStartup = true; m = true; }
89
+ if (s.collapseChangelog === undefined) { s.collapseChangelog = true; m = true; }
90
+ if (s.lastChangelogVersion === undefined) { s.lastChangelogVersion = "9999.0.0"; m = true; }
91
+ if (m) fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
71
92
  ' "$AGENT_DIR/settings.json" 2>/dev/null || true
72
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
+
73
111
  # Default model: when signed in to a Privateer account, default to GLM 5.1 on the
74
112
  # account's NEAR confidential-compute (TEE) channel — attestable, strongest privacy
75
113
  # tier. Otherwise (BYO key, no account) fall back to a cheap OpenRouter model. An
@@ -9,15 +9,19 @@
9
9
  // device-code flow the account channel needs.
10
10
  //
11
11
  // Pi already owns /login and /logout for PROVIDER auth (and /whoami), so we do NOT
12
- // shadow them. /signin drives the Privateer *account* device flow directly which
13
- // has to exist separately, because the `privateer` inference provider only registers
14
- // once credentials exist, so a first-time user can't reach it through provider auth.
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.
15
17
  //
16
18
  // On a successful /signin we hot-register the account provider so privateer/* models
17
- // appear immediately, without a restart.
19
+ // appear immediately (the account catalog refreshes to the live listing without a
20
+ // restart).
18
21
 
19
22
  import { readFileSync } from "node:fs";
20
23
  import { homedir } from "node:os";
24
+ import { join } from "node:path";
21
25
  import * as priv from "../src/auth/privateer.ts";
22
26
  import { makeAccountProvider } from "../src/providers/account.ts";
23
27
 
@@ -54,7 +58,7 @@ const ANCHOR = [
54
58
  " .-----. ", // lock body top (the shackle's base)
55
59
  " | o | ", // lock body + keyhole
56
60
  " '--+--' ", // lock body base, shank exits
57
- " ---+--- ", // stock (crossbar)
61
+ " /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
58
62
  " \\ | / ", // arms
59
63
  " \\_|_/ ", // flukes
60
64
  ];
@@ -102,6 +106,34 @@ function accountLine(modelProvider?: string): string {
102
106
  return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
103
107
  }
104
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
+
105
137
  // Compose the framed banner: anchor column + text column, inside a rounded accent box.
106
138
  function renderBanner(width: number, modelProvider?: string): string[] {
107
139
  // Leading blanks drop the text block so the wordmark sits beside the lock body and
@@ -116,8 +148,11 @@ function renderBanner(width: number, modelProvider?: string): string[] {
116
148
  `${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
117
149
  `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
118
150
  ];
119
- // 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.
120
153
  const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
154
+ const notice = updateNotice();
155
+ if (notice) rows.push(` ${notice}`);
121
156
  const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
122
157
  const inner = Math.min(cap, Math.max(...rows.map(vlen)));
123
158
  const bar = "─".repeat(inner + 2);
@@ -150,7 +185,6 @@ function accountBadge(): string {
150
185
  export default function privateerBrand(pi: any): void {
151
186
  let currentModelProvider: string | undefined;
152
187
  let ctxRef: any = null;
153
- let greeted = false;
154
188
 
155
189
  const setHeader = (ctx: any) =>
156
190
  ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
@@ -161,6 +195,31 @@ export default function privateerBrand(pi: any): void {
161
195
  ctx?.ui?.setStatus?.("account", accountBadge());
162
196
  };
163
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
+
164
223
  async function doSignIn(ctx: any): Promise<void> {
165
224
  if (priv.hasCredentials()) {
166
225
  const u = priv.currentUser();
@@ -227,13 +286,8 @@ export default function privateerBrand(pi: any): void {
227
286
  if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
228
287
  ctx?.ui?.setTitle?.("Privateer");
229
288
  refresh(ctx);
230
- if (!priv.hasCredentials() && !greeted) {
231
- greeted = true;
232
- ctx?.ui?.notify?.(
233
- "Not signed in — run /signin to connect your Privateer account (adds hosted models + remote access).",
234
- "info",
235
- );
236
- }
289
+ // No startup notify here: the banner's account line already surfaces the
290
+ // "not signed in · /signin" prompt, so a second line would just be noise.
237
291
  });
238
292
 
239
293
  // Keep the header's account line in sync with the picked model (the "this model
@@ -254,6 +308,10 @@ export default function privateerBrand(pi: any): void {
254
308
  ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
255
309
  });
256
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
+ });
257
315
  pi.registerCommand?.("signin", {
258
316
  description: "Sign in to your Privateer account (device-code flow)",
259
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.2",
3
+ "version": "0.3.4",
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",
@@ -136,8 +136,16 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
136
136
  }
137
137
  }
138
138
 
139
- // Extension factory: registers the account provider when a machine login exists. No
140
- // login → nothing registered (BYO-key only).
139
+ // Extension factory: registers the account provider so `/login` can offer it.
140
+ //
141
+ // We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
142
+ // `/login` builds its "Use a subscription" list from the OAuth providers registered
143
+ // here (authStorage.getOAuthProviders()); a not-yet-logged-in machine has no
144
+ // credentials, so gating registration on hasCredentials() left `/login` with no
145
+ // Privateer option — the classic chicken-and-egg where you can't log in because
146
+ // you're not logged in. The OAuth provider's login() itself runs the device-code
147
+ // flow when hasCredentials() is false (see privateerOAuthProvider.login), so first
148
+ // login works entirely through Pi once the provider is present.
141
149
  //
142
150
  // Registration is SYNCHRONOUS (seeded with the fallback), then refined once the live
143
151
  // catalog is fetched. This matters: Pi flushes provider registrations made during the
@@ -151,7 +159,7 @@ export function makeAccountProvider() {
151
159
  return (pi: {
152
160
  registerProvider?: (name: string, config: unknown) => void;
153
161
  }): void => {
154
- if (!hasCredentials() || typeof pi.registerProvider !== "function") return;
162
+ if (typeof pi.registerProvider !== "function") return;
155
163
  const register = (ids: string[]): void =>
156
164
  pi.registerProvider!("privateer", {
157
165
  name: "Privateer account",