privateer-agent 0.5.1 → 0.6.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.
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // Cross-platform Privateer launcher — the single source of launch logic for every
3
+ // platform (macOS, Linux, Windows). `bin/privateer-tui` (unix) and the Windows
4
+ // `privateer.cmd` are thin shims that just pick a Node and run THIS file.
5
+ //
6
+ // It boots Pi's full interactive TUI with the Privateer moat + tool packs. The moat
7
+ // is installed as re-export SHIMS in the agent dir's extensions/, so BOTH this TUI
8
+ // and any subagents it spawns (child processes reading the same agent dir) load the
9
+ // identical set — including our permission gate. One source of truth via discovery
10
+ // (no `-e`, which would double-load vs discovery). Runs in the current directory;
11
+ // model via PRIVATEER_MODEL=provider/id.
12
+ //
13
+ // Ported from the original bash launcher; behaviour is intended to match exactly.
14
+
15
+ import { spawn } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
22
+ const REPO = path.resolve(HERE, "..");
23
+ const isWin = process.platform === "win32";
24
+
25
+ const PRIVATEER_HOME = process.env.PRIVATEER_HOME || path.join(os.homedir(), ".privateer");
26
+ const ENV_FILE = path.join(REPO, ".env"); // dev-only; a real install has none
27
+
28
+ // --- bundle detection ------------------------------------------------------
29
+ // A self-contained bundle ships its own pinned Node at "$REPO/node[.exe]" plus a
30
+ // BUNDLE_INFO.json marker (built by scripts/build-bundle.mjs). When present we use
31
+ // that runtime and never touch system node/npm. Putting the bundle dir on PATH also
32
+ // lets any child that boots via `#!/usr/bin/env node` (Pi's cli.js, the subagent
33
+ // wrapper) resolve the bundled node.
34
+ const bundledNode = path.join(REPO, isWin ? "node.exe" : "node");
35
+ const BUNDLED = fs.existsSync(bundledNode) && fs.existsSync(path.join(REPO, "BUNDLE_INFO.json"));
36
+
37
+ // The node used for child processes. When bundled, the bundled runtime; otherwise the
38
+ // very node already running this script (a suitable >=22, since we booted under it).
39
+ const NODE_BIN = BUNDLED ? bundledNode : process.execPath;
40
+ // Make sure the chosen node's directory is on PATH for shebang-spawned grandchildren.
41
+ process.env.PATH = path.dirname(NODE_BIN) + path.delimiter + (process.env.PATH || "");
42
+
43
+ const args = process.argv.slice(2);
44
+ const sub = args[0];
45
+
46
+ // `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
47
+ // print the pi-coding-agent version (e.g. 0.80.3); intercept so users see the
48
+ // Privateer release they installed. (The startup banner already shows this version.)
49
+ if (sub === "--version" || sub === "-V") {
50
+ const ver = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")).version; } catch { return null; } };
51
+ const pv = ver(path.join(REPO, "package.json")) || "unknown";
52
+ const pi = ver(path.join(REPO, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"));
53
+ console.log(`privateer ${pv}${pi ? ` (pi ${pi})` : ""}`);
54
+ process.exit(0);
55
+ }
56
+
57
+ // Faithfully propagate a child's exit/signal, mirroring bash `exec`.
58
+ function runToCompletion(cmd, cmdArgs, opts = {}) {
59
+ const child = spawn(cmd, cmdArgs, { stdio: "inherit", env: process.env, ...opts });
60
+ child.on("exit", (code, signal) => {
61
+ if (signal) process.kill(process.pid, signal);
62
+ else process.exit(code ?? 0);
63
+ });
64
+ child.on("error", (e) => {
65
+ console.error(`privateer: failed to launch — ${e.message}`);
66
+ process.exit(1);
67
+ });
68
+ }
69
+
70
+ // --- `privateer update` ----------------------------------------------------
71
+ // Fetch the latest release and exit. Bundle installs re-run the download+extract
72
+ // installer; npm installs update the global package.
73
+ if (sub === "update") {
74
+ if (BUNDLED) {
75
+ console.log("Updating Privateer to the latest release…");
76
+ if (isWin) {
77
+ runToCompletion("powershell", ["-NoProfile", "-Command", "irm https://privateer.pro/install.ps1 | iex"]);
78
+ } else {
79
+ runToCompletion("sh", ["-c", "curl -fsSL https://privateer.pro/install.sh | sh"]);
80
+ }
81
+ } else {
82
+ console.log("Updating privateer-agent to the latest release…");
83
+ runToCompletion(isWin ? "npm.cmd" : "npm", ["install", "-g", "privateer-agent@latest"]);
84
+ }
85
+ // runToCompletion exits via the child's exit handler.
86
+ }
87
+
88
+ // --- `privateer daemon [run|install|uninstall|status]` ---------------------
89
+ // The resident background daemon (routines + app-driven headless task spawns). Boots
90
+ // straight into src/daemon via bin/privateer-daemon.mjs — no moat-shim install (the
91
+ // daemon loads the moat as in-code factories, not interactive extensions).
92
+ else if (sub === "daemon") {
93
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
94
+ runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-daemon.mjs"), ...args.slice(1)]);
95
+ }
96
+
97
+ // --- normal launch: install the moat, then exec Pi's TUI -------------------
98
+ else {
99
+ const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
100
+ const EXT_DIR = path.join(AGENT_DIR, "extensions");
101
+ fs.mkdirSync(EXT_DIR, { recursive: true });
102
+
103
+ // Install/refresh the moat + tool-pack shims. Each shim re-exports its target by
104
+ // ABSOLUTE path (as a file:// URL, portable across OSes) so the target's own
105
+ // relative imports resolve from the repo. We remove any shim we previously managed
106
+ // first, so a dropped package can't linger and reload.
107
+ const MANAGED = [
108
+ "privateer-brand", "privateer-context", "privateer-gate", "privateer-account",
109
+ "privateer-models", "privateer-posture", "privateer-tools", "privateer-privacy",
110
+ "pi-privacy", "pi-web-access", "rpiv-web-tools", "pi-mcp-adapter", "pi-hypa", "pi-subagents",
111
+ ];
112
+ for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
113
+
114
+ const ext = (...p) => path.join(REPO, "extensions", ...p);
115
+ const dep = (...p) => path.join(REPO, "node_modules", ...p);
116
+ const shim = (name, target) =>
117
+ fs.writeFileSync(path.join(EXT_DIR, `${name}.ts`), `export { default } from ${JSON.stringify(pathToFileURL(target).href)};\n`);
118
+
119
+ shim("privateer-brand", ext("privateer-brand.ts")); // banner, ⚓ badge, /signin /signout
120
+ shim("privateer-context", ext("privateer-context.ts")); // PRIVATEER.md context + /init
121
+ shim("privateer-gate", ext("privateer-gate.ts")); // the permission gate (moat)
122
+ shim("privateer-account", ext("privateer-account.ts"));
123
+ shim("privateer-models", ext("privateer-models.ts")); // /models picker w/ privacy shields
124
+ shim("privateer-posture", ext("privateer-posture.ts"));
125
+ shim("privateer-tools", ext("privateer-tools.ts"));
126
+ shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
127
+ shim("rpiv-web-tools", dep("@juicesharp", "rpiv-web-tools", "index.ts")); // private web tools
128
+ shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
129
+ shim("pi-hypa", dep("@hypabolic", "pi-hypa", "extensions", "index.ts"));
130
+ shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
131
+
132
+ const CLI = dep("@earendil-works", "pi-coding-agent", "dist", "cli.js");
133
+ process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
134
+ // The binary pi-subagents spawns for each child. Point it at OUR cli.js so the child
135
+ // reads this same PI_CODING_AGENT_DIR and DISCOVERS the moat shims (gated + private,
136
+ // no -e injection). Set only when unset so a power user can override.
137
+ if (!process.env.PI_SUBAGENT_PI_BINARY) process.env.PI_SUBAGENT_PI_BINARY = CLI;
138
+ // Suppress Pi's upstream update banner (our banner is the startup surface). Disables
139
+ // ONLY the version fetch — fd/rg can still download on first run.
140
+ if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
141
+
142
+ // Quiet Pi's built-in startup chatter so our banner is the only greeting. Each key is
143
+ // set only when unset, so a user's own settings.json toggle still wins.
144
+ try {
145
+ const sp = path.join(AGENT_DIR, "settings.json");
146
+ let s = {};
147
+ try { s = JSON.parse(fs.readFileSync(sp, "utf8")); } catch { /* new/absent */ }
148
+ let m = false;
149
+ if (s.quietStartup === undefined) { s.quietStartup = true; m = true; }
150
+ if (s.collapseChangelog === undefined) { s.collapseChangelog = true; m = true; }
151
+ if (s.lastChangelogVersion === undefined) { s.lastChangelogVersion = "9999.0.0"; m = true; }
152
+ if (m) fs.writeFileSync(sp, JSON.stringify(s, null, 2) + "\n");
153
+ } catch { /* best-effort */ }
154
+
155
+ // Passive update check: refresh the cached "latest version" at most ~daily, in the
156
+ // background so it never blocks or breaks launch (offline-safe). The banner reads
157
+ // this cache and shows a "↑ vX available · run privateer update" notice. We never
158
+ // auto-install. Fire-and-forget: the event loop stays alive while the TUI child runs.
159
+ refreshUpdateCache();
160
+
161
+ // Default model. Explicit PRIVATEER_MODEL wins; else Tinfoil GLM 5.2 (client-attested
162
+ // TEE, strongest tier) when a Tinfoil key is present; else the signed-in account's
163
+ // NEAR channel; else a cheap OpenRouter fallback.
164
+ const CRED = path.join(PRIVATEER_HOME, "credentials.json");
165
+ const MODEL = process.env.PRIVATEER_MODEL
166
+ ? process.env.PRIVATEER_MODEL
167
+ : haveTinfoilKey()
168
+ ? "tinfoil/glm-5-2"
169
+ : fs.existsSync(CRED)
170
+ ? "privateer/near/zai-org/GLM-5.1-FP8"
171
+ : "openrouter/openai/gpt-4o-mini";
172
+
173
+ // Dev convenience: load provider keys from the repo's .env if present.
174
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
175
+ runToCompletion(NODE_BIN, [...nodeArgs, CLI, "--model", MODEL, ...args]);
176
+ }
177
+
178
+ // --- helpers ---------------------------------------------------------------
179
+ function haveTinfoilKey() {
180
+ if (process.env.TINFOIL_API_KEY) return true;
181
+ try { return /^TINFOIL_API_KEY=.+/m.test(fs.readFileSync(ENV_FILE, "utf8")); }
182
+ catch { return false; }
183
+ }
184
+
185
+ function refreshUpdateCache() {
186
+ const cache = path.join(PRIVATEER_HOME, "update-check.json");
187
+ try {
188
+ const stat = fs.statSync(cache);
189
+ if (Date.now() - stat.mtimeMs < 24 * 60 * 60 * 1000) return; // fresh (<1 day)
190
+ } catch { /* missing — refresh */ }
191
+
192
+ const write = (latest) => {
193
+ if (!/^[0-9]/.test(latest || "")) return;
194
+ try {
195
+ fs.mkdirSync(PRIVATEER_HOME, { recursive: true });
196
+ fs.writeFileSync(cache, JSON.stringify({ latest }) + "\n");
197
+ } catch { /* best-effort */ }
198
+ };
199
+
200
+ if (BUNDLED) {
201
+ // No npm in a bundle — read the latest tag off GitHub Releases.
202
+ fetch("https://api.github.com/repos/privateer-agent/privateer-agent/releases/latest", {
203
+ headers: { "User-Agent": "privateer-cli", Accept: "application/vnd.github+json" },
204
+ })
205
+ .then((r) => (r.ok ? r.json() : null))
206
+ .then((j) => write(String(j?.tag_name || "").replace(/^v/, "")))
207
+ .catch(() => { /* offline — keep stale cache */ });
208
+ } else {
209
+ const p = spawn(isWin ? "npm.cmd" : "npm", ["view", "privateer-agent", "version"], {
210
+ stdio: ["ignore", "pipe", "ignore"],
211
+ });
212
+ let out = "";
213
+ p.stdout.on("data", (d) => { out += d; });
214
+ p.on("close", () => write(out.trim()));
215
+ p.on("error", () => { /* no npm — ignore */ });
216
+ p.unref();
217
+ }
218
+ }
package/bin/privateer-tui CHANGED
@@ -1,15 +1,10 @@
1
1
  #!/usr/bin/env bash
2
- # Launch Pi's full interactive TUI with the Privateer moat + tool packs. The moat is
3
- # installed as re-export SHIMS in the agent dir's extensions/, so BOTH this TUI and
4
- # any subagents it spawns (which run as child `pi` processes reading the agent dir)
5
- # load the identical setincluding our permission gate. So subagent tool calls are
6
- # gated too (the gate runs bypass-within-restricted-tools headlessly; danger.ts still
7
- # blocks destructive shell). One source of truth via discovery — no -e (which would
8
- # double-load vs discovery).
9
- #
10
- # Runs in the current directory. Model via PRIVATEER_MODEL=provider/id.
2
+ # Thin unix shim: pick a Node, then hand off to the cross-platform launcher
3
+ # (bin/privateer-launch.mjs), which is the single source of launch logic shared with
4
+ # the Windows privateer.cmd. All the real work moat-shim install, model pick,
5
+ # update/daemon dispatch, exec into Pi's TUI lives in the launcher.
11
6
  set -euo pipefail
12
- # Resolve through symlinks so REPO points at the real repo, not the symlink dir.
7
+ # Resolve through symlinks so REPO points at the real bundle/repo dir.
13
8
  SOURCE="${BASH_SOURCE[0]}"
14
9
  while [ -h "$SOURCE" ]; do
15
10
  DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
@@ -18,16 +13,9 @@ while [ -h "$SOURCE" ]; do
18
13
  done
19
14
  REPO="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
20
15
 
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
-
16
+ # Bundled runtime wins; else a system node >=22; else an nvm node; else PATH `node`.
30
17
  pick_node() {
18
+ if [ -x "$REPO/node" ] && [ -f "$REPO/BUNDLE_INFO.json" ]; then echo "$REPO/node"; return; fi
31
19
  if command -v node >/dev/null 2>&1 \
32
20
  && node -e 'process.exit((+process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' 2>/dev/null; then
33
21
  command -v node; return
@@ -38,127 +26,4 @@ pick_node() {
38
26
  echo "node"
39
27
  }
40
28
 
41
- # `privateer daemon [run|install|uninstall|status]` — the resident background daemon
42
- # (routines + app-driven headless task spawns). Handled HERE, before the Pi TUI exec
43
- # and before the moat-shim install (the daemon doesn't need the interactive extension
44
- # set), so it boots straight into src/daemon via bin/privateer-daemon.mjs.
45
- if [ "${1:-}" = "daemon" ]; then
46
- shift
47
- DAEMON_NODE="$(pick_node)"
48
- DAEMON_ENV_ARGS=""
49
- [ -f "$REPO/.env" ] && DAEMON_ENV_ARGS="--env-file=$REPO/.env"
50
- exec "$DAEMON_NODE" ${DAEMON_ENV_ARGS:+"$DAEMON_ENV_ARGS"} "$REPO/bin/privateer-daemon.mjs" "$@"
51
- fi
52
-
53
- AGENT_DIR="${PRIVATEER_HOME:-$HOME/.privateer}/agent"
54
- EXT_DIR="$AGENT_DIR/extensions"
55
- mkdir -p "$EXT_DIR"
56
-
57
- # Install/refresh the moat + tool-pack shims. Each shim re-exports its target by
58
- # ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
59
- # symlink would resolve them relative to the shim's location and break). We remove
60
- # any shim we previously managed first, so a dropped package can't linger and reload.
61
- MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-models privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
62
- for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
63
- shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
64
- # Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
65
- shim privateer-brand "$REPO/extensions/privateer-brand.ts"
66
- # PRIVATEER.md project-context loading (like AGENTS.md/CLAUDE.md) + the /init command.
67
- shim privateer-context "$REPO/extensions/privateer-context.ts"
68
- shim privateer-gate "$REPO/extensions/privateer-gate.ts"
69
- shim privateer-account "$REPO/extensions/privateer-account.ts"
70
- # The /models picker — searchable model selector with per-row privacy shields
71
- # (TEE / ZDR / standard). Pi's built-in /model is redirected here by the
72
- # patch-package patch (patches/@earendil-works+pi-coding-agent+*.patch).
73
- shim privateer-models "$REPO/extensions/privateer-models.ts"
74
- shim privateer-posture "$REPO/extensions/privateer-posture.ts"
75
- shim privateer-tools "$REPO/extensions/privateer-tools.ts"
76
- # pi-privacy wrapped with the account-channel tier resolver (privateer/near… = TEE),
77
- # so it replaces loading pi-privacy's default entry.
78
- shim privateer-privacy "$REPO/extensions/privateer-privacy.ts"
79
- # Privacy-aligned web tools: pluggable backends (SearXNG self-hosted = private; no
80
- # WebView). Pick the backend with /web-tools, or set SEARXNG_URL. Replaces
81
- # pi-web-access (opened a WebView + defaulted to Exa, a 3rd-party search).
82
- shim rpiv-web-tools "$REPO/node_modules/@juicesharp/rpiv-web-tools/index.ts"
83
- shim pi-mcp-adapter "$REPO/node_modules/pi-mcp-adapter/index.ts"
84
- shim pi-hypa "$REPO/node_modules/@hypabolic/pi-hypa/extensions/index.ts"
85
- shim pi-subagents "$REPO/node_modules/pi-subagents/src/extension/index.ts"
86
-
87
- CLI="$REPO/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
88
- export PI_CODING_AGENT_DIR="$AGENT_DIR"
89
- # The binary pi-subagents spawns for each subagent child. Without this it falls back
90
- # to `pi` on PATH (getPiSpawnCommand) — which a Privateer install does NOT provide, so
91
- # every subagent spawn would fail with ENOENT. Point it at OUR bundled cli.js (it's
92
- # executable, `#!/usr/bin/env node`): the child then reads this same PI_CODING_AGENT_DIR
93
- # and DISCOVERS the moat shims above — so subagents run gated + private, no -e injection
94
- # (hence no double-load). Set only when unset so a power user can override it.
95
- export PI_SUBAGENT_PI_BINARY="${PI_SUBAGENT_PI_BINARY:-$CLI}"
96
- # Suppress Pi's upstream "Update Available — run `pi update`" banner. It's noise on a
97
- # Privateer install (our banner is the startup surface), it leaks the "pi" name, and
98
- # `pi update` would fight our npm-managed install. PI_SKIP_VERSION_CHECK disables ONLY
99
- # the version fetch — unlike PI_OFFLINE it still lets fd/rg download on first run.
100
- export PI_SKIP_VERSION_CHECK=1
101
- NODE_BIN="$(pick_node)"
102
-
103
- # Quiet Pi's built-in startup chatter so our banner is the only greeting. Each key is
104
- # set only when unset, so a user's own settings.json toggle (or --verbose) still wins:
105
- # quietStartup — hide the [Extensions]/[Context]/[Skills]/… resource listing
106
- # collapseChangelog — never dump Pi's "What's New" changelog wall into the chat
107
- # lastChangelogVersion — sentinel so Pi finds no "new" entries to show on upgrade;
108
- # release notes live in the npm README / GitHub releases instead
109
- "$NODE_BIN" -e '
110
- const fs = require("fs"), p = process.argv[1];
111
- let s = {}; try { s = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
112
- let m = false;
113
- if (s.quietStartup === undefined) { s.quietStartup = true; m = true; }
114
- if (s.collapseChangelog === undefined) { s.collapseChangelog = true; m = true; }
115
- if (s.lastChangelogVersion === undefined) { s.lastChangelogVersion = "9999.0.0"; m = true; }
116
- if (m) fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
117
- ' "$AGENT_DIR/settings.json" 2>/dev/null || true
118
-
119
- # Passive update check: refresh the cached "latest npm version" at most ~daily, in the
120
- # BACKGROUND so it never blocks or breaks launch (offline-safe — a failed fetch just
121
- # leaves the stale cache in place). The banner (privateer-brand) reads this cache and
122
- # shows a one-line "↑ vX available · run privateer update" notice when we're behind. We
123
- # never auto-install — the user stays in control of when new code lands (the whole point
124
- # of an attestable tool). Detached with </dev/null &, so it outlives our exec into node.
125
- UPDATE_CACHE="${PRIVATEER_HOME:-$HOME/.privateer}/update-check.json"
126
- if [ -z "$(find "$UPDATE_CACHE" -mtime -1 2>/dev/null)" ]; then
127
- (
128
- latest="$(npm view privateer-agent version 2>/dev/null || true)"
129
- case "$latest" in
130
- [0-9]*) printf '{"latest":"%s"}\n' "$latest" > "$UPDATE_CACHE.tmp" 2>/dev/null \
131
- && mv -f "$UPDATE_CACHE.tmp" "$UPDATE_CACHE" 2>/dev/null ;;
132
- esac
133
- ) </dev/null >/dev/null 2>&1 &
134
- fi
135
-
136
- # Default model. An explicit PRIVATEER_MODEL always wins. Otherwise prefer Tinfoil's
137
- # GLM 5.2 when a Tinfoil key is available: verifiable TEE inference with CLIENT-side
138
- # attestation (the live TLS key is bound to the enclave's quote), the strongest privacy
139
- # tier we offer — stronger than the account's server-proxied NEAR channel. Failing that,
140
- # use the signed-in Privateer account's NEAR confidential-compute channel; with neither,
141
- # fall back to a cheap OpenRouter model. The Tinfoil key may sit in the ambient env or in
142
- # the dev .env the launcher loads below, so check both.
143
- CRED="${PRIVATEER_HOME:-$HOME/.privateer}/credentials.json"
144
- have_tinfoil_key() {
145
- [ -n "${TINFOIL_API_KEY:-}" ] && return 0
146
- [ -f "$REPO/.env" ] && grep -qE '^TINFOIL_API_KEY=.+' "$REPO/.env"
147
- }
148
- if [ -n "${PRIVATEER_MODEL:-}" ]; then
149
- MODEL="$PRIVATEER_MODEL"
150
- elif have_tinfoil_key; then
151
- MODEL="tinfoil/glm-5-2"
152
- elif [ -f "$CRED" ]; then
153
- MODEL="privateer/near/zai-org/GLM-5.1-FP8"
154
- else
155
- MODEL="openrouter/openai/gpt-4o-mini"
156
- fi
157
-
158
- # Dev convenience: load provider keys from the repo's .env if present. A real install
159
- # has none — so only pass the flag when the file exists (using --env-file-if-exists
160
- # unconditionally would print "…/.env not found. Continuing without it." on every
161
- # launch, muddying the startup banner). No .env → rely on the ambient environment.
162
- ENV_ARGS=""
163
- [ -f "$REPO/.env" ] && ENV_ARGS="--env-file=$REPO/.env"
164
- exec "$NODE_BIN" ${ENV_ARGS:+"$ENV_ARGS"} "$CLI" --model "$MODEL" "$@"
29
+ exec "$(pick_node)" "$REPO/bin/privateer-launch.mjs" "$@"
@@ -0,0 +1,6 @@
1
+ @echo off
2
+ REM Windows entry point for a Privateer bundle. Mirrors the unix bin/privateer-tui
3
+ REM shim: run the bundled Node against the shared cross-platform launcher. %~dp0 is
4
+ REM this file's dir (<app>\bin\), so ..\node.exe is the bundled runtime.
5
+ "%~dp0..\node.exe" "%~dp0privateer-launch.mjs" %*
6
+ exit /b %errorlevel%
@@ -24,7 +24,9 @@ import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import * as priv from "../src/auth/privateer.ts";
26
26
  import { makeAccountProvider } from "../src/providers/account.ts";
27
+ import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
27
28
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
29
+ import { type Palette, paletteFor } from "../src/ui/palette.ts";
28
30
 
29
31
  const VERSION: string = (() => {
30
32
  try {
@@ -34,37 +36,26 @@ const VERSION: string = (() => {
34
36
  }
35
37
  })();
36
38
 
37
- // ── palette (Privateer brand) ────────────────────────────────────────────────
38
- // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
39
- // truecolor and mangles it (the old indigo/cyan came out green). These indices are
40
- // universally supported. Navy (the logo mark) is too dark to read on a dark terminal, so
41
- // the whole banner silhouette, wordmark, frame, and accents is painted white: a clean
42
- // single-color mark, like the logo but legible on dark.
43
- const ESC = "\x1b[";
44
- const RESET = `${ESC}0m`;
45
- const BOLD = `${ESC}1m`;
46
- const c = (n: number): string => `${ESC}38;5;${n}m`;
47
- const OCEAN = c(231); // white (#ffffff) — anchor / wordmark "P"
48
- const OCEAN_LIGHT = c(231); // white (#ffffff) — wordmark, version, path
49
- const BORDER = c(231); // white (#ffffff) — the frame
50
- const DIM = `${ESC}90m`;
51
- const GREEN = `${ESC}32m`;
52
- const YELLOW = `${ESC}33m`;
39
+ // The banner paints from Pi's ACTIVE theme (paletteFor, in src/ui/palette.ts) rather
40
+ // than a fixed colour. It used to hardcode everything to white (256-color 231) "because
41
+ // navy is too dark on a dark terminal" which inverts the problem on a LIGHT terminal
42
+ // (white-on-white the whole mark, wordmark, and frame vanish). Pi auto-detects the
43
+ // terminal background and picks a light or dark theme; ctx.ui.setHeader hands our factory
44
+ // that live Theme (and ctx.ui.theme exposes it to the sign-in widget), so the banner's
45
+ // colours resolve to dark ink on a light bg and light ink on a dark bg.
53
46
 
54
47
  // The Privateer mark: our symbol — a padlock (with a keyhole) fused into an anchor,
55
48
  // "bring your own model" meets lock-and-key privacy — drawn from the app's logo. It's
56
- // rendered with terminal HALF-BLOCKS, so each text row packs TWO pixel rows: a "▀"
57
- // whose FOREGROUND paints the top pixel and BACKGROUND the bottom (one pixel "▀"/"▄"
58
- // on the default bg; none → a plain space). 256-color indices only, same reason as the
59
- // palette above. Every built line is MARK_W visible cells wide (SGR escapes don't
60
- // count), so the text column beside it stays aligned. To redraw: edit PIXELS (each char
61
- // is a PX palette key), keeping every row MARK_W long and the row COUNT even — the
62
- // builder derives the escapes from that.
49
+ // rendered with terminal HALF-BLOCKS, so each text row packs TWO pixel rows. The mark is
50
+ // a SINGLE colour (every set pixel is the accent), so a cell never needs two different
51
+ // colours: both pixels set → a full block "█", top only → "▀", bottom only "▄", none →
52
+ // a space all painted with the accent as FOREGROUND, no background cells at all. That
53
+ // makes the mark inherit the theme's ink (dark on light, light on dark) with one colour,
54
+ // and sidesteps the old bg-bleed hazard entirely. Every built line is MARK_W visible
55
+ // cells wide (SGR escapes don't count), so the text column beside it stays aligned. To
56
+ // redraw: edit PIXELS (each char is "O" ink or "." transparent), keeping every row MARK_W
57
+ // long and the row COUNT even — the builder pairs rows into half-block cells.
63
58
  const MARK_W = 12;
64
- const PX: Record<string, number | null> = {
65
- ".": null, // transparent — the frame (and the knocked-out keyhole) shows through
66
- O: 231, // white (#ffffff, top of the 256-color cube) — the silhouette, a clean single-color mark
67
- };
68
59
  // 12 wide; an EVEN number of rows so they pair cleanly into half-block cells. Two blank
69
60
  // leading rows give the lock a little headroom without dropping the whole mark too low.
70
61
  // A small padlock rides on top as the anchor's ring: a narrow rounded shackle over an
@@ -83,26 +74,27 @@ const PIXELS = [
83
74
  ".OO..OO..OO.", "..OO.OO.OO..",
84
75
  "..OOOOOOOO..", "...OOOOOO...", "....OOOO....", ".....OO.....",
85
76
  ];
86
- // Build the mark once at load. Each cell resets SGR so a background color can never
87
- // bleed into the row padding the framer adds after it.
88
- const MARK: string[] = (() => {
77
+ // Build the mark for a given palette (the accent is the ink). Cheap called once per
78
+ // header factory invocation, i.e. once per theme, not per frame. Each cell resets SGR so
79
+ // the accent can never bleed into the row padding the framer adds after it.
80
+ function buildMark(p: Palette): string[] {
89
81
  const rows: string[] = [];
90
82
  for (let r = 0; r < PIXELS.length; r += 2) {
91
83
  const top = PIXELS[r];
92
84
  const bot = PIXELS[r + 1] ?? ".".repeat(MARK_W);
93
85
  let line = "";
94
86
  for (let x = 0; x < MARK_W; x++) {
95
- const t = PX[top[x]];
96
- const bcol = PX[bot[x]];
97
- if (t == null && bcol == null) line += " ";
98
- else if (t != null && bcol != null) line += `${ESC}38;5;${t}m${ESC}48;5;${bcol}m▀${RESET}`;
99
- else if (t != null) line += `${ESC}38;5;${t}m▀${RESET}`;
100
- else line += `${ESC}38;5;${bcol}m▄${RESET}`;
87
+ const t = top[x] === "O";
88
+ const b = bot[x] === "O";
89
+ if (t && b) line += `${p.ACCENT}█${p.RESET}`;
90
+ else if (t) line += `${p.ACCENT}▀${p.RESET}`;
91
+ else if (b) line += `${p.ACCENT}▄${p.RESET}`;
92
+ else line += " ";
101
93
  }
102
94
  rows.push(line);
103
95
  }
104
96
  return rows;
105
- })();
97
+ }
106
98
 
107
99
  // Visible width = characters after stripping SGR escapes. Everything we render inside
108
100
  // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
@@ -140,16 +132,16 @@ function shortCwd(): string {
140
132
  // - signed out AND the current model bills to a Privateer account → it can't run
141
133
  // until they sign in, so say so plainly (warning)
142
134
  // - signed out on their own key → a quiet tease that /login adds more
143
- function accountLine(modelProvider?: string): string {
135
+ function accountLine(p: Palette, modelProvider?: string): string {
144
136
  const u = priv.currentUser();
145
137
  if (u) {
146
138
  const label = clean(u.email ?? (u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id));
147
- return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
139
+ return `${p.GREEN}connected${p.DIM} as ${p.RESET}${p.INK}${label}${p.RESET}`;
148
140
  }
149
141
  if (modelProvider === "privateer") {
150
- return `${YELLOW}not signed in · /login to use this model${RESET}`;
142
+ return `${p.YELLOW}not signed in · /login to use this model${p.RESET}`;
151
143
  }
152
- return `${DIM}not signed in · ${OCEAN_LIGHT}/login${DIM} to connect your account${RESET}`;
144
+ return `${p.DIM}not signed in · ${p.INK}/login${p.DIM} to connect your account${p.RESET}`;
153
145
  }
154
146
 
155
147
  // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
@@ -167,12 +159,12 @@ function isNewer(a: string, b: string): boolean {
167
159
  // The "update available" banner line, or "" when we're current / offline / unchecked.
168
160
  // Reads the cache the launcher refreshes in the background (see bin/privateer-tui) —
169
161
  // never fetches here, so the banner stays synchronous and never blocks on the network.
170
- function updateNotice(): string {
162
+ function updateNotice(p: Palette): string {
171
163
  try {
172
164
  const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
173
165
  const { latest } = JSON.parse(readFileSync(join(home, "update-check.json"), "utf8"));
174
166
  if (typeof latest === "string" && isNewer(latest, VERSION)) {
175
- return `${YELLOW}↑ v${latest} available${DIM} · run ${RESET}${OCEAN_LIGHT}privateer update${RESET}`;
167
+ return `${p.YELLOW}↑ v${latest} available${p.DIM} · run ${p.RESET}${p.INK}privateer update${p.RESET}`;
176
168
  }
177
169
  } catch {
178
170
  // no cache yet, unreadable, or malformed — show nothing.
@@ -184,16 +176,16 @@ function updateNotice(): string {
184
176
  // loaded (so the moat's "the agent knows this project" state is visible), otherwise a
185
177
  // quiet tease that /init scaffolds one. Reads the filesystem at render time, so it
186
178
  // reflects the current cwd and updates after /init (via onContextChanged → refresh).
187
- function contextLine(): string {
179
+ function contextLine(p: Palette): string {
188
180
  const files = discoverContextFiles();
189
181
  if (files.length === 0) {
190
- return `${DIM}no PRIVATEER.md · ${OCEAN_LIGHT}/init${DIM} to add project context${RESET}`;
182
+ return `${p.DIM}no PRIVATEER.md · ${p.INK}/init${p.DIM} to add project context${p.RESET}`;
191
183
  }
192
184
  // Show the nearest (deepest, wins-last) file's path; note any additional ancestors
193
185
  // with a "+N" so the header stays one line but the count isn't hidden.
194
186
  const nearest = shortPath(files[files.length - 1].path);
195
- const more = files.length > 1 ? `${DIM} +${files.length - 1}${RESET}` : "";
196
- return `${GREEN}⚓${DIM} ${RESET}${OCEAN_LIGHT}${nearest}${RESET}${more}`;
187
+ const more = files.length > 1 ? `${p.DIM} +${files.length - 1}${p.RESET}` : "";
188
+ return `${p.GREEN}⚓${p.DIM} ${p.RESET}${p.INK}${nearest}${p.RESET}${more}`;
197
189
  }
198
190
 
199
191
  // ── "What's New" — a tiny in-banner changelog ────────────────────────────────
@@ -206,11 +198,11 @@ const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
206
198
  { text: "Self-update built in —", cmd: "privateer update" },
207
199
  ];
208
200
 
209
- function whatsNewRows(): string[] {
210
- const head = `${BOLD}${OCEAN_LIGHT}✦ What's new${RESET}`;
201
+ function whatsNewRows(p: Palette): string[] {
202
+ const head = `${p.BOLD}${p.INK}✦ What's new${p.RESET}`;
211
203
  const items = WHATS_NEW.map(
212
204
  ({ text, cmd }) =>
213
- `${OCEAN}·${RESET} ${DIM}${text}${RESET}${cmd ? ` ${OCEAN_LIGHT}${cmd}${RESET}` : ""}`,
205
+ `${p.ACCENT}·${p.RESET} ${p.DIM}${text}${p.RESET}${cmd ? ` ${p.INK}${cmd}${p.RESET}` : ""}`,
214
206
  );
215
207
  return [head, ...items];
216
208
  }
@@ -220,51 +212,55 @@ function whatsNewRows(): string[] {
220
212
  // mark), so we zip by row index and pad the short side — every text-only row lands in
221
213
  // the same column as the rows beside the mark. One place owns the left gutter, so
222
214
  // spacing can't drift between the mark rows and the trailing rows.
223
- function renderBanner(width: number, modelProvider?: string): string[] {
215
+ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?: string): string[] {
224
216
  // Right column, top to bottom. The two leading blanks drop the wordmark down so it
225
217
  // sits beside the lock body (not the shackle); the rest follows in reading order.
226
218
  const text: string[] = [
227
219
  "",
228
- `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}${DIM} privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
229
- `${DIM}Chart your own course privately.${RESET}`,
220
+ `${p.BOLD}${p.ACCENT}✻ ${p.ACCENT}P${p.INK}RIVATEER${p.RESET}${p.DIM} privateer-agent ${p.INK}v${VERSION}${p.RESET}`,
221
+ `${p.DIM}Chart your own course privately.${p.RESET}`,
230
222
  "",
231
- accountLine(modelProvider),
232
- `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
233
- contextLine(),
223
+ accountLine(p, modelProvider),
224
+ `${p.INK}${shortCwd()}${p.RESET}`,
225
+ contextLine(p),
234
226
  ];
235
- const notice = updateNotice();
227
+ const notice = updateNotice(p);
236
228
  if (notice) text.push(notice);
237
229
  // A blank spacer, then the What's New block — set off below the identity lines.
238
- text.push("", ...whatsNewRows());
230
+ text.push("", ...whatsNewRows(p));
239
231
 
240
232
  // Zip the mark and the text column by row. Rows past the mark's height get a blank
241
233
  // gutter of the mark's width, so the text stays in one column throughout.
242
234
  const gap = " ";
243
- const height = Math.max(MARK.length, text.length);
235
+ const height = Math.max(mark.length, text.length);
244
236
  const rows: string[] = [];
245
237
  for (let i = 0; i < height; i++) {
246
238
  // The mark lines already carry their own per-pixel colors, so we don't wrap them.
247
- const left = i < MARK.length ? MARK[i] : " ".repeat(MARK_W);
239
+ const left = i < mark.length ? mark[i] : " ".repeat(MARK_W);
248
240
  rows.push(`${left}${gap}${text[i] ?? ""}`.trimEnd());
249
241
  }
250
242
 
251
243
  const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
252
244
  const inner = Math.min(cap, Math.max(...rows.map(vlen)));
253
245
  const bar = "─".repeat(inner + 2);
254
- const out = [`${BORDER}╭${bar}╮${RESET}`];
246
+ const out = [`${p.BORDER}╭${bar}╮${p.RESET}`];
255
247
  for (const row of rows) {
256
248
  const pad = Math.max(0, inner - vlen(row));
257
- out.push(`${BORDER}│${RESET} ${row}${" ".repeat(pad)} ${BORDER}│${RESET}`);
249
+ out.push(`${p.BORDER}│${p.RESET} ${row}${" ".repeat(pad)} ${p.BORDER}│${p.RESET}`);
258
250
  }
259
- out.push(`${BORDER}╰${bar}╯${RESET}`);
251
+ out.push(`${p.BORDER}╰${bar}╯${p.RESET}`);
260
252
  return out;
261
253
  }
262
254
 
263
255
  // A Pi header Component (setHeader factory return). Static banner; captures the model
264
- // provider so the account line reflects the picked model.
265
- function headerComponent(modelProvider?: string) {
256
+ // provider so the account line reflects the picked model, and the live theme so every
257
+ // colour tracks the terminal background (dark ink on light, light ink on dark). The
258
+ // palette and mark are resolved once here (per theme), not per frame.
259
+ function headerComponent(theme: any, modelProvider?: string) {
260
+ const p = paletteFor(theme);
261
+ const mark = buildMark(p);
266
262
  return {
267
- render: (width: number): string[] => renderBanner(width, modelProvider),
263
+ render: (width: number): string[] => renderBanner(width, p, mark, modelProvider),
268
264
  invalidate() {},
269
265
  };
270
266
  }
@@ -293,8 +289,12 @@ export default function privateerBrand(pi: any): void {
293
289
  }
294
290
  };
295
291
 
292
+ // Pi calls the factory with (tui, theme) — pass the live theme through so the banner
293
+ // paints from it. Falls back to ctx.ui.theme when a Pi build hands the factory no theme.
296
294
  const setHeader = (ctx: any) =>
297
- ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
295
+ ctx?.ui?.setHeader?.((_tui: any, theme: any) =>
296
+ headerComponent(theme ?? ctx?.ui?.theme, currentModelProvider),
297
+ );
298
298
 
299
299
  const refresh = (ctx: any) => {
300
300
  dbg(`refresh: hasUI=${!!ctx?.hasUI} hasSetHeader=${typeof ctx?.ui?.setHeader} user=${priv.currentUser()?.email ?? null}`);
@@ -355,6 +355,7 @@ export default function privateerBrand(pi: any): void {
355
355
  }
356
356
  ctx?.ui?.notify?.("Connecting to Privateer — requesting a device code…", "info");
357
357
  try {
358
+ const p = paletteFor(ctx?.ui?.theme);
358
359
  const user = await priv.runDeviceLogin({
359
360
  onCode: (code: any) => {
360
361
  const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
@@ -362,11 +363,11 @@ export default function privateerBrand(pi: any): void {
362
363
  ctx?.ui?.setWidget?.(
363
364
  "privateer-signin",
364
365
  [
365
- `${OCEAN_LIGHT}⚓ Sign in to Privateer${RESET}`,
366
- `${DIM}Approve this terminal in the Privateer app:${RESET}`,
367
- ` code ${BOLD}${OCEAN}${userCode}${RESET}`,
368
- uri ? `${DIM} or open ${RESET}${OCEAN_LIGHT}${uri}${RESET}` : "",
369
- `${DIM} waiting for approval…${RESET}`,
366
+ `${p.INK}⚓ Sign in to Privateer${p.RESET}`,
367
+ `${p.DIM}Approve this terminal in the Privateer app:${p.RESET}`,
368
+ ` code ${p.BOLD}${p.ACCENT}${userCode}${p.RESET}`,
369
+ uri ? `${p.DIM} or open ${p.RESET}${p.INK}${uri}${p.RESET}` : "",
370
+ `${p.DIM} waiting for approval…${p.RESET}`,
370
371
  ].filter(Boolean),
371
372
  { placement: "aboveEditor" },
372
373
  );
@@ -406,6 +407,47 @@ export default function privateerBrand(pi: any): void {
406
407
  );
407
408
  }
408
409
 
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 override — respect 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}`);
441
+ return;
442
+ }
443
+ } catch (e) {
444
+ dbg(`activateSignedInModel: setModel threw ${(e as Error).message}`);
445
+ }
446
+ await new Promise((r) => setTimeout(r, 400)); // credential still spawning — retry
447
+ }
448
+ dbg(`activateSignedInModel: gave up switching to ${spec}`);
449
+ }
450
+
409
451
  dbg("extension loaded, onSignedIn listener registering");
410
452
 
411
453
  pi.on("session_start", (_e: any, ctx: any) => {
@@ -451,6 +493,9 @@ export default function privateerBrand(pi: any): void {
451
493
  priv.onSignedIn(() => {
452
494
  dbg(`onSignedIn fired; ctxRef=${ctxRef ? "set" : "null"}`);
453
495
  refresh(ctxRef);
496
+ // Activate a confidential model in the live session so the user can prompt right
497
+ // away instead of dead-ending on the keyless launch model. See activateSignedInModel.
498
+ void activateSignedInModel(ctxRef);
454
499
  });
455
500
 
456
501
  // /init (in privateer-context) just created or changed a PRIVATEER.md — re-render the
@@ -29,6 +29,7 @@ import { agentDir } from "../src/config/paths.ts";
29
29
  import { agentVersion } from "../src/config/version.ts";
30
30
  import { SettingsManager } from "@earendil-works/pi-coding-agent";
31
31
  import * as priv from "../src/auth/privateer.ts";
32
+ import { paletteFor } from "../src/ui/palette.ts";
32
33
  import type { PermissionMode } from "../src/config/permissionMode.ts";
33
34
 
34
35
  const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
@@ -221,7 +222,6 @@ function advertiseCommands(): { name: string; description?: string }[] {
221
222
  // driven from the phone — with a reminder that `/remote-access off` stops it. We
222
223
  // keep a UI handle (captured from session_start / the command ctx) so the relay's
223
224
  // own connect/disconnect callbacks can refresh the indicator, not just the command.
224
- const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", DIM = "\x1b[2m", RESET = "\x1b[0m";
225
225
  const REMOTE_STATUS_KEY = "privateer:remote-access";
226
226
  let uiRef: any = null;
227
227
  // "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
@@ -235,10 +235,13 @@ function refreshRemoteStatus(): void {
235
235
  ui.setStatus(REMOTE_STATUS_KEY, undefined);
236
236
  return;
237
237
  }
238
+ // Paint from the active theme so the footer reads on a light terminal too (a bare
239
+ // green/yellow escape can wash out on white) — falls back to white on no theme.
240
+ const p = paletteFor(ui.theme);
238
241
  const text =
239
242
  remoteState === "connected"
240
- ? `${GREEN}⟿ remote access${RESET} ${DIM}· /remote-access off to stop${RESET}`
241
- : `${YELLOW}⟿ remote access · connecting…${RESET} ${DIM}· /remote-access off to stop${RESET}`;
243
+ ? `${p.GREEN}⟿ remote access${p.RESET} ${p.DIM}· /remote-access off to stop${p.RESET}`
244
+ : `${p.YELLOW}⟿ remote access · connecting…${p.RESET} ${p.DIM}· /remote-access off to stop${p.RESET}`;
242
245
  ui.setStatus(REMOTE_STATUS_KEY, text);
243
246
  }
244
247
 
@@ -8,31 +8,28 @@
8
8
 
9
9
  import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
10
  import { accountPosture } from "../src/providers/account.ts";
11
+ import { type Palette, paletteFor } from "../src/ui/palette.ts";
11
12
 
12
13
  const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
13
14
 
14
- // ANSI so the shield "references the previous color": the TEE tiers used to show a
15
- // green/yellow traffic-light dot now they show a shield tinted the same color
16
- // (green = verified, yellow = unconfirmed). The status bar renders these escapes.
17
- const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
18
-
19
- // The TEE tiers render as a colored shield + "Trusted Execution" (pi-privacy labels
20
- // these "Verified TEE" / "TEE (unconfirmed)"; we rename to Trusted Execution for the
21
- // privateer badge and swap the dot for a shield). Everything else keeps the dot.
22
- function badgeLabel(tier: PrivacyTier): string | null {
23
- if (tier === "tee-verified") return `${GREEN}⛉ Trusted Execution${RESET}`;
24
- if (tier === "tee-unverified") return `${YELLOW}⛉ Trusted Execution (unconfirmed)${RESET}`;
15
+ // The shield "references the previous color": the TEE tiers show a shield tinted like the
16
+ // old traffic-light dot (green = verified, yellow = unconfirmed). The colours come from
17
+ // the active theme (paletteFor) so the badge stays legible on a light terminal too — a
18
+ // bare "\x1b[33m" yellow washes out on white. The status bar renders these escapes.
19
+ function badgeLabel(tier: PrivacyTier, p: Palette): string | null {
20
+ if (tier === "tee-verified") return `${p.GREEN}⛉ Trusted Execution${p.RESET}`;
21
+ if (tier === "tee-unverified") return `${p.YELLOW}⛉ Trusted Execution (unconfirmed)${p.RESET}`;
25
22
  return null;
26
23
  }
27
24
 
28
- async function badgeFor(provider: string, modelId: string): Promise<string> {
25
+ async function badgeFor(provider: string, modelId: string, p: Palette): Promise<string> {
29
26
  const res =
30
27
  provider === "privateer"
31
28
  ? await accountPosture(modelId)
32
29
  : await verifyModelPosture(provider, modelId, {
33
30
  apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
34
31
  });
35
- const shield = badgeLabel(res.tier as PrivacyTier);
32
+ const shield = badgeLabel(res.tier as PrivacyTier, p);
36
33
  if (shield) return shield;
37
34
  const info = TIERS[res.tier as PrivacyTier];
38
35
  return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
@@ -46,7 +43,7 @@ export default function privateerPosture(pi: any): void {
46
43
  const mine = ++seq;
47
44
  try {
48
45
  ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
49
- const badge = await badgeFor(provider, modelId);
46
+ const badge = await badgeFor(provider, modelId, paletteFor(ctx?.ui?.theme));
50
47
  if (mine === seq) ctx.ui.setStatus("privacy", badge);
51
48
  } catch {
52
49
  if (mine === seq) ctx.ui.setStatus("privacy", undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
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",
@@ -48,14 +48,14 @@
48
48
  "dev": "tsx watch src/main.ts",
49
49
  "typecheck": "tsc --noEmit",
50
50
  "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done",
51
- "postinstall": "patch-package"
51
+ "postinstall": "patch-package --error-on-fail"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22.19.0"
55
55
  },
56
56
  "dependencies": {
57
57
  "@earendil-works/pi-ai": "^0.80.3",
58
- "@earendil-works/pi-coding-agent": "^0.80.3",
58
+ "@earendil-works/pi-coding-agent": "0.80.3",
59
59
  "@earendil-works/pi-tui": "^0.80.3",
60
60
  "@hypabolic/pi-hypa": "^0.1.6",
61
61
  "@juicesharp/rpiv-web-tools": "^1.20.0",
package/src/cli/chat.ts CHANGED
@@ -10,9 +10,14 @@
10
10
 
11
11
  import "../boot.ts"; // env + attestation dispatcher, before any Pi import
12
12
  import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
13
+ import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
13
14
  import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
14
15
 
15
- const RESET = "\x1b[0m", DIM = "\x1b[2m", CYAN = "\x1b[36m", YELLOW = "\x1b[33m", RED = "\x1b[31m", GREEN = "\x1b[32m";
16
+ // This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
17
+ // itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
18
+ // yellow / "\x1b[36m" cyan and faint "\x1b[2m" dim wash out, so cliPalette swaps in dark
19
+ // 256-colour indices there. On a dark terminal it's the same named colours as before.
20
+ const { RESET, DIM, CYAN, YELLOW, RED, GREEN } = cliPalette();
16
21
 
17
22
  async function main() {
18
23
  const readline = await import("node:readline");
@@ -34,7 +39,7 @@ async function main() {
34
39
  const priv = await import("../auth/privateer.ts");
35
40
  const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
36
41
  const { agentVersion } = await import("../config/version.ts");
37
- const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
42
+ const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
38
43
 
39
44
  // resolveDefaultModel() already honours PRIVATEER_MODEL first, then the account
40
45
  // default when signed in, then a BYO key — one source of truth (defaultModel.ts).
@@ -470,6 +475,14 @@ async function main() {
470
475
  },
471
476
  });
472
477
  console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
478
+ // Move the live session onto a confidential model right away, so the next prompt
479
+ // doesn't dead-end on the keyless launch model ("No API key found for openrouter").
480
+ // resolveSignedInModel prefers Tinfoil GLM 5.2, else the account's NEAR channel;
481
+ // PRIVATEER_MODEL (a deliberate override) is respected and left alone.
482
+ if (!process.env.PRIVATEER_MODEL?.trim()) {
483
+ const target = resolveSignedInModel();
484
+ if (target !== currentSpec) await switchModel(target, false);
485
+ }
473
486
  } catch (e) {
474
487
  console.log(`${RED}${(e as Error).message}${RESET}`);
475
488
  }
@@ -16,11 +16,19 @@ import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
17
17
 
18
18
  // The signed-in default: a NEAR confidential-compute (TEE, attestable) model — the
19
- // strongest privacy tier, and the same id the app shows first. Kept here as the one
20
- // definition; providers/account.ts imports it so its seed catalog can't drift.
19
+ // strongest privacy tier the account channel offers, and the same id the app shows
20
+ // first. Kept here as the one definition; providers/account.ts imports it so its seed
21
+ // catalog can't drift.
21
22
  export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
22
23
  export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
23
24
 
25
+ // Tinfoil's GLM 5.2 — CLIENT-side-attested TEE inference (the live TLS key is bound to
26
+ // the enclave's quote), the strongest privacy tier we offer, stronger than the account's
27
+ // server-proxied NEAR channel. Preferred whenever a Tinfoil key is present. Kept as the
28
+ // one definition so bin/privateer-tui and this resolver agree. See extensions/privateer-
29
+ // privacy.ts, which registers `tinfoil/glm-5-2` (and friends) on the tinfoil provider.
30
+ export const TINFOIL_DEFAULT_SPEC = "tinfoil/glm-5-2";
31
+
24
32
  // Last-resort BYO default, preserved from the pre-resolver code so a user who set an
25
33
  // OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
26
34
  // either, this still surfaces the familiar "No API key found for openrouter" — a clear
@@ -48,12 +56,14 @@ export interface ResolveDefaultModelOptions {
48
56
 
49
57
  // Resolve the model spec ("provider/id") to use when no model is named. Pure and
50
58
  // synchronous (only reads env + the credentials file), so it's safe to call from any
51
- // entry point at startup. Precedence:
59
+ // entry point at startup. Precedence (mirrors bin/privateer-tui's launch logic, so the
60
+ // launcher, the REPL, and the next-launch seed all agree):
52
61
  // 1. explicit user choice (config/channel) — deliberate, always wins
53
62
  // 2. PRIVATEER_MODEL env — dev/global override
54
- // 3. signed into Privateerthe account default the fix: subscription users
55
- // 4. a BYO provider whose key is present anthropic, openai, openrouter
56
- // 5. LEGACY_BYO_FALLBACK — familiar "add a key" signal
63
+ // 3. Tinfoil key presentTinfoil GLM 5.2 strongest (client-attested) privacy
64
+ // 4. signed into Privateer the account default subscription users, no BYO key
65
+ // 5. a BYO provider whose key is present — anthropic, openai, openrouter
66
+ // 6. LEGACY_BYO_FALLBACK — familiar "add a key" signal
57
67
  export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
58
68
  const env = opts.env ?? process.env;
59
69
 
@@ -63,6 +73,10 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
63
73
  const fromEnv = env.PRIVATEER_MODEL?.trim();
64
74
  if (fromEnv) return fromEnv;
65
75
 
76
+ // Privacy-first: a Tinfoil key means we can run verifiable TEE inference right now,
77
+ // which we prefer even over the account's NEAR channel — same order the launcher uses.
78
+ if (env.TINFOIL_API_KEY?.trim()) return TINFOIL_DEFAULT_SPEC;
79
+
66
80
  const signedIn = opts.signedIn ?? hasCredentials();
67
81
  if (signedIn) return ACCOUNT_DEFAULT_SPEC;
68
82
 
@@ -73,6 +87,17 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
73
87
  return LEGACY_BYO_FALLBACK;
74
88
  }
75
89
 
90
+ // The confidential model to switch the LIVE session onto the moment a user signs in.
91
+ // A terminal launched with no credentials is pinned by `--model` to the keyless
92
+ // OpenRouter fallback; without an in-session switch it stays there and the first prompt
93
+ // after /login dead-ends on "No API key found for openrouter". This resolves the model
94
+ // sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2 when a key is present, otherwise
95
+ // the account's NEAR confidential channel (billable to the subscription, no BYO key).
96
+ // PRIVATEER_MODEL still wins — a deliberate override is never stomped.
97
+ export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
98
+ return resolveDefaultModel({ env, signedIn: true });
99
+ }
100
+
76
101
  // Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
77
102
  // only the first delimiter separates provider from model). Returns null for a spec
78
103
  // with no provider prefix.
@@ -0,0 +1,135 @@
1
+ // Shared terminal-colour palettes — the one place that decides how Privateer's CLI
2
+ // surfaces paint on a light vs dark terminal. Two audiences:
3
+ //
4
+ // 1. Pi extensions (brand banner, posture badge, remote-access footer) run inside
5
+ // Pi's TUI, which ALREADY auto-detects the terminal background (OSC 11 / COLORFGBG)
6
+ // and picks a light or dark theme. They get a live `Theme` (via ctx.ui.setHeader's
7
+ // factory arg or ctx.ui.theme), so paletteFor(theme) reads the theme's semantic
8
+ // colours — dark ink on a light bg, light ink on a dark bg — instead of a fixed
9
+ // colour. That's the fix for the old "everything painted white → invisible on a
10
+ // white terminal" bug.
11
+ //
12
+ // 2. The lean standalone REPL (src/cli/chat.ts) runs its OWN readline loop with no Pi
13
+ // TUI and therefore no Theme. It detects the scheme itself from COLORFGBG and picks
14
+ // a matching CliPalette — on a light bg, explicit 256-colour indices that are
15
+ // guaranteed high-contrast (a pale terminal yellow on white is the classic
16
+ // unreadable case), on a dark bg the standard named colours it always used.
17
+ //
18
+ // IMPORT-SAFETY: this module imports NOTHING (no Pi, no node builtins beyond the ambient
19
+ // process global), so it's safe to load from boot-ordered entrypoints — see boot.ts's
20
+ // ORDERING CONTRACT. `theme` is typed `any` because the whole extension layer treats Pi
21
+ // objects loosely and we don't want a Pi type import here.
22
+
23
+ // ── SGR primitives ───────────────────────────────────────────────────────────
24
+ // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app mangles truecolor. These
25
+ // indices are universally supported.
26
+ const ESC = "\x1b[";
27
+ export const RESET = `${ESC}0m`;
28
+ export const BOLD = `${ESC}1m`;
29
+ const c = (n: number): string => `${ESC}38;5;${n}m`;
30
+
31
+ // ── theme-derived palette (Pi extensions) ────────────────────────────────────
32
+ export type Palette = {
33
+ RESET: string;
34
+ BOLD: string;
35
+ INK: string; // primary readable text (wordmark body, version, paths, labels)
36
+ ACCENT: string; // brand accent (marks, highlights, codes, command hints)
37
+ BORDER: string; // frames / box drawing
38
+ DIM: string; // secondary / muted prose
39
+ GREEN: string; // success (connected, verified, context loaded)
40
+ YELLOW: string; // warning (not-signed-in, unconfirmed, update available)
41
+ };
42
+
43
+ // Last-resort palette for when no theme is reachable (headless surfaces have no banner,
44
+ // and any Pi new enough to render UI exposes the theme — so this is belt-and-suspenders).
45
+ // Kept white so behaviour on a dark terminal is unchanged if the theme lookup ever fails.
46
+ export const FALLBACK: Palette = {
47
+ RESET,
48
+ BOLD,
49
+ INK: c(231),
50
+ ACCENT: c(231),
51
+ BORDER: c(231),
52
+ DIM: `${ESC}90m`,
53
+ GREEN: `${ESC}32m`,
54
+ YELLOW: `${ESC}33m`,
55
+ };
56
+
57
+ // Build a Palette from a Pi Theme. getFgAnsi(name) returns the raw SGR foreground escape
58
+ // for a theme colour; every lookup falls back to the white default so a theme missing a
59
+ // given colour name can never blank a surface.
60
+ export function paletteFor(theme: any): Palette {
61
+ if (!theme || typeof theme.getFgAnsi !== "function") return FALLBACK;
62
+ const g = (name: string, fallback: string): string => {
63
+ try {
64
+ const a = theme.getFgAnsi(name);
65
+ return typeof a === "string" && a.length > 0 ? a : fallback;
66
+ } catch {
67
+ return fallback;
68
+ }
69
+ };
70
+ return {
71
+ RESET,
72
+ BOLD,
73
+ INK: g("text", FALLBACK.INK),
74
+ ACCENT: g("accent", FALLBACK.ACCENT),
75
+ BORDER: g("border", FALLBACK.BORDER),
76
+ DIM: g("dim", FALLBACK.DIM),
77
+ GREEN: g("success", FALLBACK.GREEN),
78
+ YELLOW: g("warning", FALLBACK.YELLOW),
79
+ };
80
+ }
81
+
82
+ // ── standalone REPL palette (no Pi Theme) ────────────────────────────────────
83
+ export type TerminalScheme = "light" | "dark";
84
+
85
+ export type CliPalette = {
86
+ RESET: string;
87
+ BOLD: string;
88
+ DIM: string;
89
+ GREEN: string;
90
+ YELLOW: string;
91
+ RED: string;
92
+ CYAN: string; // used as the REPL's accent (prompts, app-relay echoes)
93
+ };
94
+
95
+ // Dark bg: the standard named SGR colours the REPL always used — the terminal maps them
96
+ // to its own readable palette.
97
+ const CLI_DARK: CliPalette = {
98
+ RESET,
99
+ BOLD,
100
+ DIM: `${ESC}2m`,
101
+ GREEN: `${ESC}32m`,
102
+ YELLOW: `${ESC}33m`,
103
+ RED: `${ESC}31m`,
104
+ CYAN: `${ESC}36m`,
105
+ };
106
+
107
+ // Light bg: explicit dark 256-colour indices so contrast doesn't depend on the terminal's
108
+ // ANSI palette (a pale terminal yellow/cyan on white is unreadable; faint `2m` washes
109
+ // out). These are all mid-to-dark tones that read cleanly on white.
110
+ const CLI_LIGHT: CliPalette = {
111
+ RESET,
112
+ BOLD,
113
+ DIM: c(243), // solid medium gray instead of faint
114
+ GREEN: c(28), // dark green
115
+ YELLOW: c(130), // dark amber (plain 33m is invisible-pale on white)
116
+ RED: c(124), // dark red
117
+ CYAN: c(24), // dark teal-blue accent
118
+ };
119
+
120
+ // Detect the terminal background from COLORFGBG (set by many terminals as "fg;bg", the
121
+ // last field being the background ANSI index). 7/15 = light; everything else — or no
122
+ // COLORFGBG at all — defaults to dark, matching the REPL's historical assumption.
123
+ export function detectScheme(env: Record<string, string | undefined> = process.env): TerminalScheme {
124
+ const fgbg = env.COLORFGBG;
125
+ if (fgbg) {
126
+ const parts = fgbg.split(";");
127
+ const bg = parseInt(parts[parts.length - 1], 10);
128
+ if (!Number.isNaN(bg)) return bg === 7 || bg === 15 ? "light" : "dark";
129
+ }
130
+ return "dark";
131
+ }
132
+
133
+ export function cliPalette(scheme: TerminalScheme = detectScheme()): CliPalette {
134
+ return scheme === "light" ? CLI_LIGHT : CLI_DARK;
135
+ }