privateer-agent 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,9 @@
12
12
  <a href="https://www.npmjs.com/package/privateer-agent">
13
13
  <img src="https://img.shields.io/npm/v/privateer-agent" alt="npm" />
14
14
  </a>
15
+ <a href="https://github.com/privateer-agent/privateer-agent/releases">
16
+ <img src="https://img.shields.io/badge/changelog-what's%20new-5b8def" alt="Changelog" />
17
+ </a>
15
18
  <img src="https://img.shields.io/badge/node-%E2%89%A522.19-brightgreen" alt="Node >= 22.19" />
16
19
  <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" />
17
20
  <img src="https://img.shields.io/badge/permissions-safe%20by%20default-2ea44f" alt="Safe by default" />
@@ -247,6 +250,12 @@ npm run typecheck
247
250
  npm test
248
251
  ```
249
252
 
253
+ ## Changelog
254
+
255
+ Release notes and what's new in each version live on the
256
+ [**GitHub releases page**](https://github.com/privateer-agent/privateer-agent/releases).
257
+ Privateer keeps its startup clean — the app won't dump a changelog into your terminal.
258
+
250
259
  ## License
251
260
 
252
261
  [MIT](LICENSE) © Patrick
package/bin/privateer-tui CHANGED
@@ -59,15 +59,27 @@ shim pi-subagents "$REPO/node_modules/pi-subagents/src/extension/index.ts"
59
59
 
60
60
  CLI="$REPO/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
61
61
  export PI_CODING_AGENT_DIR="$AGENT_DIR"
62
+ # Suppress Pi's upstream "Update Available — run `pi update`" banner. It's noise on a
63
+ # Privateer install (our banner is the startup surface), it leaks the "pi" name, and
64
+ # `pi update` would fight our npm-managed install. PI_SKIP_VERSION_CHECK disables ONLY
65
+ # the version fetch — unlike PI_OFFLINE it still lets fd/rg download on first run.
66
+ export PI_SKIP_VERSION_CHECK=1
62
67
  NODE_BIN="$(pick_node)"
63
68
 
64
- # Quiet Pi's built-in startup resource listing ([Extensions]/[Context]/[Skills]/…) by
65
- # default our banner is the intended startup surface, and the extension list is noise.
66
- # Only set it when unset, so a later `--verbose` or a user's own settings toggle sticks.
69
+ # Quiet Pi's built-in startup chatter so our banner is the only greeting. Each key is
70
+ # set only when unset, so a user's own settings.json toggle (or --verbose) still wins:
71
+ # quietStartup — hide the [Extensions]/[Context]/[Skills]/… resource listing
72
+ # collapseChangelog — never dump Pi's "What's New" changelog wall into the chat
73
+ # lastChangelogVersion — sentinel so Pi finds no "new" entries to show on upgrade;
74
+ # release notes live in the npm README / GitHub releases instead
67
75
  "$NODE_BIN" -e '
68
76
  const fs = require("fs"), p = process.argv[1];
69
77
  let s = {}; try { s = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
70
- if (s.quietStartup === undefined) { s.quietStartup = true; fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n"); }
78
+ let m = false;
79
+ if (s.quietStartup === undefined) { s.quietStartup = true; m = true; }
80
+ if (s.collapseChangelog === undefined) { s.collapseChangelog = true; m = true; }
81
+ if (s.lastChangelogVersion === undefined) { s.lastChangelogVersion = "9999.0.0"; m = true; }
82
+ if (m) fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
71
83
  ' "$AGENT_DIR/settings.json" 2>/dev/null || true
72
84
 
73
85
  # Default model: when signed in to a Privateer account, default to GLM 5.1 on the
@@ -83,4 +95,10 @@ else
83
95
  MODEL="openrouter/openai/gpt-4o-mini"
84
96
  fi
85
97
 
86
- exec "$NODE_BIN" --env-file="$REPO/.env" "$CLI" --model "$MODEL" "$@"
98
+ # Dev convenience: load provider keys from the repo's .env if present. A real install
99
+ # has none — so only pass the flag when the file exists (using --env-file-if-exists
100
+ # unconditionally would print "…/.env not found. Continuing without it." on every
101
+ # launch, muddying the startup banner). No .env → rely on the ambient environment.
102
+ ENV_ARGS=""
103
+ [ -f "$REPO/.env" ] && ENV_ARGS="--env-file=$REPO/.env"
104
+ 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,271 @@
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. The account provider now registers unconditionally (see
13
+ // makeAccountProvider), so Privateer appears under Pi's /login "Use a subscription"
14
+ // list and a first-time user CAN sign in through provider auth. /signin remains as a
15
+ // friendlier, dedicated shortcut that drives the same account device-code flow
16
+ // directly — one obvious command instead of /login → pick a provider.
17
+ //
18
+ // On a successful /signin we hot-register the account provider so privateer/* models
19
+ // appear immediately (the account catalog refreshes to the live listing without a
20
+ // restart).
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import * as priv from "../src/auth/privateer.ts";
25
+ import { makeAccountProvider } from "../src/providers/account.ts";
26
+
27
+ const VERSION: string = (() => {
28
+ try {
29
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
30
+ } catch {
31
+ return "0.3.0";
32
+ }
33
+ })();
34
+
35
+ // ── palette (Privateer "Open Water" ocean-blue brand) ────────────────────────
36
+ // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
37
+ // truecolor and mangles it (the old indigo/cyan came out green). These indices are
38
+ // universally supported. Navy (the logo mark) is too dark to read on a dark terminal,
39
+ // so we use the app's ocean blues — the same "Open Water" gradient as the brand.
40
+ const ESC = "\x1b[";
41
+ const RESET = `${ESC}0m`;
42
+ const BOLD = `${ESC}1m`;
43
+ const c = (n: number): string => `${ESC}38;5;${n}m`;
44
+ const OCEAN = c(39); // ≈ #00afff — primary ocean blue (anchor, wordmark "P")
45
+ const OCEAN_LIGHT = c(81); // ≈ #5fd7ff — light sky accent (wordmark, version, path)
46
+ const BORDER = c(32); // ≈ #0087d7 — deeper ocean, the frame
47
+ const DIM = `${ESC}90m`;
48
+ const GREEN = `${ESC}32m`;
49
+ const YELLOW = `${ESC}33m`;
50
+
51
+ // The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
52
+ // own model" meets lock-and-key privacy, echoing the app's anchor+padlock logo. Every
53
+ // line is the same visible width (11) so the text column beside it stays aligned.
54
+ const ANCHOR = [
55
+ " .-. ", // shackle arch
56
+ " | | ", // shackle legs
57
+ " .-----. ", // lock body top (the shackle's base)
58
+ " | o | ", // lock body + keyhole
59
+ " '--+--' ", // lock body base, shank exits
60
+ " /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
61
+ " \\ | / ", // arms
62
+ " \\_|_/ ", // flukes
63
+ ];
64
+
65
+ // Visible width = characters after stripping SGR escapes. Everything we render inside
66
+ // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
67
+ function vlen(s: string): number {
68
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
69
+ }
70
+
71
+ // Strip ESC/C0/C1 control bytes from any value we did NOT author before it lands in a
72
+ // render line. Account email/id/pubkey come from the server (via credentials.json),
73
+ // the device user_code + verification_uri come off the device-code response, and cwd
74
+ // comes from the filesystem — none is guaranteed free of raw escape sequences, which
75
+ // would otherwise reach the terminal unsanitized. Our own SGR color codes are added
76
+ // AFTER cleaning the untrusted substring, so they're preserved; normal printable text
77
+ // (including Unicode) is untouched.
78
+ // eslint-disable-next-line no-control-regex
79
+ const CONTROL_RE = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f]", "g");
80
+ function clean(s: unknown): string {
81
+ return String(s ?? "").replace(CONTROL_RE, "");
82
+ }
83
+
84
+ function shortCwd(): string {
85
+ const cwd = process.cwd();
86
+ const home = homedir();
87
+ const path = cwd === home || cwd.startsWith(home + "/") ? "~" + cwd.slice(home.length) : cwd;
88
+ return clean(path);
89
+ }
90
+
91
+ // The account line under the tagline — three states, ported from tree-cli's Banner:
92
+ // - signed in → "connected as <account>"
93
+ // - signed out AND the current model bills to a Privateer account → it can't run
94
+ // until they sign in, so say so plainly (warning)
95
+ // - signed out on their own key → a quiet tease that /signin adds more
96
+ function accountLine(modelProvider?: string): string {
97
+ const u = priv.currentUser();
98
+ if (u) {
99
+ const label = clean(u.email ?? (u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id));
100
+ return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
101
+ }
102
+ if (modelProvider === "privateer") {
103
+ return `${YELLOW}not signed in · /signin to use this model${RESET}`;
104
+ }
105
+ return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
106
+ }
107
+
108
+ // Compose the framed banner: anchor column + text column, inside a rounded accent box.
109
+ function renderBanner(width: number, modelProvider?: string): string[] {
110
+ // Leading blanks drop the text block so the wordmark sits beside the lock body and
111
+ // the shackle rises above it (one entry per anchor line — 8 total).
112
+ const right = [
113
+ "",
114
+ "",
115
+ `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}`,
116
+ `${DIM}Chart your own course privately.${RESET}`,
117
+ "",
118
+ accountLine(modelProvider),
119
+ `${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
120
+ `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
121
+ ];
122
+ // Build the body rows (anchor + gutter + text).
123
+ const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
124
+ const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
125
+ const inner = Math.min(cap, Math.max(...rows.map(vlen)));
126
+ const bar = "─".repeat(inner + 2);
127
+ const out = [`${BORDER}╭${bar}╮${RESET}`];
128
+ for (const row of rows) {
129
+ const pad = Math.max(0, inner - vlen(row));
130
+ out.push(`${BORDER}│${RESET} ${row}${" ".repeat(pad)} ${BORDER}│${RESET}`);
131
+ }
132
+ out.push(`${BORDER}╰${bar}╯${RESET}`);
133
+ return out;
134
+ }
135
+
136
+ // A Pi header Component (setHeader factory return). Static banner; captures the model
137
+ // provider so the account line reflects the picked model.
138
+ function headerComponent(modelProvider?: string) {
139
+ return {
140
+ render: (width: number): string[] => renderBanner(width, modelProvider),
141
+ invalidate() {},
142
+ };
143
+ }
144
+
145
+ // Short account label for the footer badge.
146
+ function accountBadge(): string {
147
+ const u = priv.currentUser();
148
+ if (!u) return "⚓ guest";
149
+ const label = clean(u.email ? u.email.split("@")[0] : u.solanaPublicKey ? u.solanaPublicKey.slice(0, 6) + "…" : u.id);
150
+ return `⚓ ${label}`;
151
+ }
152
+
153
+ export default function privateerBrand(pi: any): void {
154
+ let currentModelProvider: string | undefined;
155
+ let ctxRef: any = null;
156
+
157
+ const setHeader = (ctx: any) =>
158
+ ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
159
+
160
+ const refresh = (ctx: any) => {
161
+ if (!ctx?.hasUI) return;
162
+ setHeader(ctx);
163
+ ctx?.ui?.setStatus?.("account", accountBadge());
164
+ };
165
+
166
+ async function doSignIn(ctx: any): Promise<void> {
167
+ if (priv.hasCredentials()) {
168
+ const u = priv.currentUser();
169
+ return ctx?.ui?.notify?.(
170
+ `Already signed in as ${u?.email ?? u?.id}. Run /signout to switch accounts.`,
171
+ "info",
172
+ );
173
+ }
174
+ ctx?.ui?.notify?.("Connecting to Privateer — requesting a device code…", "info");
175
+ try {
176
+ const user = await priv.runDeviceLogin({
177
+ onCode: (code: any) => {
178
+ const uri = clean(code.verification_uri_complete ?? code.verification_uri ?? "");
179
+ const userCode = clean(code.user_code);
180
+ ctx?.ui?.setWidget?.(
181
+ "privateer-signin",
182
+ [
183
+ `${OCEAN_LIGHT}⚓ Sign in to Privateer${RESET}`,
184
+ `${DIM}Approve this terminal in the Privateer app:${RESET}`,
185
+ ` code ${BOLD}${OCEAN}${userCode}${RESET}`,
186
+ uri ? `${DIM} or open ${RESET}${OCEAN_LIGHT}${uri}${RESET}` : "",
187
+ `${DIM} waiting for approval…${RESET}`,
188
+ ].filter(Boolean),
189
+ { placement: "aboveEditor" },
190
+ );
191
+ },
192
+ });
193
+ ctx?.ui?.setWidget?.("privateer-signin", undefined);
194
+ // Hot-register the account provider so privateer/* models appear without a restart.
195
+ try {
196
+ await makeAccountProvider()(pi);
197
+ } catch {
198
+ /* provider list fetch failed — models appear on next launch */
199
+ }
200
+ refresh(ctx);
201
+ ctx?.ui?.notify?.(`Signed in as ${user.email ?? user.id}. Your Privateer models are ready.`, "info");
202
+ } catch (e) {
203
+ ctx?.ui?.setWidget?.("privateer-signin", undefined);
204
+ ctx?.ui?.notify?.((e as Error).message || "Sign-in failed.", "error");
205
+ }
206
+ }
207
+
208
+ async function doSignOut(ctx: any): Promise<void> {
209
+ if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
210
+ const u = priv.currentUser();
211
+ await priv.logout();
212
+ refresh(ctx);
213
+ ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
214
+ }
215
+
216
+ function showStatus(ctx: any): void {
217
+ const u = priv.currentUser();
218
+ ctx?.ui?.notify?.(
219
+ u
220
+ ? `Signed in to Privateer as ${u.email ?? u.id}.`
221
+ : "Not signed in. Run /signin to connect your Privateer account.",
222
+ "info",
223
+ );
224
+ }
225
+
226
+ pi.on("session_start", (_e: any, ctx: any) => {
227
+ ctxRef = ctx;
228
+ currentModelProvider = ctx?.model?.provider ?? currentModelProvider;
229
+ if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
230
+ ctx?.ui?.setTitle?.("Privateer");
231
+ refresh(ctx);
232
+ // No startup notify here: the banner's account line already surfaces the
233
+ // "not signed in · /signin" prompt, so a second line would just be noise.
234
+ });
235
+
236
+ // Keep the header's account line in sync with the picked model (the "this model
237
+ // needs sign-in" state depends on the current provider).
238
+ pi.on("model_select", (e: any, ctx: any) => {
239
+ ctxRef = ctx;
240
+ const next = e?.model?.provider;
241
+ if (next && next !== currentModelProvider) {
242
+ currentModelProvider = next;
243
+ setHeader(ctx);
244
+ }
245
+ });
246
+
247
+ // The machine login was invalidated server-side (TTL lapsed or revoked in the app):
248
+ // announce it and reflect it in the badge/header immediately.
249
+ priv.onSessionExpired(() => {
250
+ refresh(ctxRef);
251
+ ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
252
+ });
253
+
254
+ pi.registerCommand?.("signin", {
255
+ description: "Sign in to your Privateer account (device-code flow)",
256
+ handler: (_args: string, ctx: any) => doSignIn(ctx),
257
+ });
258
+ pi.registerCommand?.("signout", {
259
+ description: "Sign out of your Privateer account on this terminal",
260
+ handler: (_args: string, ctx: any) => doSignOut(ctx),
261
+ });
262
+ pi.registerCommand?.("privateer", {
263
+ description: "Privateer account: /privateer [status | signin | signout]",
264
+ handler: (args: string, ctx: any) => {
265
+ const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
266
+ if (sub === "signin" || sub === "login") return doSignIn(ctx);
267
+ if (sub === "signout" || sub === "logout") return doSignOut(ctx);
268
+ return showStatus(ctx);
269
+ },
270
+ });
271
+ }
@@ -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.1",
3
+ "version": "0.3.3",
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
  ],
@@ -136,8 +136,16 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
136
136
  }
137
137
  }
138
138
 
139
- // Extension factory: registers the account provider when a machine login exists. No
140
- // login → nothing registered (BYO-key only).
139
+ // Extension factory: registers the account provider so `/login` can offer it.
140
+ //
141
+ // We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
142
+ // `/login` builds its "Use a subscription" list from the OAuth providers registered
143
+ // here (authStorage.getOAuthProviders()); a not-yet-logged-in machine has no
144
+ // credentials, so gating registration on hasCredentials() left `/login` with no
145
+ // Privateer option — the classic chicken-and-egg where you can't log in because
146
+ // you're not logged in. The OAuth provider's login() itself runs the device-code
147
+ // flow when hasCredentials() is false (see privateerOAuthProvider.login), so first
148
+ // login works entirely through Pi once the provider is present.
141
149
  //
142
150
  // Registration is SYNCHRONOUS (seeded with the fallback), then refined once the live
143
151
  // catalog is fetched. This matters: Pi flushes provider registrations made during the
@@ -151,7 +159,7 @@ export function makeAccountProvider() {
151
159
  return (pi: {
152
160
  registerProvider?: (name: string, config: unknown) => void;
153
161
  }): void => {
154
- if (!hasCredentials() || typeof pi.registerProvider !== "function") return;
162
+ if (typeof pi.registerProvider !== "function") return;
155
163
  const register = (ids: string[]): void =>
156
164
  pi.registerProvider!("privateer", {
157
165
  name: "Privateer account",