privateer-agent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <h1 align="center">⚓ Privateer</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>A provider-agnostic terminal coding agent — bring your own model, keep your own privacy.</strong>
8
+ <strong>A privacy-first, safe-by-default distribution of the <a href="https://pi.dev">Pi</a> coding agent — bring your own model, keep your own privacy.</strong>
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -46,8 +46,37 @@ permission gate**.
46
46
  - **Privacy you can verify, not just trust.** Confidential-enclave (TEE) inference is
47
47
  cryptographically **attested** — not a policy promise — and an **on-device PII gate**
48
48
  warns before structured personal data ever leaves your machine for an unverified model.
49
- - **Genuinely extensible.** MCP servers, sub-agents, custom commands, and skills all just
50
- files. Nothing to compile.
49
+ - **It's Pi underneath.** Privateer is a distribution of the [Pi](https://pi.dev) coding agent
50
+ every Pi extension, skill, and command works, and Privateer's own features are just extensions
51
+ you can read, swap, or build on. Nothing to compile. See [Built on Pi](#built-on-pi).
52
+
53
+ ## Built on Pi
54
+
55
+ Privateer is a **distribution of the [Pi](https://pi.dev) coding agent**
56
+ (`@earendil-works/pi-coding-agent`): Pi is the runtime, the model routing, the interactive TUI,
57
+ and the extension / skill / prompt discovery system — **everything that works in Pi works here.**
58
+ What Privateer adds is a *moat* of Pi extensions layered on top:
59
+
60
+ | Extension | What it adds |
61
+ |---|---|
62
+ | `privateer-gate` | safe-by-default permission gate + destructive-command danger filter |
63
+ | `privateer-privacy` | `pi-privacy` — TEE attestation, ZDR routing, on-device PII gate — bound to the account tier resolver |
64
+ | `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
65
+ | `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
66
+ | `rpiv-web-tools` | private-by-default web search (self-hosted SearXNG, no WebView) |
67
+ | `pi-mcp-adapter`, `pi-subagents` | MCP servers · bounded parallel sub-agents |
68
+
69
+ They're ordinary Pi extensions — inspect them, replace them, or build your own alongside.
70
+ **Extend by discovery:** drop an extension into `~/.privateer/agent/extensions/` (move the home
71
+ with `PRIVATEER_HOME`), add a skill or prompt beside it, or list an npm/git package under
72
+ `packages` in `~/.privateer/agent/settings.json` — Pi auto-loads them on next launch, right next
73
+ to Privateer's own. Any extension from the Pi ecosystem loads the same way. (There's no CLI flag
74
+ for this — discovery is the entry point.)
75
+
76
+ **The floor you can't lower is the safety gate.** While it's loaded, its block on destructive
77
+ shell commands, secret exfiltration, and plan-mode escapes sits *above* every relaxation —
78
+ `bypass` mode, the approval allowlist, even a phone-approved remote turn can't fire them
79
+ silently. The moat is swappable; the floor under it holds.
51
80
 
52
81
  ## Highlights
53
82
 
@@ -126,6 +155,7 @@ export OLLAMA_BASE_URL=http://localhost:11434/v1 # local, keyless
126
155
  export NEAR_AI_API_KEY=... # verifiable TEE inference (cloud.near.ai)
127
156
  export TINFOIL_API_KEY=... # verifiable TEE inference (tinfoil.sh)
128
157
  export VENICE_API_KEY=vapi_... # no-retention inference
158
+ export PRIVATEER_API_KEY=sk-priv-... # Privateer developer API (privateer.pro); or /signin instead
129
159
  ```
130
160
 
131
161
  Pick a model with **`/model`** (browse each configured provider's live catalog) or pass one
@@ -198,14 +228,17 @@ and protected files (`.env`, shell rc files…) are guarded — the gate is neve
198
228
 
199
229
  ## Extend it
200
230
 
201
- - **MCP servers** declare them and their tools become first-class, gated like the rest
202
- (local stdio, or remote HTTP with interactive OAuth).
203
- - **Sub-agents** — delegate investigations to bounded parallel agents that run under the same
204
- permission gate.
231
+ Everything below is a **Pi extension** loaded by discovery (see [Built on Pi](#built-on-pi))
232
+ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, gated like the rest.
233
+
234
+ - **MCP servers** (`pi-mcp-adapter`) — declare them and their tools become first-class, gated
235
+ like the rest (local stdio, or remote HTTP with interactive OAuth).
236
+ - **Sub-agents** (`pi-subagents`) — delegate investigations to bounded parallel agents that run
237
+ under the same permission gate.
205
238
  - **Routines** — saved tasks the daemon runs unattended; ask the agent to schedule work and
206
239
  approve it once.
207
- - **Web tools** — private-by-default web search/fetch with pluggable backends (self-hosted
208
- SearXNG for fully private search).
240
+ - **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
241
+ (self-hosted SearXNG for fully private search).
209
242
 
210
243
  ## Develop
211
244
 
package/bin/privateer-tui CHANGED
@@ -83,4 +83,10 @@ else
83
83
  MODEL="openrouter/openai/gpt-4o-mini"
84
84
  fi
85
85
 
86
- exec "$NODE_BIN" --env-file="$REPO/.env" "$CLI" --model "$MODEL" "$@"
86
+ # Dev convenience: load provider keys from the repo's .env if present. A real install
87
+ # has none — so only pass the flag when the file exists (using --env-file-if-exists
88
+ # unconditionally would print "…/.env not found. Continuing without it." on every
89
+ # launch, muddying the startup banner). No .env → rely on the ambient environment.
90
+ ENV_ARGS=""
91
+ [ -f "$REPO/.env" ] && ENV_ARGS="--env-file=$REPO/.env"
92
+ exec "$NODE_BIN" ${ENV_ARGS:+"$ENV_ARGS"} "$CLI" --model "$MODEL" "$@"
package/bin/privateer.mjs CHANGED
File without changes
@@ -0,0 +1,6 @@
1
+ // The Privateer account provider as a Pi extension (for `-e` into Pi's TUI).
2
+ // Registers the `privateer` subscription channel when a machine login exists; Pi's
3
+ // own OAuth /login flow drives the child-session credential.
4
+ import { makeAccountProvider } from "../src/providers/account.ts";
5
+
6
+ export default makeAccountProvider();
@@ -0,0 +1,274 @@
1
+ // Privateer branding for Pi's TUI + the account (sign-in) surface. Three concerns
2
+ // live together here because they share one piece of state — whether this terminal
3
+ // is signed in to a Privateer account:
4
+ //
5
+ // 1. A branded startup header — the anchor mark + "✻ PRIVATEER" wordmark + the
6
+ // "Chart your own course privately." tagline (ported from tree-cli's Banner).
7
+ // 2. A live status-bar badge (⚓ account) showing the sign-in state at a glance.
8
+ // 3. Sign-in UX — /signin, /signout, and a /privateer hub — which drive the
9
+ // device-code flow the account channel needs.
10
+ //
11
+ // Pi already owns /login and /logout for PROVIDER auth (and /whoami), so we do NOT
12
+ // shadow them. /signin drives the Privateer *account* device flow directly — which
13
+ // has to exist separately, because the `privateer` inference provider only registers
14
+ // once credentials exist, so a first-time user can't reach it through provider auth.
15
+ //
16
+ // On a successful /signin we hot-register the account provider so privateer/* models
17
+ // appear immediately, without a restart.
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import * as priv from "../src/auth/privateer.ts";
22
+ import { makeAccountProvider } from "../src/providers/account.ts";
23
+
24
+ const VERSION: string = (() => {
25
+ try {
26
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
27
+ } catch {
28
+ return "0.3.0";
29
+ }
30
+ })();
31
+
32
+ // ── palette (Privateer "Open Water" ocean-blue brand) ────────────────────────
33
+ // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
34
+ // truecolor and mangles it (the old indigo/cyan came out green). These indices are
35
+ // universally supported. Navy (the logo mark) is too dark to read on a dark terminal,
36
+ // so we use the app's ocean blues — the same "Open Water" gradient as the brand.
37
+ const ESC = "\x1b[";
38
+ const RESET = `${ESC}0m`;
39
+ const BOLD = `${ESC}1m`;
40
+ const c = (n: number): string => `${ESC}38;5;${n}m`;
41
+ const OCEAN = c(39); // ≈ #00afff — primary ocean blue (anchor, wordmark "P")
42
+ const OCEAN_LIGHT = c(81); // ≈ #5fd7ff — light sky accent (wordmark, version, path)
43
+ const BORDER = c(32); // ≈ #0087d7 — deeper ocean, the frame
44
+ const DIM = `${ESC}90m`;
45
+ const GREEN = `${ESC}32m`;
46
+ const YELLOW = `${ESC}33m`;
47
+
48
+ // The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
49
+ // own model" meets lock-and-key privacy, echoing the app's anchor+padlock logo. Every
50
+ // line is the same visible width (11) so the text column beside it stays aligned.
51
+ const ANCHOR = [
52
+ " .-. ", // shackle arch
53
+ " | | ", // shackle legs
54
+ " .-----. ", // lock body top (the shackle's base)
55
+ " | o | ", // lock body + keyhole
56
+ " '--+--' ", // lock body base, shank exits
57
+ " ---+--- ", // stock (crossbar)
58
+ " \\ | / ", // arms
59
+ " \\_|_/ ", // flukes
60
+ ];
61
+
62
+ // Visible width = characters after stripping SGR escapes. Everything we render inside
63
+ // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
64
+ function vlen(s: string): number {
65
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
66
+ }
67
+
68
+ // Strip ESC/C0/C1 control bytes from any value we did NOT author before it lands in a
69
+ // render line. Account email/id/pubkey come from the server (via credentials.json),
70
+ // the device user_code + verification_uri come off the device-code response, and cwd
71
+ // comes from the filesystem — none is guaranteed free of raw escape sequences, which
72
+ // would otherwise reach the terminal unsanitized. Our own SGR color codes are added
73
+ // AFTER cleaning the untrusted substring, so they're preserved; normal printable text
74
+ // (including Unicode) is untouched.
75
+ // eslint-disable-next-line no-control-regex
76
+ const CONTROL_RE = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f]", "g");
77
+ function clean(s: unknown): string {
78
+ return String(s ?? "").replace(CONTROL_RE, "");
79
+ }
80
+
81
+ function shortCwd(): string {
82
+ const cwd = process.cwd();
83
+ const home = homedir();
84
+ const path = cwd === home || cwd.startsWith(home + "/") ? "~" + cwd.slice(home.length) : cwd;
85
+ return clean(path);
86
+ }
87
+
88
+ // The account line under the tagline — three states, ported from tree-cli's Banner:
89
+ // - signed in → "connected as <account>"
90
+ // - signed out AND the current model bills to a Privateer account → it can't run
91
+ // until they sign in, so say so plainly (warning)
92
+ // - signed out on their own key → a quiet tease that /signin adds more
93
+ function accountLine(modelProvider?: string): string {
94
+ const u = priv.currentUser();
95
+ if (u) {
96
+ const label = clean(u.email ?? (u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id));
97
+ return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
98
+ }
99
+ if (modelProvider === "privateer") {
100
+ return `${YELLOW}not signed in · /signin to use this model${RESET}`;
101
+ }
102
+ return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
103
+ }
104
+
105
+ // Compose the framed banner: anchor column + text column, inside a rounded accent box.
106
+ function renderBanner(width: number, modelProvider?: string): string[] {
107
+ // Leading blanks drop the text block so the wordmark sits beside the lock body and
108
+ // the shackle rises above it (one entry per anchor line — 8 total).
109
+ const right = [
110
+ "",
111
+ "",
112
+ `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}`,
113
+ `${DIM}Chart your own course privately.${RESET}`,
114
+ "",
115
+ accountLine(modelProvider),
116
+ `${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
117
+ `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
118
+ ];
119
+ // Build the body rows (anchor + gutter + text).
120
+ const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
121
+ const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
122
+ const inner = Math.min(cap, Math.max(...rows.map(vlen)));
123
+ const bar = "─".repeat(inner + 2);
124
+ const out = [`${BORDER}╭${bar}╮${RESET}`];
125
+ for (const row of rows) {
126
+ const pad = Math.max(0, inner - vlen(row));
127
+ out.push(`${BORDER}│${RESET} ${row}${" ".repeat(pad)} ${BORDER}│${RESET}`);
128
+ }
129
+ out.push(`${BORDER}╰${bar}╯${RESET}`);
130
+ return out;
131
+ }
132
+
133
+ // A Pi header Component (setHeader factory return). Static banner; captures the model
134
+ // provider so the account line reflects the picked model.
135
+ function headerComponent(modelProvider?: string) {
136
+ return {
137
+ render: (width: number): string[] => renderBanner(width, modelProvider),
138
+ invalidate() {},
139
+ };
140
+ }
141
+
142
+ // Short account label for the footer badge.
143
+ function accountBadge(): string {
144
+ const u = priv.currentUser();
145
+ if (!u) return "⚓ guest";
146
+ const label = clean(u.email ? u.email.split("@")[0] : u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id);
147
+ return `⚓ ${label}`;
148
+ }
149
+
150
+ export default function privateerBrand(pi: any): void {
151
+ let currentModelProvider: string | undefined;
152
+ let ctxRef: any = null;
153
+ let greeted = false;
154
+
155
+ const setHeader = (ctx: any) =>
156
+ ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
157
+
158
+ const refresh = (ctx: any) => {
159
+ if (!ctx?.hasUI) return;
160
+ setHeader(ctx);
161
+ ctx?.ui?.setStatus?.("account", accountBadge());
162
+ };
163
+
164
+ async function doSignIn(ctx: any): Promise<void> {
165
+ if (priv.hasCredentials()) {
166
+ const u = priv.currentUser();
167
+ return ctx?.ui?.notify?.(
168
+ `Already signed in as ${u?.email ?? u?.id}. Run /signout to switch accounts.`,
169
+ "info",
170
+ );
171
+ }
172
+ ctx?.ui?.notify?.("Connecting to Privateer — requesting a device code…", "info");
173
+ try {
174
+ const user = await priv.runDeviceLogin({
175
+ onCode: (code: any) => {
176
+ const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
177
+ const userCode = clean(code.user_code);
178
+ ctx?.ui?.setWidget?.(
179
+ "privateer-signin",
180
+ [
181
+ `${OCEAN_LIGHT}⚓ Sign in to Privateer${RESET}`,
182
+ `${DIM}Approve this terminal in the Privateer app:${RESET}`,
183
+ ` code ${BOLD}${OCEAN}${userCode}${RESET}`,
184
+ uri ? `${DIM} or open ${RESET}${OCEAN_LIGHT}${uri}${RESET}` : "",
185
+ `${DIM} waiting for approval…${RESET}`,
186
+ ].filter(Boolean),
187
+ { placement: "aboveEditor" },
188
+ );
189
+ },
190
+ });
191
+ ctx?.ui?.setWidget?.("privateer-signin", undefined);
192
+ // Hot-register the account provider so privateer/* models appear without a restart.
193
+ try {
194
+ await makeAccountProvider()(pi);
195
+ } catch {
196
+ /* provider list fetch failed — models appear on next launch */
197
+ }
198
+ refresh(ctx);
199
+ ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}. Your Privateer models are ready.`, "info");
200
+ } catch (e) {
201
+ ctx?.ui?.setWidget?.("privateer-signin", undefined);
202
+ ctx?.ui?.notify?.((e as Error).message || "Sign-in failed.", "error");
203
+ }
204
+ }
205
+
206
+ async function doSignOut(ctx: any): Promise<void> {
207
+ if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
208
+ const u = priv.currentUser();
209
+ await priv.logout();
210
+ refresh(ctx);
211
+ ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
212
+ }
213
+
214
+ function showStatus(ctx: any): void {
215
+ const u = priv.currentUser();
216
+ ctx?.ui?.notify?.(
217
+ u
218
+ ? `Signed in to Privateer as ${u.email ?? u.id}.`
219
+ : "Not signed in. Run /signin to connect your Privateer account.",
220
+ "info",
221
+ );
222
+ }
223
+
224
+ pi.on("session_start", (_e: any, ctx: any) => {
225
+ ctxRef = ctx;
226
+ currentModelProvider = ctx?.model?.provider ?? currentModelProvider;
227
+ if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
228
+ ctx?.ui?.setTitle?.("Privateer");
229
+ refresh(ctx);
230
+ if (!priv.hasCredentials() && !greeted) {
231
+ greeted = true;
232
+ ctx?.ui?.notify?.(
233
+ "Not signed in — run /signin to connect your Privateer account (adds hosted models + remote access).",
234
+ "info",
235
+ );
236
+ }
237
+ });
238
+
239
+ // Keep the header's account line in sync with the picked model (the "this model
240
+ // needs sign-in" state depends on the current provider).
241
+ pi.on("model_select", (e: any, ctx: any) => {
242
+ ctxRef = ctx;
243
+ const next = e?.model?.provider;
244
+ if (next && next !== currentModelProvider) {
245
+ currentModelProvider = next;
246
+ setHeader(ctx);
247
+ }
248
+ });
249
+
250
+ // The machine login was invalidated server-side (TTL lapsed or revoked in the app):
251
+ // announce it and reflect it in the badge/header immediately.
252
+ priv.onSessionExpired(() => {
253
+ refresh(ctxRef);
254
+ ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
255
+ });
256
+
257
+ pi.registerCommand?.("signin", {
258
+ description: "Sign in to your Privateer account (device-code flow)",
259
+ handler: (_args: string, ctx: any) => doSignIn(ctx),
260
+ });
261
+ pi.registerCommand?.("signout", {
262
+ description: "Sign out of your Privateer account on this terminal",
263
+ handler: (_args: string, ctx: any) => doSignOut(ctx),
264
+ });
265
+ pi.registerCommand?.("privateer", {
266
+ description: "Privateer account: /privateer [status | signin | signout]",
267
+ handler: (args: string, ctx: any) => {
268
+ const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
269
+ if (sub === "signin" || sub === "login") return doSignIn(ctx);
270
+ if (sub === "signout" || sub === "logout") return doSignOut(ctx);
271
+ return showStatus(ctx);
272
+ },
273
+ });
274
+ }
@@ -0,0 +1,140 @@
1
+ // The Privateer control extension for Pi's TUI (Phase 6): the permission gate PLUS
2
+ // remote access, so the app can drive the real TUI (not just the lean REPL).
3
+ //
4
+ // - gate: tool_call → block/allow via Pi's native approval UI (ctx.ui); /mode command.
5
+ // - remote access: /remote-access on connects the relay; the bridge routes the
6
+ // gate's approvals to the phone (getRemote/remoteAsk) and drives turns from app
7
+ // prompts (pi.sendUserMessage). Turn events forward to the app via the adapter —
8
+ // the same pi.on(...) stream the adapter already speaks.
9
+ //
10
+ // The gate + bridge share one extension so the gate's remote branch and the relay
11
+ // are wired to the same state.
12
+
13
+ import { makePermissionGate, defaultLocalAsk } from "../src/ext/permissionGate.ts";
14
+ import { createEngineEventAdapter } from "../src/bridge/engineAdapter.ts";
15
+ import { RemoteBridge } from "../src/remote/remoteBridge.ts";
16
+ import { RelayClient } from "../src/remote/relayClient.ts";
17
+ import { makeSendFileTool } from "../src/tools/sendFile.ts";
18
+ import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
19
+ import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
20
+ import * as priv from "../src/auth/privateer.ts";
21
+ import type { PermissionMode } from "../src/config/permissionMode.ts";
22
+
23
+ const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
24
+ let mode: PermissionMode = MODES.includes(process.env.PRIVATEER_MODE as PermissionMode)
25
+ ? (process.env.PRIVATEER_MODE as PermissionMode)
26
+ : "default";
27
+ const allowlist: string[] = [];
28
+ const allowedOutsideRoots: string[] = [];
29
+
30
+ let piRef: any = null;
31
+ let relay: any = null;
32
+
33
+ // Inbound app→CLI files land here (keyed by "#n"); save_attachment persists them.
34
+ const attachments = new AttachmentStore();
35
+ let sinceLastPrompt: StoredAttachment[] = [];
36
+
37
+ const bridge = new RemoteBridge({
38
+ onPrompt: (text) => {
39
+ // Fold any files the app sent since the last prompt into a reference note so the
40
+ // model knows they exist and can save_attachment them.
41
+ const atts = sinceLastPrompt;
42
+ sinceLastPrompt = [];
43
+ const note = atts.length
44
+ ? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
45
+ `Use the save_attachment tool with the ref number to write one to disk.]`
46
+ : "";
47
+ piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
48
+ },
49
+ onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
50
+ onControllerAttached: () => relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]),
51
+ onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
52
+ onStatus: () => {},
53
+ });
54
+
55
+ const gate = makePermissionGate({
56
+ getMode: () => mode,
57
+ setMode: (m) => (mode = m),
58
+ allowlist,
59
+ allowedOutsideRoots,
60
+ cwd: process.cwd(),
61
+ localAsk: defaultLocalAsk,
62
+ getRemote: bridge.getRemote,
63
+ getNoQuarter: bridge.getNoQuarter,
64
+ remoteAsk: bridge.remoteAsk,
65
+ });
66
+
67
+ export default function privateerControl(pi: any): void {
68
+ piRef = pi;
69
+ gate(pi); // tool_call (block/allow) + tool_result (redact)
70
+
71
+ // File transfer both ways: send_file_to_client (CLI→app, via the bridge's relay) and
72
+ // save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
73
+ // live here because they share the RemoteBridge / its attachment stream.
74
+ pi.registerTool?.(makeSendFileTool(bridge));
75
+ pi.registerTool?.(makeSaveAttachmentTool(attachments));
76
+
77
+ // Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
78
+ // no one can approve, so a "default" gate would fail-closed on every tool and the
79
+ // subagent couldn't work. Instead switch to bypass: auto-approve within the agent's
80
+ // OWN restricted tool set (pi-subagents' per-role `tools:` allowlist), while
81
+ // decideAuto STILL forces dangerous shell / secret-exfil / destructive actions to
82
+ // "ask" → headless → denied. So delegation is gated in the parent (the spawn tool
83
+ // call), and the subagent is bounded by its role + danger detection. The TUI
84
+ // (mode "tui") keeps the interactive default gate.
85
+ // Explicit ALLOWLIST of headless run modes — never an "anything but tui" denylist,
86
+ // so an unexpected/undefined mode in the interactive TUI can't silently drop us into
87
+ // bypass (which would run tools ungated). Only switch when we're *sure* it's headless
88
+ // and the user hasn't pinned a mode via PRIVATEER_MODE.
89
+ const HEADLESS = new Set(["json", "print", "rpc"]);
90
+ pi.on("session_start", (_e: any, ctx: any) => {
91
+ if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
92
+ mode = "bypass";
93
+ }
94
+ });
95
+
96
+ // Forward turn events to the app. The relay only sends when a controller is
97
+ // attached, so this is safe on every turn (local or remote).
98
+ const adapter = createEngineEventAdapter();
99
+ const fwd = (ev: any) => {
100
+ for (const ee of adapter.toEngineEvents(ev)) bridge.forwardEvent(ee);
101
+ };
102
+ pi.on("message_update", (ev: any) => fwd(ev));
103
+ pi.on("tool_execution_start", (ev: any) => fwd(ev));
104
+ pi.on("tool_execution_end", (ev: any) => fwd(ev));
105
+ pi.on("turn_end", (ev: any) => fwd(ev));
106
+ // A remote-initiated agent run ends here → clear the remote flag so a later
107
+ // locally-typed turn isn't treated as remote.
108
+ pi.on("agent_end", (ev: any) => {
109
+ fwd(ev);
110
+ bridge.settleTurn();
111
+ });
112
+
113
+ pi.registerCommand?.("mode", {
114
+ description: "Show or set the permission mode: default | acceptEdits | bypass | plan",
115
+ handler: (args: string, ctx: any) => {
116
+ const m = String(args ?? "").trim() as PermissionMode;
117
+ if (m && MODES.includes(m)) mode = m;
118
+ else if (m) return ctx.ui?.notify?.(`unknown mode "${m}" — use ${MODES.join(" | ")}`, "warning");
119
+ ctx.ui?.notify?.(`permission mode: ${mode}`, "info");
120
+ },
121
+ });
122
+
123
+ pi.registerCommand?.("remote-access", {
124
+ description: "Drive this terminal from the Privateer app: /remote-access on | off",
125
+ handler: async (args: string, ctx: any) => {
126
+ const off = String(args ?? "").trim().toLowerCase() === "off";
127
+ if (off) {
128
+ relay?.stop();
129
+ relay = null;
130
+ return ctx.ui?.notify?.("remote access off", "info");
131
+ }
132
+ if (relay) return ctx.ui?.notify?.("remote access already on", "info");
133
+ if (!priv.hasCredentials()) return ctx.ui?.notify?.("Not signed in to Privateer.", "warning");
134
+ relay = new RelayClient(bridge.callbacks, { label: "privateer-cli" });
135
+ bridge.attachRelay(relay);
136
+ await relay.start();
137
+ ctx.ui?.notify?.("Remote access on — approve this terminal in the Privateer app, then drive it from there.", "info");
138
+ },
139
+ });
140
+ }
@@ -0,0 +1,42 @@
1
+ // The privacy-posture badge in Pi's status bar (Phase 6 polish). On model select
2
+ // (and at session start) it computes the current model's posture and pins it to the
3
+ // footer via ctx.ui.setStatus — so the moat is *visible*: a green "Verified TEE"
4
+ // for an attested enclave, a distinct label for a mere ZDR claim.
5
+ //
6
+ // Handles both surfaces: the account channel (privateer/*, via server-proxy
7
+ // attestation) which pi-privacy doesn't know, and everything else via pi-privacy.
8
+
9
+ import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
+ import { accountPosture } from "../src/providers/account.ts";
11
+
12
+ const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
13
+
14
+ async function badgeFor(provider: string, modelId: string): Promise<string> {
15
+ const res =
16
+ provider === "privateer"
17
+ ? await accountPosture(modelId)
18
+ : await verifyModelPosture(provider, modelId, {
19
+ apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
20
+ });
21
+ const info = TIERS[res.tier as PrivacyTier];
22
+ return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
23
+ }
24
+
25
+ export default function privateerPosture(pi: any): void {
26
+ // "latest wins" so rapid model cycling (Ctrl+P) doesn't leave a stale badge.
27
+ let seq = 0;
28
+ const update = async (provider?: string, modelId?: string, ctx?: any) => {
29
+ if (!provider || !modelId || !ctx?.ui?.setStatus) return;
30
+ const mine = ++seq;
31
+ try {
32
+ ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
33
+ const badge = await badgeFor(provider, modelId);
34
+ if (mine === seq) ctx.ui.setStatus("privacy", badge);
35
+ } catch {
36
+ if (mine === seq) ctx.ui.setStatus("privacy", undefined);
37
+ }
38
+ };
39
+
40
+ pi.on("model_select", (event: any, ctx: any) => update(event?.model?.provider, event?.model?.id, ctx));
41
+ pi.on("session_start", (_event: any, ctx: any) => update(ctx?.model?.provider, ctx?.model?.id, ctx));
42
+ }
@@ -0,0 +1,15 @@
1
+ // pi-privacy for privateer-agent: the standard pi-privacy extension (providers +
2
+ // attestation + posture badge feed + PII gate) PLUS a tier resolver that teaches it
3
+ // about the private ACCOUNT channel it doesn't ship — so a privateer/near… model
4
+ // (actually confidential-compute TEE) is treated as verified-private (no PII
5
+ // over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
6
+ // default entry directly.
7
+ import { makePiPrivacyExtension } from "pi-privacy";
8
+ import { accountPosture } from "../src/providers/account.ts";
9
+
10
+ export default makePiPrivacyExtension({
11
+ resolveTier: async (provider, modelId) => {
12
+ if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
13
+ return (await accountPosture(modelId)).tier;
14
+ },
15
+ });
@@ -0,0 +1,9 @@
1
+ // Privateer-specific custom tools for Pi's TUI (Phase 5). Today: create_routine
2
+ // (schedule unattended tasks → the daemon runs them). The generic tools (read/edit/
3
+ // bash/grep, web, subagents, todo) come from Pi builtins + adopted packages, so only
4
+ // the privateer-only tools live here. Gated by our permission-gate extension.
5
+ import { routineToolDefinition } from "../src/tools/routine.ts";
6
+
7
+ export default function privateerTools(pi: any): void {
8
+ pi.registerTool?.(routineToolDefinition);
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",
@@ -28,7 +28,7 @@
28
28
  "venice"
29
29
  ],
30
30
  "bin": {
31
- "privateer": "./bin/privateer.mjs"
31
+ "privateer": "./bin/privateer-tui"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
@@ -36,6 +36,7 @@
36
36
  "files": [
37
37
  "bin",
38
38
  "src",
39
+ "extensions",
39
40
  "README.md",
40
41
  "LICENSE"
41
42
  ],
@@ -59,7 +60,7 @@
59
60
  "@noble/curves": "^1.9.7",
60
61
  "@noble/hashes": "^1.7.1",
61
62
  "pi-mcp-adapter": "^2.11.0",
62
- "pi-privacy": "^0.2.0",
63
+ "pi-privacy": "^0.3.0",
63
64
  "pi-subagents": "^0.34.0",
64
65
  "picomatch": "^4.0.4",
65
66
  "tsx": "^4.16.0",
@@ -1,9 +1,10 @@
1
- // The provider landscape — the single map of how each of privateer's 21 providers
1
+ // The provider landscape — the single map of how each of privateer's 22 providers
2
2
  // becomes reachable under Pi. Written after verifying (2026-07-07) that:
3
3
  // - pi-ai ships STATIC model catalogs for its 14 built-in providers, so they're
4
4
  // selectable once a key is present — privateer emits NO models.json entry for them;
5
- // - the `pi-privacy` extension registers the 5 privacy providers (tinfoil, nearai,
6
- // venice, ollama, custom) at load — privateer emits NO entry for them either;
5
+ // - the `pi-privacy` extension registers the 6 privacy providers (tinfoil, nearai,
6
+ // venice, ollama, custom, privateer-api) at load — privateer emits NO entry for
7
+ // them either;
7
8
  // - so the ONLY provider this generator must emit is `qwen` (config-only,
8
9
  // non-privacy, no built-in catalog), and `privateer` (the account OAuth channel)
9
10
  // is handled in code, not config (Phase 4).
@@ -65,6 +66,9 @@ export const PROVIDERS: ProviderEntry[] = [
65
66
  { id: "tinfoil", source: "pi-privacy" },
66
67
  { id: "venice", source: "pi-privacy" },
67
68
  { id: "custom", source: "pi-privacy" },
69
+ // Public developer-API surface (sk-priv-… key). Registered by the pi-privacy
70
+ // extension like the others; distinct from the in-app `privateer` account channel.
71
+ { id: "privateer-api", source: "pi-privacy" },
68
72
  { id: "privateer", source: "account" },
69
73
  ];
70
74