privateer-agent 0.3.4 → 0.3.6

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
@@ -63,6 +63,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
63
63
  | Extension | What it adds |
64
64
  |---|---|
65
65
  | `privateer-gate` | safe-by-default permission gate + destructive-command danger filter |
66
+ | `privateer-context` | loads `PRIVATEER.md` project context (like `AGENTS.md`/`CLAUDE.md`) + the `/init` command |
66
67
  | `privateer-privacy` | `pi-privacy` — TEE attestation, ZDR routing, on-device PII gate — bound to the account tier resolver |
67
68
  | `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
68
69
  | `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
@@ -168,6 +169,24 @@ vLLM, llama.cpp) works as a custom provider — just give it a base URL.
168
169
 
169
170
  Override the config location with `PRIVATEER_HOME`.
170
171
 
172
+ ## Context files — `PRIVATEER.md`
173
+
174
+ Give the agent standing knowledge about your project — conventions, common commands,
175
+ domain notes — by dropping a **`PRIVATEER.md`** in the directory. Privateer loads it
176
+ automatically at the start of every turn and prepends it to the model's system prompt,
177
+ exactly the way Pi loads `AGENTS.md` / `CLAUDE.md` (all three are recognized, and all
178
+ matching files are concatenated).
179
+
180
+ Run **`/init`** to scaffold a starter `PRIVATEER.md` in the current directory, then edit
181
+ it. The startup banner shows a **⚓** line with the loaded file's path (and a `+N` count
182
+ when ancestor files also apply), or a `/init` hint when none is found.
183
+
184
+ Discovery mirrors Pi's context-file lookup: the global agent dir
185
+ (`~/.privateer/agent/PRIVATEER.md`) first, then every directory from the filesystem root
186
+ down to the current one — so a repo-root `PRIVATEER.md` applies to every subdirectory, and
187
+ a deeper file can refine it. `AGENTS.md` and `CLAUDE.md` continue to work unchanged; use
188
+ `--no-context-files` (`-nc`) to disable context-file loading entirely.
189
+
171
190
  ## Private & verifiable inference
172
191
 
173
192
  **NEAR AI Cloud** and **Tinfoil** run every model inside a **Trusted Execution Environment** —
package/bin/privateer-tui CHANGED
@@ -46,11 +46,13 @@ mkdir -p "$EXT_DIR"
46
46
  # ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
47
47
  # symlink would resolve them relative to the shim's location and break). We remove
48
48
  # any shim we previously managed first, so a dropped package can't linger and reload.
49
- MANAGED="privateer-brand privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
49
+ MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
50
50
  for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
51
51
  shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
52
52
  # Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
53
53
  shim privateer-brand "$REPO/extensions/privateer-brand.ts"
54
+ # PRIVATEER.md project-context loading (like AGENTS.md/CLAUDE.md) + the /init command.
55
+ shim privateer-context "$REPO/extensions/privateer-context.ts"
54
56
  shim privateer-gate "$REPO/extensions/privateer-gate.ts"
55
57
  shim privateer-account "$REPO/extensions/privateer-account.ts"
56
58
  shim privateer-posture "$REPO/extensions/privateer-posture.ts"
@@ -108,13 +110,22 @@ if [ -z "$(find "$UPDATE_CACHE" -mtime -1 2>/dev/null)" ]; then
108
110
  ) </dev/null >/dev/null 2>&1 &
109
111
  fi
110
112
 
111
- # Default model: when signed in to a Privateer account, default to GLM 5.1 on the
112
- # account's NEAR confidential-compute (TEE) channel attestable, strongest privacy
113
- # tier. Otherwise (BYO key, no account) fall back to a cheap OpenRouter model. An
114
- # explicit PRIVATEER_MODEL always wins.
113
+ # Default model. An explicit PRIVATEER_MODEL always wins. Otherwise prefer Tinfoil's
114
+ # GLM 5.2 when a Tinfoil key is available: verifiable TEE inference with CLIENT-side
115
+ # attestation (the live TLS key is bound to the enclave's quote), the strongest privacy
116
+ # tier we offer — stronger than the account's server-proxied NEAR channel. Failing that,
117
+ # use the signed-in Privateer account's NEAR confidential-compute channel; with neither,
118
+ # fall back to a cheap OpenRouter model. The Tinfoil key may sit in the ambient env or in
119
+ # the dev .env the launcher loads below, so check both.
115
120
  CRED="${PRIVATEER_HOME:-$HOME/.privateer}/credentials.json"
121
+ have_tinfoil_key() {
122
+ [ -n "${TINFOIL_API_KEY:-}" ] && return 0
123
+ [ -f "$REPO/.env" ] && grep -qE '^TINFOIL_API_KEY=.+' "$REPO/.env"
124
+ }
116
125
  if [ -n "${PRIVATEER_MODEL:-}" ]; then
117
126
  MODEL="$PRIVATEER_MODEL"
127
+ elif have_tinfoil_key; then
128
+ MODEL="tinfoil/glm-5-2"
118
129
  elif [ -f "$CRED" ]; then
119
130
  MODEL="privateer/near/zai-org/GLM-5.1-FP8"
120
131
  else
@@ -19,11 +19,12 @@
19
19
  // appear immediately (the account catalog refreshes to the live listing without a
20
20
  // restart).
21
21
 
22
- import { readFileSync } from "node:fs";
22
+ import { readFileSync, appendFileSync } from "node:fs";
23
23
  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 { discoverContextFiles, onContextChanged } from "../src/context.ts";
27
28
 
28
29
  const VERSION: string = (() => {
29
30
  try {
@@ -51,14 +52,14 @@ const YELLOW = `${ESC}33m`;
51
52
 
52
53
  // The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
53
54
  // own model" meets lock-and-key privacy, echoing the app's anchor+padlock logo. Every
54
- // line is the same visible width (11) so the text column beside it stays aligned.
55
+ // line is the SAME visible width (MARK_W) and centers on column 5 (the shank/keyhole),
56
+ // so the text column beside it stays aligned. Keep them equal-width if you edit the art.
57
+ const MARK_W = 11;
55
58
  const ANCHOR = [
56
59
  " .-. ", // shackle arch
57
- " | | ", // shackle legs
58
- " .-----. ", // lock body top (the shackle's base)
59
- " | o | ", // lock body + keyhole
60
- " '--+--' ", // lock body base, shank exits
61
- " /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
60
+ " |___| ", // lock body top (the shackle's base)
61
+ " |_t_| ", // lock body + keyhole
62
+ " /\\ | /\\ ", // stock arms flare from the shank (each \\ is one backslash)
62
63
  " \\ | / ", // arms
63
64
  " \\_|_/ ", // flukes
64
65
  ];
@@ -82,11 +83,16 @@ function clean(s: unknown): string {
82
83
  return String(s ?? "").replace(CONTROL_RE, "");
83
84
  }
84
85
 
85
- function shortCwd(): string {
86
- const cwd = process.cwd();
86
+ // Collapse $HOME to ~ in an absolute path, and strip control bytes (paths come off the
87
+ // filesystem). Shared by the cwd line and the PRIVATEER.md line.
88
+ function shortPath(p: string): string {
87
89
  const home = homedir();
88
- const path = cwd === home || cwd.startsWith(home + "/") ? "~" + cwd.slice(home.length) : cwd;
89
- return clean(path);
90
+ const short = p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
91
+ return clean(short);
92
+ }
93
+
94
+ function shortCwd(): string {
95
+ return shortPath(process.cwd());
90
96
  }
91
97
 
92
98
  // The account line under the tagline — three states, ported from tree-cli's Banner:
@@ -134,25 +140,73 @@ function updateNotice(): string {
134
140
  return "";
135
141
  }
136
142
 
137
- // Compose the framed banner: anchor column + text column, inside a rounded accent box.
143
+ // The PRIVATEER.md line under the block: green anchor when a project-context file is
144
+ // loaded (so the moat's "the agent knows this project" state is visible), otherwise a
145
+ // quiet tease that /init scaffolds one. Reads the filesystem at render time, so it
146
+ // reflects the current cwd and updates after /init (via onContextChanged → refresh).
147
+ function contextLine(): string {
148
+ const files = discoverContextFiles();
149
+ if (files.length === 0) {
150
+ return `${DIM}no PRIVATEER.md · ${OCEAN_LIGHT}/init${DIM} to add project context${RESET}`;
151
+ }
152
+ // Show the nearest (deepest, wins-last) file's path; note any additional ancestors
153
+ // with a "+N" so the header stays one line but the count isn't hidden.
154
+ const nearest = shortPath(files[files.length - 1].path);
155
+ const more = files.length > 1 ? `${DIM} +${files.length - 1}${RESET}` : "";
156
+ return `${GREEN}⚓${DIM} ${RESET}${OCEAN_LIGHT}${nearest}${RESET}${more}`;
157
+ }
158
+
159
+ // ── "What's New" — a tiny in-banner changelog ────────────────────────────────
160
+ // A hand-curated highlights list (newest first). Not the full changelog — just the two
161
+ // or three things a returning user should notice. `cmd`, when present, is rendered in the
162
+ // accent color so the actionable bit stands out from the prose. Trim this as it ages.
163
+ const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
164
+ { text: "Privateer agent CLI is live —", cmd: "npm i -g privateer-agent" },
165
+ { text: "PRIVATEER.md project context —", cmd: "/init" },
166
+ { text: "Self-update built in —", cmd: "privateer update" },
167
+ ];
168
+
169
+ function whatsNewRows(): string[] {
170
+ const head = `${BOLD}${OCEAN_LIGHT}✦ What's new${RESET}`;
171
+ const items = WHATS_NEW.map(
172
+ ({ text, cmd }) =>
173
+ `${OCEAN}·${RESET} ${DIM}${text}${RESET}${cmd ? ` ${OCEAN_LIGHT}${cmd}${RESET}` : ""}`,
174
+ );
175
+ return [head, ...items];
176
+ }
177
+
178
+ // Compose the framed banner: the mark on the left, an independent text column on the
179
+ // right. The two columns have DIFFERENT heights (the text runs longer than the 6-line
180
+ // mark), so we zip by row index and pad the short side — every text-only row lands in
181
+ // the same column as the rows beside the mark. One place owns the left gutter, so
182
+ // spacing can't drift between the mark rows and the trailing rows.
138
183
  function renderBanner(width: number, modelProvider?: string): string[] {
139
- // Leading blanks drop the text block so the wordmark sits beside the lock body and
140
- // the shackle rises above it (one entry per anchor line 8 total).
141
- const right = [
142
- "",
184
+ // Right column, top to bottom. The two leading blanks drop the wordmark down so it
185
+ // sits beside the lock body (not the shackle); the rest follows in reading order.
186
+ const text: string[] = [
143
187
  "",
144
- `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}`,
188
+ `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}${DIM} privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
145
189
  `${DIM}Chart your own course privately.${RESET}`,
146
190
  "",
147
191
  accountLine(modelProvider),
148
- `${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
149
192
  `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
193
+ contextLine(),
150
194
  ];
151
- // Build the body rows (anchor + gutter + text). A pending-update notice, if any, gets
152
- // its own row under the block, indented to sit beneath the text column.
153
- const rows = ANCHOR.map((a, i) => `${OCEAN}${a}${RESET} ${right[i] ?? ""}`);
154
195
  const notice = updateNotice();
155
- if (notice) rows.push(` ${notice}`);
196
+ if (notice) text.push(notice);
197
+ // A blank spacer, then the What's New block — set off below the identity lines.
198
+ text.push("", ...whatsNewRows());
199
+
200
+ // Zip the mark and the text column by row. Rows past the mark's height get a blank
201
+ // gutter of the mark's width, so the text stays in one column throughout.
202
+ const gap = " ";
203
+ const height = Math.max(ANCHOR.length, text.length);
204
+ const rows: string[] = [];
205
+ for (let i = 0; i < height; i++) {
206
+ const left = i < ANCHOR.length ? `${OCEAN}${ANCHOR[i]}${RESET}` : " ".repeat(MARK_W);
207
+ rows.push(`${left}${gap}${text[i] ?? ""}`.trimEnd());
208
+ }
209
+
156
210
  const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
157
211
  const inner = Math.min(cap, Math.max(...rows.map(vlen)));
158
212
  const bar = "─".repeat(inner + 2);
@@ -186,15 +240,45 @@ export default function privateerBrand(pi: any): void {
186
240
  let currentModelProvider: string | undefined;
187
241
  let ctxRef: any = null;
188
242
 
243
+ // TEMP debug trace (PRIVATEER_DEBUG=1): append a line to ~/.privateer/brand-debug.log
244
+ // so we can see, from a real sign-in, whether the refresh path fires and with what state.
245
+ const dbg = (msg: string): void => {
246
+ if (!process.env.PRIVATEER_DEBUG) return;
247
+ try {
248
+ const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
249
+ appendFileSync(join(home, "brand-debug.log"), `${new Date().toISOString()} ${msg}\n`);
250
+ } catch {
251
+ /* best effort */
252
+ }
253
+ };
254
+
189
255
  const setHeader = (ctx: any) =>
190
256
  ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
191
257
 
192
258
  const refresh = (ctx: any) => {
259
+ dbg(`refresh: hasUI=${!!ctx?.hasUI} hasSetHeader=${typeof ctx?.ui?.setHeader} user=${priv.currentUser()?.email ?? null}`);
193
260
  if (!ctx?.hasUI) return;
194
261
  setHeader(ctx);
195
262
  ctx?.ui?.setStatus?.("account", accountBadge());
196
263
  };
197
264
 
265
+ // Drop Pi's PERSISTED account credential (the "privateer" entry in auth.json).
266
+ // Pi reuses this credential on the next launch and refreshes it only when it
267
+ // EXPIRES — never reactively on a 401 (see the LIFECYCLE HAZARD note in
268
+ // src/auth/privateer.ts). So whenever the machine login goes away — an explicit
269
+ // /signout, or a revocation/expiry we detect server-side — we MUST also drop this
270
+ // persisted copy, or the next run reuses a token that's already dead server-side
271
+ // and dead-ends on the first inference. Removing it makes the next /signin spawn a
272
+ // fresh session. Reached via the model registry (constructed with the auth
273
+ // storage; see session.ts). Best-effort: nothing persisted → nothing to do.
274
+ const dropPersistedAccount = (ctx: any): void => {
275
+ try {
276
+ ctx?.modelRegistry?.authStorage?.remove?.("privateer");
277
+ } catch {
278
+ /* no persisted credential / older Pi without this shape — nothing to do */
279
+ }
280
+ };
281
+
198
282
  // /update — run the global npm install in a child process and report the outcome via
199
283
  // notify (the TUI keeps running the OLD code; npm swaps the global bin's inode in
200
284
  // place, so replacing it under us is safe and the new version loads on next launch).
@@ -266,6 +350,7 @@ export default function privateerBrand(pi: any): void {
266
350
  if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
267
351
  const u = priv.currentUser();
268
352
  await priv.logout();
353
+ dropPersistedAccount(ctx);
269
354
  refresh(ctx);
270
355
  ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
271
356
  }
@@ -280,9 +365,26 @@ export default function privateerBrand(pi: any): void {
280
365
  );
281
366
  }
282
367
 
368
+ dbg("extension loaded, onSignedIn listener registering");
369
+
283
370
  pi.on("session_start", (_e: any, ctx: any) => {
371
+ dbg("session_start");
284
372
  ctxRef = ctx;
285
373
  currentModelProvider = ctx?.model?.provider ?? currentModelProvider;
374
+
375
+ // Validate the machine login against the server at launch. The banner/badge
376
+ // otherwise reflect ONLY local credentials.json, so a terminal that was signed
377
+ // out from the app (or whose login expired) keeps showing "connected as …"
378
+ // indefinitely — nothing else spawns a session at startup (Pi reuses its
379
+ // persisted account credential and refreshes it only on expiry, not on a 401).
380
+ // warmSession spawns this terminal's child session from the parent refresh
381
+ // token; if that token was revoked/expired the server 401s, which clears the
382
+ // local credentials and fires onSessionExpired — flipping the banner to "not
383
+ // signed in" right here at launch instead of dead-ending on the first prompt.
384
+ // Fire-and-forget: warmSession swallows transient errors, and the
385
+ // onSessionExpired handler below owns the UI update.
386
+ void priv.warmSession();
387
+
286
388
  if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
287
389
  ctx?.ui?.setTitle?.("Privateer");
288
390
  refresh(ctx);
@@ -301,9 +403,45 @@ export default function privateerBrand(pi: any): void {
301
403
  }
302
404
  });
303
405
 
406
+ // A machine login was newly established. This fires for BOTH sign-in paths — our
407
+ // /signin command AND Pi's /login → "Use a subscription" OAuth flow. doSignIn
408
+ // already refreshes itself, but a /login sign-in has no other hook back to us, so
409
+ // without this the header/badge would keep showing "not signed in" until relaunch.
410
+ priv.onSignedIn(() => {
411
+ dbg(`onSignedIn fired; ctxRef=${ctxRef ? "set" : "null"}`);
412
+ refresh(ctxRef);
413
+ });
414
+
415
+ // /init (in privateer-context) just created or changed a PRIVATEER.md — re-render the
416
+ // banner so its context line flips from the "/init" hint to "PRIVATEER.md loaded".
417
+ onContextChanged(() => refresh(ctxRef));
418
+
419
+ // The terminal is quitting (Ctrl+C, Ctrl+D, /quit, SIGTERM …). Pi awaits this
420
+ // handler inside runtimeHost.dispose() BEFORE process.exit, so it's our one
421
+ // reliable window to revoke the server-side sessions this run created — the
422
+ // account channel Pi drives AND any child session — so the terminal drops off the
423
+ // app's Linked Devices list immediately instead of lingering until the rows expire.
424
+ // Only on "quit": the other reasons (new/resume/fork/reload) keep this process
425
+ // alive and reuse the same account credential, so revoking would kill a live session.
426
+ // Best-effort and time-bounded (see revokeLocalSessions); exit must never hang.
427
+ pi.on("session_shutdown", async (e: any) => {
428
+ if (e?.reason && e.reason !== "quit") return;
429
+ await priv.revokeLocalSessions();
430
+ // Pair the revoke with dropping Pi's persisted account credential (the contract
431
+ // in src/auth/privateer.ts): revokeLocalSessions kills the account session
432
+ // server-side, so leaving the persisted copy behind would make the NEXT launch
433
+ // reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
434
+ // refresh on a 401). Mirrors the daemon's shutdown (daemon/index.ts).
435
+ dropPersistedAccount(ctxRef);
436
+ });
437
+
304
438
  // The machine login was invalidated server-side (TTL lapsed or revoked in the app):
305
439
  // announce it and reflect it in the badge/header immediately.
306
440
  priv.onSessionExpired(() => {
441
+ // clearCredentials() has already wiped the local machine login; also drop Pi's
442
+ // persisted account credential so the next prompt/launch doesn't reuse a token
443
+ // that's now dead server-side (see dropPersistedAccount).
444
+ dropPersistedAccount(ctxRef);
307
445
  refresh(ctxRef);
308
446
  ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
309
447
  });
@@ -0,0 +1,59 @@
1
+ // PRIVATEER.md context loading + the /init command.
2
+ //
3
+ // Pi natively loads AGENTS.md / CLAUDE.md but its candidate list is hardcoded upstream,
4
+ // so PRIVATEER.md would otherwise be ignored. This extension makes PRIVATEER.md a
5
+ // first-class context file without patching node_modules:
6
+ //
7
+ // 1. before_agent_start — discover PRIVATEER.md (global agent dir + cwd ancestors) and
8
+ // append its contents to the turn's system prompt, framed exactly like Pi frames
9
+ // AGENTS.md, so the model treats them identically.
10
+ // 2. /init — write a starter PRIVATEER.md into the current directory.
11
+ //
12
+ // The banner (privateer-brand) shows whether a PRIVATEER.md is loaded and, when none is,
13
+ // advertises /init. After /init we emit the shared context-changed signal so that line
14
+ // refreshes at once. See src/context.ts for the discovery/formatting details.
15
+
16
+ import { contextBlock, writeTemplate, emitContextChanged, CONTEXT_BLOCK_MARKER } from "../src/context.ts";
17
+
18
+ // Honor Pi's own "disable context files" switch, so --no-context-files / -nc silences
19
+ // PRIVATEER.md too (not just AGENTS.md/CLAUDE.md) — otherwise the flag would half-work.
20
+ const CONTEXT_FILES_DISABLED =
21
+ process.argv.includes("--no-context-files") || process.argv.includes("-nc");
22
+
23
+ export default function privateerContext(pi: any): void {
24
+ // Inject PRIVATEER.md into every turn's system prompt. The prompt is rebuilt per turn
25
+ // and chained across before_agent_start handlers, so appending here is idempotent for
26
+ // the turn; the marker guard makes it a no-op if an earlier handler already added it.
27
+ pi.on("before_agent_start", (event: any) => {
28
+ if (CONTEXT_FILES_DISABLED) return;
29
+ const cwd = event?.systemPromptOptions?.cwd ?? process.cwd();
30
+ const base: string = event?.systemPrompt ?? "";
31
+ if (base.includes(CONTEXT_BLOCK_MARKER)) return; // already injected this chain
32
+ const block = contextBlock(cwd);
33
+ if (!block) return; // no PRIVATEER.md anywhere — leave the prompt untouched
34
+ return { systemPrompt: base + block };
35
+ });
36
+
37
+ // /init — scaffold a PRIVATEER.md in the working directory. Never clobbers an existing
38
+ // one; on success we signal the banner so its "PRIVATEER.md loaded" line updates now
39
+ // (the file is picked up automatically on the next turn — no reload needed).
40
+ pi.registerCommand?.("init", {
41
+ description: "Create a starter PRIVATEER.md project-context file in this directory",
42
+ handler: (_args: string, ctx: any) => {
43
+ try {
44
+ const { path, created } = writeTemplate(process.cwd());
45
+ if (!created) {
46
+ ctx?.ui?.notify?.(`PRIVATEER.md already exists at ${path} — left untouched.`, "info");
47
+ return;
48
+ }
49
+ emitContextChanged();
50
+ ctx?.ui?.notify?.(
51
+ `Created ${path}. Edit it with your project's context — it loads automatically each turn.`,
52
+ "info",
53
+ );
54
+ } catch (e) {
55
+ ctx?.ui?.notify?.(`Could not create PRIVATEER.md: ${(e as Error).message || e}`, "error");
56
+ }
57
+ },
58
+ });
59
+ }
@@ -30,6 +30,45 @@ const allowedOutsideRoots: string[] = [];
30
30
  let piRef: any = null;
31
31
  let relay: any = null;
32
32
 
33
+ // Persistent footer indicator for remote access. When the relay is up, the footer
34
+ // shows a GREEN "⟿ remote access" line so it's always obvious this terminal can be
35
+ // driven from the phone — with a reminder that `/remote-access off` stops it. We
36
+ // keep a UI handle (captured from session_start / the command ctx) so the relay's
37
+ // own connect/disconnect callbacks can refresh the indicator, not just the command.
38
+ const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", DIM = "\x1b[2m", RESET = "\x1b[0m";
39
+ const REMOTE_STATUS_KEY = "privateer:remote-access";
40
+ let uiRef: any = null;
41
+ // "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
42
+ // "connected" → socket open, controller reachable (green).
43
+ let remoteState: "off" | "connecting" | "connected" = "off";
44
+
45
+ function refreshRemoteStatus(): void {
46
+ const ui = uiRef;
47
+ if (!ui?.setStatus) return;
48
+ if (remoteState === "off") {
49
+ ui.setStatus(REMOTE_STATUS_KEY, undefined);
50
+ return;
51
+ }
52
+ const text =
53
+ remoteState === "connected"
54
+ ? `${GREEN}⟿ remote access${RESET} ${DIM}· /remote-access off to stop${RESET}`
55
+ : `${YELLOW}⟿ remote access · connecting…${RESET} ${DIM}· /remote-access off to stop${RESET}`;
56
+ ui.setStatus(REMOTE_STATUS_KEY, text);
57
+ }
58
+
59
+ function setRemoteState(s: typeof remoteState): void {
60
+ remoteState = s;
61
+ refreshRemoteStatus();
62
+ }
63
+
64
+ // Tear down the relay and clear the indicator. Used by `/remote-access off` AND by
65
+ // the app's own "End remote access" action (onTerminate), so both paths converge.
66
+ function disableRemote(): void {
67
+ relay?.stop();
68
+ relay = null;
69
+ setRemoteState("off");
70
+ }
71
+
33
72
  // Inbound app→CLI files land here (keyed by "#n"); save_attachment persists them.
34
73
  const attachments = new AttachmentStore();
35
74
  let sinceLastPrompt: StoredAttachment[] = [];
@@ -47,9 +86,22 @@ const bridge = new RemoteBridge({
47
86
  piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
48
87
  },
49
88
  onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
50
- onControllerAttached: () => relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]),
89
+ // The app asked to end remote access from its side — stop the relay locally too so
90
+ // the terminal doesn't keep reconnecting, and clear the green indicator.
91
+ onTerminate: () => disableRemote(),
92
+ onControllerAttached: () => {
93
+ // A controller reached us → the socket is up and driving: go green.
94
+ setRemoteState("connected");
95
+ relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
96
+ },
51
97
  onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
52
- onStatus: () => {},
98
+ // Drive the indicator from the relay's own status stream: "connected" green;
99
+ // its reconnect/retry notices → yellow "connecting…". Ignored once we're off.
100
+ onStatus: (text) => {
101
+ if (!relay) return;
102
+ if (/disconnect|reconnect|retry|couldn't|could not/i.test(text)) setRemoteState("connecting");
103
+ else if (/connected/i.test(text)) setRemoteState("connected");
104
+ },
53
105
  });
54
106
 
55
107
  const gate = makePermissionGate({
@@ -88,6 +140,11 @@ export default function privateerControl(pi: any): void {
88
140
  // and the user hasn't pinned a mode via PRIVATEER_MODE.
89
141
  const HEADLESS = new Set(["json", "print", "rpc"]);
90
142
  pi.on("session_start", (_e: any, ctx: any) => {
143
+ // Capture the UI handle so the relay's connect/disconnect callbacks can refresh
144
+ // the footer indicator (they fire outside any command's ctx). Re-render in case
145
+ // remote access was already on when the session (re)started.
146
+ if (ctx?.ui) uiRef = ctx.ui;
147
+ refreshRemoteStatus();
91
148
  if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
92
149
  mode = "bypass";
93
150
  }
@@ -123,16 +180,17 @@ export default function privateerControl(pi: any): void {
123
180
  pi.registerCommand?.("remote-access", {
124
181
  description: "Drive this terminal from the Privateer app: /remote-access on | off",
125
182
  handler: async (args: string, ctx: any) => {
183
+ if (ctx?.ui) uiRef = ctx.ui; // keep the handle fresh for relay-driven refreshes
126
184
  const off = String(args ?? "").trim().toLowerCase() === "off";
127
185
  if (off) {
128
- relay?.stop();
129
- relay = null;
186
+ disableRemote();
130
187
  return ctx.ui?.notify?.("remote access off", "info");
131
188
  }
132
189
  if (relay) return ctx.ui?.notify?.("remote access already on", "info");
133
190
  if (!priv.hasCredentials()) return ctx.ui?.notify?.("Not signed in to Privateer.", "warning");
134
191
  relay = new RelayClient(bridge.callbacks, { label: "privateer-cli" });
135
192
  bridge.attachRelay(relay);
193
+ setRemoteState("connecting"); // yellow until the relay reports connected
136
194
  await relay.start();
137
195
  ctx.ui?.notify?.("Remote access on — approve this terminal in the Privateer app, then drive it from there.", "info");
138
196
  },
@@ -1,7 +1,7 @@
1
1
  // The privacy-posture badge in Pi's status bar (Phase 6 polish). On model select
2
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.
3
+ // footer via ctx.ui.setStatus — so the moat is *visible*: a green shield "Trusted
4
+ // Execution" for an attested enclave, a distinct label for a mere ZDR claim.
5
5
  //
6
6
  // Handles both surfaces: the account channel (privateer/*, via server-proxy
7
7
  // attestation) which pi-privacy doesn't know, and everything else via pi-privacy.
@@ -11,6 +11,20 @@ import { accountPosture } from "../src/providers/account.ts";
11
11
 
12
12
  const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
13
13
 
14
+ // ANSI so the shield "references the previous color": the TEE tiers used to show a
15
+ // green/yellow traffic-light dot — now they show a shield tinted the same color
16
+ // (green = verified, yellow = unconfirmed). The status bar renders these escapes.
17
+ const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
18
+
19
+ // The TEE tiers render as a colored shield + "Trusted Execution" (pi-privacy labels
20
+ // these "Verified TEE" / "TEE (unconfirmed)"; we rename to Trusted Execution for the
21
+ // privateer badge and swap the dot for a shield). Everything else keeps the dot.
22
+ function badgeLabel(tier: PrivacyTier): string | null {
23
+ if (tier === "tee-verified") return `${GREEN}⛉ Trusted Execution${RESET}`;
24
+ if (tier === "tee-unverified") return `${YELLOW}⛉ Trusted Execution (unconfirmed)${RESET}`;
25
+ return null;
26
+ }
27
+
14
28
  async function badgeFor(provider: string, modelId: string): Promise<string> {
15
29
  const res =
16
30
  provider === "privateer"
@@ -18,6 +32,8 @@ async function badgeFor(provider: string, modelId: string): Promise<string> {
18
32
  : await verifyModelPosture(provider, modelId, {
19
33
  apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
20
34
  });
35
+ const shield = badgeLabel(res.tier as PrivacyTier);
36
+ if (shield) return shield;
21
37
  const info = TIERS[res.tier as PrivacyTier];
22
38
  return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
23
39
  }
@@ -4,12 +4,61 @@
4
4
  // (actually confidential-compute TEE) is treated as verified-private (no PII
5
5
  // over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
6
6
  // default entry directly.
7
+ //
8
+ // It also WIDENS the tinfoil provider's model list. pi-privacy registers `tinfoil` with
9
+ // a single seed model, so any other Tinfoil model — notably our default `tinfoil/glm-5-2`
10
+ // — resolves as a "custom model id" with a startup warning and never shows in the picker.
11
+ // We re-register tinfoil with its current chat catalog AFTER pi-privacy runs (a second
12
+ // registerProvider call replaces the provider's model list; pi-privacy registers
13
+ // synchronously, so ours lands second and wins). This is purely a display/resolution
14
+ // list — posture and attestation are dispatcher-bound and unaffected by the model set.
7
15
  import { makePiPrivacyExtension } from "pi-privacy";
8
16
  import { accountPosture } from "../src/providers/account.ts";
9
17
 
10
- export default makePiPrivacyExtension({
18
+ // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), glm-5-2 first — the
19
+ // launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
20
+ // doc-upload) are intentionally omitted. Refresh from the live catalog if Tinfoil adds
21
+ // models; this static list just needs to cover what we default to and commonly pick.
22
+ const TINFOIL_MODELS = [
23
+ "glm-5-2",
24
+ "kimi-k2-6",
25
+ "deepseek-v4-pro",
26
+ "gpt-oss-120b",
27
+ "gpt-oss-safeguard-120b",
28
+ "gemma4-31b",
29
+ "llama3-3-70b",
30
+ ];
31
+
32
+ function tinfoilModel(id: string) {
33
+ return {
34
+ id,
35
+ name: id,
36
+ reasoning: false,
37
+ input: ["text"] as ("text" | "image")[],
38
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
39
+ contextWindow: 128000,
40
+ maxTokens: 4096,
41
+ };
42
+ }
43
+
44
+ const privacy = makePiPrivacyExtension({
11
45
  resolveTier: async (provider, modelId) => {
12
46
  if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
13
47
  return (await accountPosture(modelId)).tier;
14
48
  },
15
49
  });
50
+
51
+ export default function privateerPrivacy(pi: any): void {
52
+ privacy(pi);
53
+ // Re-register tinfoil with the fuller catalog. Mirrors pi-privacy's provider config
54
+ // (baseUrl/api + ${TINFOIL_API_KEY} template with authHeader); only the model list is
55
+ // widened so `tinfoil/glm-5-2` and friends resolve without the "custom model id" warning.
56
+ pi.registerProvider?.("tinfoil", {
57
+ name: "Tinfoil (private TEE inference)",
58
+ baseUrl: "https://inference.tinfoil.sh/v1",
59
+ api: "openai-completions",
60
+ apiKey: "${TINFOIL_API_KEY}",
61
+ authHeader: true,
62
+ models: TINFOIL_MODELS.map(tinfoilModel),
63
+ });
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",
@@ -97,6 +97,26 @@ let _child: ChildSession | null = null;
97
97
  let _spawnInFlight: Promise<ChildSession> | null = null;
98
98
  let _refreshInFlight: Promise<ChildSession> | null = null;
99
99
 
100
+ // The most recent ACCOUNT-provider session (spawnAccountCredentials /
101
+ // refreshAccountCredentials). Pi owns this credential's lifecycle — it drives the
102
+ // account inference channel in the TUI — so it's a distinct server-side session
103
+ // (device row) from _child. We record only its latest access token here so exit
104
+ // cleanup / an explicit sign-out (revokeAccountSession) can kill it. Rotations
105
+ // overwrite it; the previous token is already dead server-side, so tracking only
106
+ // the latest is right.
107
+ //
108
+ // LIFECYCLE HAZARD: Pi PERSISTS this session to auth.json (with a ~24h `expires`) and
109
+ // reuses it on the next launch, refreshing only when `Date.now() >= expires` — it does
110
+ // NOT refresh reactively on a 401. So revoking it at exit while leaving the persisted
111
+ // copy in place would let the next run send a token that still looks valid but is dead
112
+ // server-side → inference fails with a dead-end `401 {code: SESSION_REVOKED}`.
113
+ // The fix is to revoke it AND drop the persisted credential together: the caller must
114
+ // remove the "privateer" entry from Pi's authStorage (authStorage.remove("privateer"))
115
+ // right after revokeLocalSessions() so the next launch spawns a fresh session instead
116
+ // of reusing the revoked one. Doing both is safe; doing only one is not. See
117
+ // revokeLocalSessions and its callers (cli/chat.ts, daemon/index.ts).
118
+ let _account: { accessToken: string } | null = null;
119
+
100
120
  export function loadCredentials(): Credentials | null {
101
121
  if (_cache !== undefined) return _cache;
102
122
  const path = credentialsPath();
@@ -127,6 +147,7 @@ export function clearCredentials(): void {
127
147
  }
128
148
  _cache = null;
129
149
  _child = null;
150
+ _account = null;
130
151
  }
131
152
 
132
153
  export function hasCredentials(): boolean {
@@ -171,6 +192,39 @@ function notifySessionExpired(): void {
171
192
  }
172
193
  }
173
194
 
195
+ // ── Sign-in notification ─────────────────────────────────────────────────────
196
+ // Fired when a Privateer login completes on this terminal — the cue for the UI to
197
+ // re-render its header/badge to the signed-in state. It reaches every sign-in entry
198
+ // point:
199
+ // - our dedicated /signin command and a FRESH /login → "Use a subscription" OAuth
200
+ // login both run the device-code flow, which fires this from pollForToken once
201
+ // credentials are written; and
202
+ // - an ALREADY-LINKED machine selecting the subscription runs no device code (it
203
+ // just spawns an account session), so privateerOAuthProvider.login() fires this
204
+ // itself — otherwise that path had no hook back to the header and it kept showing
205
+ // the stale "not signed in" banner until the next launch.
206
+ // A listener here refreshes the UI regardless of which path the user took.
207
+
208
+ type SignedInListener = () => void;
209
+ const _signedInListeners = new Set<SignedInListener>();
210
+
211
+ export function onSignedIn(listener: SignedInListener): () => void {
212
+ _signedInListeners.add(listener);
213
+ return () => _signedInListeners.delete(listener);
214
+ }
215
+
216
+ // Emit the sign-in signal. Exported so the account OAuth provider can announce a
217
+ // completed subscription login on the already-linked path (see the note above).
218
+ export function notifySignedIn(): void {
219
+ for (const listener of _signedInListeners) {
220
+ try {
221
+ listener();
222
+ } catch {
223
+ /* a failing listener must not break the auth path */
224
+ }
225
+ }
226
+ }
227
+
174
228
  // ── Device authorization flow ────────────────────────────────────────────────
175
229
 
176
230
  export interface DeviceCode {
@@ -229,6 +283,7 @@ export async function pollForToken(
229
283
  const data = (await res.json()) as Omit<Credentials, "serverBaseUrl">;
230
284
  const creds: Credentials = { ...data, serverBaseUrl: base };
231
285
  saveCredentials(creds);
286
+ notifySignedIn();
232
287
  return creds;
233
288
  }
234
289
 
@@ -409,23 +464,17 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promise<
409
464
  }
410
465
 
411
466
  /**
412
- * Best-effort revoke of THIS terminal's child session on exit, so the terminal
413
- * disappears from the app's Linked Devices list immediately instead of
414
- * lingering until its access-token rows expire (24h server-side).
415
- *
416
- * Deliberately NOT authedFetch: that would spawn/refresh a session just to kill
417
- * it. If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
418
- * Bounded by a short timeout — exit must never hang on a slow network — and all
419
- * failures are swallowed; the server's TTL remains the fallback.
467
+ * DELETE the server-side session identified by `accessToken` (RFC-style bearer
468
+ * possession proof). Deliberately a raw fetch, NOT authedFetch authedFetch would
469
+ * spawn/refresh a brand-new session just to kill this one. Bounded by a short
470
+ * timeout so exit never hangs on a slow network, and all failures are swallowed;
471
+ * the server's TTL is the fallback.
420
472
  */
421
- export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
422
- const child = _child;
423
- if (!child) return;
424
- _child = null; // never reuse a session we've asked the server to revoke
473
+ async function deleteSession(accessToken: string, timeoutMs: number): Promise<void> {
425
474
  try {
426
475
  await fetch(`${serverBaseUrl()}/auth/session/current`, {
427
476
  method: "DELETE",
428
- headers: { Authorization: `Bearer ${child.accessToken}` },
477
+ headers: { Authorization: `Bearer ${accessToken}` },
429
478
  signal: AbortSignal.timeout(timeoutMs),
430
479
  });
431
480
  } catch {
@@ -433,6 +482,48 @@ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
433
482
  }
434
483
  }
435
484
 
485
+ /**
486
+ * Best-effort revoke of THIS terminal's child session (from authedFetch/apiRequest).
487
+ * If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
488
+ */
489
+ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
490
+ const child = _child;
491
+ if (!child) return;
492
+ _child = null; // never reuse a session we've asked the server to revoke
493
+ await deleteSession(child.accessToken, timeoutMs);
494
+ }
495
+
496
+ /**
497
+ * Best-effort revoke of the account-provider session (the one Pi drives for account
498
+ * inference). Called both on EXPLICIT sign-out AND as part of exit cleanup (via
499
+ * revokeLocalSessions) — safe in the exit path ONLY because the caller also drops Pi's
500
+ * persisted copy (authStorage.remove("privateer")) so the next launch spawns fresh
501
+ * rather than reusing this now-dead token. See the _account note and revokeLocalSessions.
502
+ */
503
+ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
504
+ const account = _account;
505
+ if (!account) return;
506
+ _account = null;
507
+ await deleteSession(account.accessToken, timeoutMs);
508
+ }
509
+
510
+ /**
511
+ * Revoke ALL server-side sessions this terminal created — the in-memory child session
512
+ * (authedFetch/apiRequest) AND the account-provider inference session — so the terminal
513
+ * drops off the app's Linked Devices list the moment it exits (Ctrl+C, /quit, SIGTERM …)
514
+ * instead of lingering until its token TTL (~24h). Best-effort, time-bounded, and the
515
+ * two revokes run in parallel so a slow network can't double the exit delay.
516
+ *
517
+ * IMPORTANT: the account session is persisted by Pi (auth.json) and reused on the next
518
+ * launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
519
+ * copy right after this resolves — `authStorage.remove("privateer")` — or the next run
520
+ * will reuse the token we just revoked and dead-end on a 401 (see the _account note).
521
+ * Callers: cli/chat.ts cleanup() and daemon/index.ts shutdown().
522
+ */
523
+ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
524
+ await Promise.all([revokeChildSession(timeoutMs), revokeAccountSession(timeoutMs)]);
525
+ }
526
+
436
527
  // ── Logout ───────────────────────────────────────────────────────────────────
437
528
 
438
529
  /**
@@ -493,6 +584,7 @@ export async function spawnAccountCredentials(): Promise<AccountCredential> {
493
584
  throw new Error("Your Privateer session expired. Run /login to sign in again.");
494
585
  }
495
586
  const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
587
+ _account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
496
588
  return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
497
589
  }
498
590
 
@@ -502,6 +594,7 @@ export async function refreshAccountCredentials(refresh: string): Promise<Accoun
502
594
  const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
503
595
  if (!res.ok) throw new Error(`account refresh failed (${res.status})`);
504
596
  const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
597
+ _account = { accessToken }; // the rotated session is the one an explicit sign-out revokes
505
598
  return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
506
599
  }
507
600
 
package/src/cli/chat.ts CHANGED
@@ -27,6 +27,7 @@ async function main() {
27
27
  const { RelayClient } = await import("../remote/relayClient.ts");
28
28
  const priv = await import("../auth/privateer.ts");
29
29
  const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
30
+ const { agentVersion } = await import("../config/version.ts");
30
31
 
31
32
  const spec = process.env.PRIVATEER_MODEL ?? "openrouter/openai/gpt-4o-mini";
32
33
  const slash = spec.indexOf("/");
@@ -62,7 +63,13 @@ async function main() {
62
63
  const bridge = new RemoteBridge({
63
64
  onPrompt: (text) => void runTurn(text, true),
64
65
  onInterrupt: () => void session?.abort?.(),
65
- onControllerAttached: () => relay?.sendSnapshot([]),
66
+ // On (re)attach, resync the transcript AND push live context (model +
67
+ // version) so the app's session banner shows what this terminal is really
68
+ // running. NON-PII: no cwd — see RelayClient.sendContext.
69
+ onControllerAttached: () => {
70
+ relay?.sendSnapshot([]);
71
+ relay?.sendContext({ model: spec, version: agentVersion() });
72
+ },
66
73
  onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
67
74
  });
68
75
 
@@ -113,6 +120,26 @@ async function main() {
113
120
  });
114
121
  for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
115
122
 
123
+ // Exit cleanup: revoke the server-side sessions THIS run created (the child API
124
+ // session AND the account inference session) so the terminal drops off the app's
125
+ // Linked Devices list the instant it closes — instead of lingering ~24h until its
126
+ // token TTL. We also drop Pi's persisted account credential (auth.json) so the next
127
+ // launch spawns a fresh session rather than reusing the one we just revoked (which
128
+ // Pi wouldn't reactively refresh on the resulting 401). Idempotent + time-bounded so
129
+ // a Ctrl+C during a slow network never hangs the exit. Registered BEFORE the account
130
+ // spawn below so an early Ctrl+C still tears down whatever was created.
131
+ let cleanedUp = false;
132
+ async function cleanup(): Promise<void> {
133
+ if (cleanedUp) return;
134
+ cleanedUp = true;
135
+ try { relay?.stop(); } catch { /* already stopped */ }
136
+ try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
137
+ try { (services.authStorage as any).remove?.("privateer"); } catch { /* nothing persisted */ }
138
+ }
139
+ const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
140
+ process.once("SIGINT", onSignal);
141
+ process.once("SIGTERM", onSignal);
142
+
116
143
  // Account channel: seed the OAuth credential (a fresh child session) so getApiKey
117
144
  // resolves it; Pi then manages refresh on expiry via the registered oauth provider.
118
145
  if (provider === "privateer") {
@@ -231,7 +258,7 @@ async function main() {
231
258
  if (!line) continue;
232
259
  await runTurn(line, false);
233
260
  }
234
- relay?.stop();
261
+ await cleanup();
235
262
  rl.close();
236
263
  console.log(`${DIM}bye.${RESET}`);
237
264
  process.exit(0);
@@ -0,0 +1,16 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ // The privateer-agent package version, read once from package.json. Used for the
4
+ // relay `context` frame so the app's session banner can show the real agent
5
+ // version. Returns "" if unreadable (never throws) — the app just omits the row.
6
+ let cached: string | null = null;
7
+ export function agentVersion(): string {
8
+ if (cached === null) {
9
+ try {
10
+ cached = String(createRequire(import.meta.url)("../../package.json").version ?? "");
11
+ } catch {
12
+ cached = "";
13
+ }
14
+ }
15
+ return cached;
16
+ }
package/src/context.ts ADDED
@@ -0,0 +1,171 @@
1
+ // PRIVATEER.md — Privateer's own project-context file, loaded like AGENTS.md / CLAUDE.md.
2
+ //
3
+ // Pi's built-in context loader only recognizes AGENTS.md and CLAUDE.md (the candidate
4
+ // list is hardcoded in the upstream resource-loader and isn't extensible via a hook).
5
+ // Rather than patch node_modules, we discover PRIVATEER.md ourselves and inject its
6
+ // contents into the system prompt from the privateer-context extension — using the
7
+ // exact <project_context>/<project_instructions> framing Pi uses for AGENTS.md, so the
8
+ // model treats a PRIVATEER.md indistinguishably from a native context file.
9
+ //
10
+ // Discovery mirrors Pi's loadProjectContextFiles: the global agent dir first, then every
11
+ // ancestor directory from the filesystem root down to cwd (nearest-wins ordering, deeper
12
+ // files last so they can refine broader ones). All matches are concatenated.
13
+ //
14
+ // This module is pure (no Pi imports) so both the injection extension and the brand
15
+ // banner can share it. The onContextChanged / emitContextChanged pair lets /init poke the
16
+ // banner to re-render its "PRIVATEER.md loaded" line without either extension reaching
17
+ // into the other — the same listener idiom as priv.onSignedIn in the auth module.
18
+
19
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+
23
+ export const PRIVATEER_MD = "PRIVATEER.md";
24
+
25
+ // Case variants we accept on disk (matches Pi's AGENTS.md / AGENTS.MD tolerance).
26
+ const CANDIDATES = ["PRIVATEER.md", "PRIVATEER.MD"];
27
+
28
+ export interface ContextFile {
29
+ path: string;
30
+ content: string;
31
+ }
32
+
33
+ // The global agent dir the launcher points Pi at (PRIVATEER_HOME/agent). We read the
34
+ // same PI_CODING_AGENT_DIR env the launcher exports so a global ~/.privateer/agent/
35
+ // PRIVATEER.md is honored just like a global AGENTS.md; fall back for `npm start`/dev
36
+ // runs that don't go through bin/privateer-tui.
37
+ function globalAgentDir(): string {
38
+ const fromEnv = process.env.PI_CODING_AGENT_DIR;
39
+ if (fromEnv) return resolve(fromEnv);
40
+ const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
41
+ return join(home, "agent");
42
+ }
43
+
44
+ function readCandidate(dir: string): ContextFile | null {
45
+ for (const name of CANDIDATES) {
46
+ const path = join(dir, name);
47
+ if (existsSync(path)) {
48
+ try {
49
+ return { path, content: readFileSync(path, "utf-8") };
50
+ } catch {
51
+ // unreadable (perms, races) — skip silently; the model just won't see it.
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // All PRIVATEER.md files that apply to `cwd`, in prompt order: global agent dir first,
59
+ // then root→cwd so the nearest (deepest) file lands last. De-duplicated by absolute path
60
+ // (the global dir can coincide with an ancestor).
61
+ export function discoverContextFiles(cwd: string = process.cwd()): ContextFile[] {
62
+ const files: ContextFile[] = [];
63
+ const seen = new Set<string>();
64
+ const push = (f: ContextFile | null) => {
65
+ if (f && !seen.has(f.path)) {
66
+ files.push(f);
67
+ seen.add(f.path);
68
+ }
69
+ };
70
+
71
+ push(readCandidate(globalAgentDir()));
72
+
73
+ // Walk cwd → root collecting matches, then reverse so root comes first (matching Pi's
74
+ // ancestorContextFiles.unshift ordering).
75
+ const ancestors: ContextFile[] = [];
76
+ let dir = resolve(cwd);
77
+ const root = resolve("/");
78
+ while (true) {
79
+ const f = readCandidate(dir);
80
+ if (f && !seen.has(f.path)) {
81
+ ancestors.unshift(f);
82
+ seen.add(f.path);
83
+ }
84
+ if (dir === root) break;
85
+ const parent = resolve(dir, "..");
86
+ if (parent === dir) break;
87
+ dir = parent;
88
+ }
89
+ files.push(...ancestors);
90
+ return files;
91
+ }
92
+
93
+ // A unique sentinel opening the injected block, so before_agent_start can no-op if the
94
+ // block is already present in the chained system prompt (defensive against re-entrancy).
95
+ export const CONTEXT_BLOCK_MARKER = "<!-- privateer:PRIVATEER.md -->";
96
+
97
+ // Format the discovered files into a system-prompt fragment using the same framing Pi
98
+ // applies to AGENTS.md (see core/system-prompt.js), so the model can't tell the two
99
+ // apart. Returns "" when there's nothing to inject.
100
+ export function contextBlock(cwd: string = process.cwd()): string {
101
+ const files = discoverContextFiles(cwd);
102
+ if (files.length === 0) return "";
103
+ let out = `\n\n${CONTEXT_BLOCK_MARKER}\n<project_context>\n\nProject-specific instructions and guidelines:\n\n`;
104
+ for (const { path, content } of files) {
105
+ out += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
106
+ }
107
+ out += "</project_context>\n";
108
+ return out;
109
+ }
110
+
111
+ // The starter template `/init` writes. Kept deliberately short and self-explaining — the
112
+ // first line tells a reader (and the model) exactly what the file is and how it's used.
113
+ export const PRIVATEER_TEMPLATE = `# PRIVATEER.md
114
+
115
+ Project context for the Privateer agent. Privateer loads this file automatically at
116
+ startup (the same way it loads AGENTS.md / CLAUDE.md) and prepends it to the model's
117
+ system prompt — so put anything the agent should always know about THIS project here.
118
+
119
+ ## Project
120
+
121
+ <One or two lines: what this project is and what it does.>
122
+
123
+ ## Conventions
124
+
125
+ - <Coding style, patterns, and idioms to follow.>
126
+ - <Things to avoid.>
127
+
128
+ ## Commands
129
+
130
+ - build: <command>
131
+ - test: <command>
132
+ - run: <command>
133
+
134
+ ## Notes for the agent
135
+
136
+ - <Domain context, gotchas, or constraints worth stating once.>
137
+ `;
138
+
139
+ export interface WriteResult {
140
+ path: string;
141
+ created: boolean; // false when a file was already there and we left it untouched
142
+ }
143
+
144
+ // Write a starter PRIVATEER.md into `dir`, never clobbering an existing one.
145
+ export function writeTemplate(dir: string = process.cwd()): WriteResult {
146
+ const path = join(dir, PRIVATEER_MD);
147
+ if (existsSync(path)) return { path, created: false };
148
+ writeFileSync(path, PRIVATEER_TEMPLATE, "utf-8");
149
+ return { path, created: true };
150
+ }
151
+
152
+ // ── change notification ──────────────────────────────────────────────────────
153
+ // Lets /init (in the context extension) tell the banner (in the brand extension) that
154
+ // PRIVATEER.md state changed, so the header re-renders its loaded/hint line immediately —
155
+ // without either extension importing the other. Mirrors priv.onSignedIn.
156
+ type Listener = () => void;
157
+ const listeners = new Set<Listener>();
158
+
159
+ export function onContextChanged(fn: Listener): void {
160
+ listeners.add(fn);
161
+ }
162
+
163
+ export function emitContextChanged(): void {
164
+ for (const fn of listeners) {
165
+ try {
166
+ fn();
167
+ } catch {
168
+ // a stale/broken listener must not break /init.
169
+ }
170
+ }
171
+ }
@@ -8,12 +8,13 @@ import {
8
8
  SessionManager,
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { agentDir, configPath } from "../config/paths.ts";
11
+ import { agentVersion } from "../config/version.ts";
11
12
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
12
13
  import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
13
14
  import { makePiPrivacyExtension } from "pi-privacy";
14
15
  import { makeAccountProvider } from "../providers/account.ts";
15
16
  import { RelayClient } from "../remote/relayClient.ts";
16
- import { hasCredentials, revokeChildSession, apiRequest, spawnAccountCredentials } from "../auth/privateer.ts";
17
+ import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, spawnAccountCredentials } from "../auth/privateer.ts";
17
18
  import {
18
19
  loadRoutines,
19
20
  upsertRoutine,
@@ -161,6 +162,9 @@ export class Daemon {
161
162
  private onControllerAttached(): void {
162
163
  this.controllerAttached = true;
163
164
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
165
+ // Version only — the routines terminal isn't a single-model session, so no
166
+ // model field (and no cwd, per RelayClient.sendContext's non-PII stance).
167
+ this.relay?.sendContext({ version: agentVersion() });
164
168
  const pending = drainPendingRelay();
165
169
  if (pending.length === 0) return;
166
170
  log(`controller attached — flushing ${pending.length} pending routine result(s)`);
@@ -260,6 +264,12 @@ export class Daemon {
260
264
  let out = "";
261
265
  let status: "ok" | "error" = "ok";
262
266
  let error: string | undefined;
267
+ // Track this run's account inference session so it can be torn down when the
268
+ // routine finishes — each run force-spawns a fresh one (below), so without this
269
+ // a long-lived daemon would leave one orphaned account "device" per run lingering
270
+ // in the app's Linked Devices until its token TTL.
271
+ let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
272
+ let spawnedAccount = false;
263
273
  try {
264
274
  // Auto-approve (bypass) gate — safety is `tools: allowedTools`; a dangerous
265
275
  // shell command still fail-closes headlessly (localAsk denies).
@@ -281,12 +291,14 @@ export class Daemon {
281
291
  extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
282
292
  },
283
293
  });
294
+ servicesRef = services as any;
284
295
 
285
296
  const { provider, modelId } = parseSpec(modelSpec);
286
297
  if (provider === "privateer") {
287
298
  try {
288
299
  const creds = await spawnAccountCredentials();
289
300
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
301
+ spawnedAccount = true;
290
302
  } catch (e) {
291
303
  log(` account channel unavailable: ${(e as Error).message}`);
292
304
  }
@@ -318,6 +330,17 @@ export class Daemon {
318
330
  } catch (err) {
319
331
  status = "error";
320
332
  error = err instanceof Error ? err.message : String(err);
333
+ } finally {
334
+ // Tear down THIS run's account inference session so it doesn't linger in the
335
+ // app's Linked Devices after the routine finishes. Revoke only the account
336
+ // session — the daemon's child API session (relay/outbox) must stay alive for
337
+ // the daemon's lifetime and is revoked on shutdown. Also drop Pi's persisted
338
+ // copy so a later run's fallback never reuses this revoked token. Best-effort;
339
+ // the next run force-spawns a fresh account session.
340
+ if (spawnedAccount) {
341
+ try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
342
+ try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
343
+ }
321
344
  }
322
345
 
323
346
  const content = formatResult(routine, out, status, error);
@@ -398,7 +421,7 @@ export function runDaemon(): void {
398
421
  const shutdown = () => {
399
422
  log("shutting down");
400
423
  daemon.stop();
401
- void revokeChildSession().finally(() => process.exit(0));
424
+ void revokeLocalSessions().finally(() => process.exit(0));
402
425
  };
403
426
  process.on("SIGINT", shutdown);
404
427
  process.on("SIGTERM", shutdown);
@@ -17,8 +17,9 @@ import {
17
17
  authedFetch,
18
18
  spawnAccountCredentials,
19
19
  refreshAccountCredentials,
20
+ notifySignedIn,
20
21
  } from "../auth/privateer.ts";
21
- import { interpretReport, teePosture, type PrivacyTier } from "pi-privacy";
22
+ import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
22
23
 
23
24
  // Seed/fallback catalog: registered synchronously so the account provider has real
24
25
  // models the instant it loads (before the live /api/models fetch resolves) — in
@@ -66,19 +67,44 @@ export async function fetchAccountModels(): Promise<string[]> {
66
67
  export const privateerOAuthProvider = {
67
68
  name: "Privateer account",
68
69
  usesCallbackServer: false,
69
- async login(cb: { onDeviceCode?: (info: unknown) => void }) {
70
- if (!hasCredentials()) {
71
- await runDeviceLogin({
72
- onCode: (code) =>
73
- cb.onDeviceCode?.({
74
- userCode: code.user_code,
75
- verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
76
- intervalSeconds: code.interval,
77
- expiresInSeconds: code.expires_in,
78
- }),
79
- });
70
+ // Pi's login dialog passes `signal` (its cancel AbortController) alongside the
71
+ // callbacks. We MUST thread it into runDeviceLogin — otherwise escape/ctrl+c
72
+ // aborts the dialog's signal but our poll loop never sees it, the login()
73
+ // promise never settles, and Pi never restores the editor: the "Waiting for
74
+ // authentication…" screen hangs with no way out. See auth/privateer.ts
75
+ // pollForToken, which checks the signal and rejects with "Login cancelled.".
76
+ async login(cb: { onDeviceCode?: (info: unknown) => void; signal?: AbortSignal }) {
77
+ // Fresh machine? The device-code flow below fires notifySignedIn itself (via
78
+ // pollForToken). Already linked? No device code runs — so we announce the
79
+ // completed subscription login ourselves at the end, or the header/badge would
80
+ // keep showing "not signed in" until the next launch.
81
+ const wasLinked = hasCredentials();
82
+ if (!wasLinked) {
83
+ try {
84
+ await runDeviceLogin({
85
+ signal: cb.signal,
86
+ onCode: (code) =>
87
+ cb.onDeviceCode?.({
88
+ userCode: code.user_code,
89
+ verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
90
+ intervalSeconds: code.interval,
91
+ expiresInSeconds: code.expires_in,
92
+ }),
93
+ });
94
+ } catch (e) {
95
+ // Normalize the cancel message to exactly "Login cancelled" (no period):
96
+ // Pi's login dialog only suppresses its "Failed to login…" error toast for
97
+ // that exact string, so a cancel should exit quietly, not flash an error.
98
+ if (cb.signal?.aborted) throw new Error("Login cancelled");
99
+ throw e;
100
+ }
80
101
  }
81
- return spawnAccountCredentials();
102
+ if (cb.signal?.aborted) throw new Error("Login cancelled");
103
+ const creds = await spawnAccountCredentials();
104
+ // The fresh path already fired notifySignedIn (pollForToken); fire here for the
105
+ // already-linked path so the header re-renders to "connected" on this terminal too.
106
+ if (wasLinked) notifySignedIn();
107
+ return creds;
82
108
  },
83
109
  async refreshToken(creds: { refresh: string }) {
84
110
  try {
@@ -108,13 +134,11 @@ export interface AccountPosture {
108
134
 
109
135
  // Posture for an account-channel model. For NEAR models the attestation is fetched
110
136
  // through the SERVER proxy (the account's NEAR key stays server-side): the server
111
- // mints the nonce AND returns the report, so the CLIENT contributes no freshness and
112
- // can't bind the report to the live TLS key. That is a trust-the-server posture, not
113
- // client-verified materially weaker than the direct nearai path (which supplies its
114
- // own randomNonce). So we must NOT promote it to `tee-verified`: that tier reads as
115
- // cryptographically proven and, critically, disables pi-privacy's PII gate. We cap a
116
- // green server posture at `tee-unverified` (honest yellow — the PII gate stays on) and
117
- // still surface the raw teePosture for display. ZDR-channel models route to
137
+ // mints the nonce and returns the report. A green attestation is trusted as a genuine
138
+ // TEE promoted to `tee-verified` (green shield "Trusted Execution" in the badge)
139
+ // so an attested confidential-compute model reads as verified-private. A yellow report
140
+ // stays `tee-unverified` (unconfirmed) and red falls back to `standard`; the raw
141
+ // teePosture is still surfaced for display. ZDR-channel models route to
118
142
  // zero-retention endpoints server-side, which we can't observe here — a policy claim.
119
143
  export async function accountPosture(modelId: string): Promise<AccountPosture> {
120
144
  if (privateerChannel(modelId) === "zdr") {
@@ -128,8 +152,8 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
128
152
  const data = (await res.json()) as { nonce?: string; report?: unknown };
129
153
  const att = interpretReport(modelId, data.nonce ?? "", data.report ?? {});
130
154
  const tp = teePosture(att);
131
- // Cap at client-unverifiable: server-proxied green never earns `tee-verified`.
132
- const tier: PrivacyTier = tp === "red" ? "standard" : "tee-unverified";
155
+ // green tee-verified, yellow → tee-unverified, red standard.
156
+ const tier: PrivacyTier = tierFromTeePosture(tp);
133
157
  return { tier, teePosture: tp };
134
158
  } catch (e) {
135
159
  return { tier: "tee-unverified", error: (e as Error).message };
@@ -422,6 +422,19 @@ export class RelayClient {
422
422
  this.rawSend({ type: "no_quarter", on });
423
423
  }
424
424
 
425
+ // Push this terminal's live context (selected model, agent version) to a
426
+ // controller so the app's session banner reflects reality instead of a stub.
427
+ // Sent on controller attach — like the snapshot/no_quarter resync. NON-PII ONLY
428
+ // by design: deliberately NO cwd / hostname / username, matching terminalLabel's
429
+ // stance (the server/controller learns as little as possible about the machine).
430
+ // Empty/absent fields are omitted so the app renders less rather than blank.
431
+ sendContext(ctx: { model?: string; version?: string }): void {
432
+ const frame: Record<string, unknown> = { type: "context" };
433
+ if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
434
+ if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
435
+ this.rawSend(frame);
436
+ }
437
+
425
438
  requestApproval(id: string, req: PermissionRequest): void {
426
439
  this.rawSend({
427
440
  type: "approval_request",