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.
- package/bin/privateer-launch.mjs +218 -0
- package/bin/privateer-tui +8 -143
- package/bin/privateer.cmd +6 -0
- package/extensions/privateer-brand.ts +117 -72
- package/extensions/privateer-gate.ts +6 -3
- package/extensions/privateer-posture.ts +11 -14
- package/package.json +3 -3
- package/src/cli/chat.ts +15 -2
- package/src/providers/defaultModel.ts +31 -6
- package/src/ui/palette.ts +135 -0
|
@@ -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
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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
|
|
87
|
-
//
|
|
88
|
-
|
|
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 =
|
|
96
|
-
const
|
|
97
|
-
if (t
|
|
98
|
-
else if (t
|
|
99
|
-
else if (
|
|
100
|
-
else line +=
|
|
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}${
|
|
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 · ${
|
|
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}${
|
|
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 · ${
|
|
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}${
|
|
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}${
|
|
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
|
-
`${
|
|
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}${
|
|
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
|
-
`${
|
|
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(
|
|
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 <
|
|
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
|
-
|
|
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?.(() =>
|
|
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
|
-
`${
|
|
366
|
-
`${DIM}Approve this terminal in the Privateer app:${RESET}`,
|
|
367
|
-
` code ${BOLD}${
|
|
368
|
-
uri ? `${DIM} or open ${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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
|
20
|
-
// definition; providers/account.ts imports it so its seed
|
|
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.
|
|
55
|
-
// 4.
|
|
56
|
-
// 5.
|
|
63
|
+
// 3. Tinfoil key present → Tinfoil 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
|
+
}
|