privateer-agent 0.3.5 → 0.4.0

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.
Files changed (48) hide show
  1. package/README.md +19 -0
  2. package/bin/privateer-daemon.mjs +30 -0
  3. package/bin/privateer-subagent.mjs +68 -0
  4. package/bin/privateer-tui +35 -5
  5. package/extensions/privateer-brand.ts +213 -34
  6. package/extensions/privateer-context.ts +59 -0
  7. package/extensions/privateer-gate.ts +351 -6
  8. package/extensions/privateer-posture.ts +18 -2
  9. package/extensions/privateer-privacy.ts +50 -1
  10. package/package.json +4 -1
  11. package/src/auth/privateer.ts +151 -19
  12. package/src/channels/bridge.ts +293 -0
  13. package/src/channels/discord.ts +210 -0
  14. package/src/channels/run.ts +383 -0
  15. package/src/channels/slack.ts +176 -0
  16. package/src/channels/status.ts +54 -0
  17. package/src/channels/telegram.ts +139 -0
  18. package/src/channels/types.ts +36 -0
  19. package/src/channels/whatsapp.ts +178 -0
  20. package/src/cli/chat.ts +414 -28
  21. package/src/cli/daemonCli.ts +67 -0
  22. package/src/config/version.ts +16 -0
  23. package/src/context.ts +171 -0
  24. package/src/crypto/accountTrust.ts +113 -0
  25. package/src/crypto/accountVerify.ts +138 -0
  26. package/src/crypto/terminalKey.ts +95 -0
  27. package/src/crypto/terminalUnseal.ts +62 -0
  28. package/src/daemon/index.ts +522 -34
  29. package/src/daemon/service.ts +232 -0
  30. package/src/ext/permissionGate.ts +38 -0
  31. package/src/permissions/classify.ts +49 -5
  32. package/src/providers/account.ts +20 -12
  33. package/src/remote/channelsControl.ts +192 -0
  34. package/src/remote/controlAuth.ts +67 -0
  35. package/src/remote/extensionsControl.ts +140 -0
  36. package/src/remote/liveTaskSession.ts +218 -0
  37. package/src/remote/relayClient.ts +524 -0
  38. package/src/remote/remoteBridge.ts +172 -0
  39. package/src/remote/routinesControl.ts +216 -0
  40. package/src/remote/skillsControl.ts +205 -0
  41. package/src/remote/subagentChannel.ts +261 -0
  42. package/src/remote/subagentRelay.ts +126 -0
  43. package/src/remote/workflowsControl.ts +132 -0
  44. package/src/routines/store.ts +5 -1
  45. package/src/workflows/expr.ts +4 -0
  46. package/src/workflows/runner.ts +8 -0
  47. package/src/workflows/schema.ts +5 -0
  48. package/src/workflows/store.ts +108 -0
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** —
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the resident Privateer daemon (routines + app-driven headless task
3
+ // spawns). Mirrors bin/privateer.mjs: load dev keys from the repo .env WITHOUT
4
+ // changing cwd, register tsx so TS resolves regardless of the invocation cwd, then
5
+ // hand off to the daemon CLI dispatcher (which imports ./boot.ts before any Pi code).
6
+ //
7
+ // Invoked two ways: interactively via the bash launcher (`privateer daemon …`), and
8
+ // by the installed launchd/systemd service (`node privateer-daemon.mjs run`).
9
+ import { register } from "tsx/esm/api";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, resolve } from "node:path";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const repo = resolve(here, "..");
15
+
16
+ try {
17
+ process.loadEnvFile(resolve(repo, ".env"));
18
+ } catch {
19
+ /* no .env — rely on the ambient environment / ~/.privateer */
20
+ }
21
+
22
+ // The daemon loads the moat as in-code factories, so its subagent children (routines /
23
+ // task sessions) can't inherit it and `pi` isn't on PATH. Point pi-subagents at our
24
+ // moat-injecting wrapper so those children spawn gated + private with no double-load.
25
+ // Process-global is safe here: the wrapper is stateless (unlike a per-parent channel).
26
+ process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
27
+
28
+ register();
29
+ const { runDaemonCli } = await import(resolve(repo, "src/cli/daemonCli.ts"));
30
+ await runDaemonCli(process.argv.slice(2));
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // The binary pi-subagents spawns for each subagent child (via PI_SUBAGENT_PI_BINARY)
3
+ // when the PARENT loaded privateer's moat as IN-CODE extension factories (the lean
4
+ // REPL, the daemon, live task sessions) rather than agent-dir discovery.
5
+ //
6
+ // Why a wrapper here (vs the plain cli.js the TUI uses): a subagent child is a fresh
7
+ // `pi` subprocess that can't inherit the parent's in-code factories. It CAN auto-
8
+ // discover agent-dir extensions — but if the parent ALSO loaded those same shims as
9
+ // factories, Pi loads both (resource-loader merges discovered + inline) and the moat
10
+ // double-loads (two gates, two provider registrations). So instead of relying on
11
+ // discovery, this wrapper injects the moat EXPLICITLY as `-e` extensions and passes
12
+ // `--no-extensions` to turn agent-dir discovery OFF. The child then loads exactly:
13
+ // • pi-subagents' own runtime extensions (already present in the argv it built), and
14
+ // • privateer's gate + privacy + account (the three `-e` below),
15
+ // with no discovery, hence no double-load — while pi-subagents' explicit `--extension`
16
+ // args still load (‑‑no‑extensions only disables DISCOVERY, not explicit `-e`).
17
+ //
18
+ // Exported helpers are pure and unit-tested (tests/subagentWrapper.test.ts); the
19
+ // spawn only runs when this file is invoked as a binary.
20
+
21
+ import { spawn } from "node:child_process";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, resolve, join } from "node:path";
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url)); // bin/
26
+ const REPO = resolve(HERE, ".."); // repo root
27
+
28
+ // Absolute paths to privateer's moat extension entry files (the same modules the TUI
29
+ // installs as discovery shims). gate = the permission moat (fail-closed / forwards
30
+ // child approvals to the parent); privacy = ZDR/TEE posture + attestation dispatcher;
31
+ // account = the privateer/* provider so a child can run account models.
32
+ export function moatExtensionPaths(repoRoot = REPO) {
33
+ return [
34
+ join(repoRoot, "extensions", "privateer-gate.ts"),
35
+ join(repoRoot, "extensions", "privateer-privacy.ts"),
36
+ join(repoRoot, "extensions", "privateer-account.ts"),
37
+ ];
38
+ }
39
+
40
+ // The bundled Pi CLI the child actually runs (executable, `#!/usr/bin/env node`).
41
+ export function piCliPath(repoRoot = REPO) {
42
+ return join(repoRoot, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
43
+ }
44
+
45
+ // Given the args pi-subagents built for the child, return the args to run the bundled
46
+ // cli.js with: `--no-extensions` + one `-e <path>` per moat extension, THEN the
47
+ // original args (so the injected flags precede the positional `Task:` prompt).
48
+ export function buildChildArgs(originalArgs, repoRoot = REPO) {
49
+ const inject = ["--no-extensions"];
50
+ for (const p of moatExtensionPaths(repoRoot)) inject.push("-e", p);
51
+ return [...inject, ...originalArgs];
52
+ }
53
+
54
+ // Run only when invoked directly (not when imported by the test).
55
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
56
+ const args = buildChildArgs(process.argv.slice(2));
57
+ const child = spawn(process.execPath, [piCliPath(), ...args], { stdio: "inherit", env: process.env });
58
+ // Propagate the child's exit faithfully so pi-subagents' parent reads the real
59
+ // outcome (a signal re-raises; otherwise exit with the same code).
60
+ child.on("exit", (code, signal) => {
61
+ if (signal) process.kill(process.pid, signal);
62
+ else process.exit(code ?? 0);
63
+ });
64
+ child.on("error", (e) => {
65
+ console.error(`privateer-subagent: failed to spawn pi — ${e.message}`);
66
+ process.exit(1);
67
+ });
68
+ }
package/bin/privateer-tui CHANGED
@@ -38,6 +38,18 @@ pick_node() {
38
38
  echo "node"
39
39
  }
40
40
 
41
+ # `privateer daemon [run|install|uninstall|status]` — the resident background daemon
42
+ # (routines + app-driven headless task spawns). Handled HERE, before the Pi TUI exec
43
+ # and before the moat-shim install (the daemon doesn't need the interactive extension
44
+ # set), so it boots straight into src/daemon via bin/privateer-daemon.mjs.
45
+ if [ "${1:-}" = "daemon" ]; then
46
+ shift
47
+ DAEMON_NODE="$(pick_node)"
48
+ DAEMON_ENV_ARGS=""
49
+ [ -f "$REPO/.env" ] && DAEMON_ENV_ARGS="--env-file=$REPO/.env"
50
+ exec "$DAEMON_NODE" ${DAEMON_ENV_ARGS:+"$DAEMON_ENV_ARGS"} "$REPO/bin/privateer-daemon.mjs" "$@"
51
+ fi
52
+
41
53
  AGENT_DIR="${PRIVATEER_HOME:-$HOME/.privateer}/agent"
42
54
  EXT_DIR="$AGENT_DIR/extensions"
43
55
  mkdir -p "$EXT_DIR"
@@ -46,11 +58,13 @@ mkdir -p "$EXT_DIR"
46
58
  # ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
47
59
  # symlink would resolve them relative to the shim's location and break). We remove
48
60
  # 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"
61
+ 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
62
  for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
51
63
  shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
52
64
  # Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
53
65
  shim privateer-brand "$REPO/extensions/privateer-brand.ts"
66
+ # PRIVATEER.md project-context loading (like AGENTS.md/CLAUDE.md) + the /init command.
67
+ shim privateer-context "$REPO/extensions/privateer-context.ts"
54
68
  shim privateer-gate "$REPO/extensions/privateer-gate.ts"
55
69
  shim privateer-account "$REPO/extensions/privateer-account.ts"
56
70
  shim privateer-posture "$REPO/extensions/privateer-posture.ts"
@@ -68,6 +82,13 @@ shim pi-subagents "$REPO/node_modules/pi-subagents/src/extension/index.ts"
68
82
 
69
83
  CLI="$REPO/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
70
84
  export PI_CODING_AGENT_DIR="$AGENT_DIR"
85
+ # The binary pi-subagents spawns for each subagent child. Without this it falls back
86
+ # to `pi` on PATH (getPiSpawnCommand) — which a Privateer install does NOT provide, so
87
+ # every subagent spawn would fail with ENOENT. Point it at OUR bundled cli.js (it's
88
+ # executable, `#!/usr/bin/env node`): the child then reads this same PI_CODING_AGENT_DIR
89
+ # and DISCOVERS the moat shims above — so subagents run gated + private, no -e injection
90
+ # (hence no double-load). Set only when unset so a power user can override it.
91
+ export PI_SUBAGENT_PI_BINARY="${PI_SUBAGENT_PI_BINARY:-$CLI}"
71
92
  # Suppress Pi's upstream "Update Available — run `pi update`" banner. It's noise on a
72
93
  # Privateer install (our banner is the startup surface), it leaks the "pi" name, and
73
94
  # `pi update` would fight our npm-managed install. PI_SKIP_VERSION_CHECK disables ONLY
@@ -108,13 +129,22 @@ if [ -z "$(find "$UPDATE_CACHE" -mtime -1 2>/dev/null)" ]; then
108
129
  ) </dev/null >/dev/null 2>&1 &
109
130
  fi
110
131
 
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.
132
+ # Default model. An explicit PRIVATEER_MODEL always wins. Otherwise prefer Tinfoil's
133
+ # GLM 5.2 when a Tinfoil key is available: verifiable TEE inference with CLIENT-side
134
+ # attestation (the live TLS key is bound to the enclave's quote), the strongest privacy
135
+ # tier we offer — stronger than the account's server-proxied NEAR channel. Failing that,
136
+ # use the signed-in Privateer account's NEAR confidential-compute channel; with neither,
137
+ # fall back to a cheap OpenRouter model. The Tinfoil key may sit in the ambient env or in
138
+ # the dev .env the launcher loads below, so check both.
115
139
  CRED="${PRIVATEER_HOME:-$HOME/.privateer}/credentials.json"
140
+ have_tinfoil_key() {
141
+ [ -n "${TINFOIL_API_KEY:-}" ] && return 0
142
+ [ -f "$REPO/.env" ] && grep -qE '^TINFOIL_API_KEY=.+' "$REPO/.env"
143
+ }
116
144
  if [ -n "${PRIVATEER_MODEL:-}" ]; then
117
145
  MODEL="$PRIVATEER_MODEL"
146
+ elif have_tinfoil_key; then
147
+ MODEL="tinfoil/glm-5-2"
118
148
  elif [ -f "$CRED" ]; then
119
149
  MODEL="privateer/near/zai-org/GLM-5.1-FP8"
120
150
  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 {
@@ -33,35 +34,75 @@ const VERSION: string = (() => {
33
34
  }
34
35
  })();
35
36
 
36
- // ── palette (Privateer "Open Water" ocean-blue brand) ────────────────────────
37
+ // ── palette (Privateer brand) ────────────────────────────────────────────────
37
38
  // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
38
39
  // truecolor and mangles it (the old indigo/cyan came out green). These indices are
39
- // universally supported. Navy (the logo mark) is too dark to read on a dark terminal,
40
- // so we use the app's ocean blues the same "Open Water" gradient as the brand.
40
+ // universally supported. Navy (the logo mark) is too dark to read on a dark terminal, so
41
+ // the whole banner silhouette, wordmark, frame, and accents is painted white: a clean
42
+ // single-color mark, like the logo but legible on dark.
41
43
  const ESC = "\x1b[";
42
44
  const RESET = `${ESC}0m`;
43
45
  const BOLD = `${ESC}1m`;
44
46
  const c = (n: number): string => `${ESC}38;5;${n}m`;
45
- const OCEAN = c(39); // #00afffprimary ocean blue (anchor, wordmark "P")
46
- const OCEAN_LIGHT = c(81); // #5fd7fflight sky accent (wordmark, version, path)
47
- const BORDER = c(32); // #0087d7deeper ocean, the frame
47
+ const OCEAN = c(231); // white (#ffffff)anchor / wordmark "P"
48
+ const OCEAN_LIGHT = c(231); // white (#ffffff) — wordmark, version, path
49
+ const BORDER = c(231); // white (#ffffff) — the frame
48
50
  const DIM = `${ESC}90m`;
49
51
  const GREEN = `${ESC}32m`;
50
52
  const YELLOW = `${ESC}33m`;
51
53
 
52
- // The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
53
- // 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
- const ANCHOR = [
56
- " .-. ", // 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)
62
- " \\ | / ", // arms
63
- " \\_|_/ ", // flukes
54
+ // The Privateer mark: our symbol a padlock (with a keyhole) fused into an anchor,
55
+ // "bring your own model" meets lock-and-key privacy drawn from the app's logo. It's
56
+ // rendered with terminal HALF-BLOCKS, so each text row packs TWO pixel rows: a "▀"
57
+ // whose FOREGROUND paints the top pixel and BACKGROUND the bottom (one pixel → "▀"/"▄"
58
+ // on the default bg; none → a plain space). 256-color indices only, same reason as the
59
+ // palette above. Every built line is MARK_W visible cells wide (SGR escapes don't
60
+ // count), so the text column beside it stays aligned. To redraw: edit PIXELS (each char
61
+ // is a PX palette key), keeping every row MARK_W long and the row COUNT even — the
62
+ // builder derives the escapes from that.
63
+ const MARK_W = 12;
64
+ const PX: Record<string, number | null> = {
65
+ ".": null, // transparent — the frame (and the knocked-out keyhole) shows through
66
+ O: 231, // white (#ffffff, top of the 256-color cube) — the silhouette, a clean single-color mark
67
+ };
68
+ // 12 wide; an EVEN number of rows so they pair cleanly into half-block cells. Two blank
69
+ // leading rows give the lock a little headroom without dropping the whole mark too low.
70
+ // A small padlock rides on top as the anchor's ring: a narrow rounded shackle over an
71
+ // 8-wide body that OVERHANGS the shackle on both sides (matching the logo's proportions,
72
+ // where the body is clearly wider than the shackle), with a small centered 2×2 keyhole
73
+ // knocked out of it — reads clearly as a lock without dominating the anchor. Then a short
74
+ // shank → hooked fluke barbs (a 2-tall blade tip that turns up-and-out) → crown → bill.
75
+ const PIXELS = [
76
+ "............", "............",
77
+ "....OOOO....", "...OO..OO...", "...OO..OO...",
78
+ "..OOOOOOOO..", "..OOOOOOOO..",
79
+ "..OOO..OOO..", "..OOO..OOO..",
80
+ "..OOOOOOOO..", "..OOOOOOOO..",
81
+ ".....OO.....", ".....OO.....",
82
+ "O....OO....O", "OO...OO...OO", "OO...OO...OO",
83
+ ".OO..OO..OO.", "..OO.OO.OO..",
84
+ "..OOOOOOOO..", "...OOOOOO...", "....OOOO....", ".....OO.....",
64
85
  ];
86
+ // Build the mark once at load. Each cell resets SGR so a background color can never
87
+ // bleed into the row padding the framer adds after it.
88
+ const MARK: string[] = (() => {
89
+ const rows: string[] = [];
90
+ for (let r = 0; r < PIXELS.length; r += 2) {
91
+ const top = PIXELS[r];
92
+ const bot = PIXELS[r + 1] ?? ".".repeat(MARK_W);
93
+ let line = "";
94
+ for (let x = 0; x < MARK_W; x++) {
95
+ const t = PX[top[x]];
96
+ const bcol = PX[bot[x]];
97
+ if (t == null && bcol == null) line += " ";
98
+ else if (t != null && bcol != null) line += `${ESC}38;5;${t}m${ESC}48;5;${bcol}m▀${RESET}`;
99
+ else if (t != null) line += `${ESC}38;5;${t}m▀${RESET}`;
100
+ else line += `${ESC}38;5;${bcol}m▄${RESET}`;
101
+ }
102
+ rows.push(line);
103
+ }
104
+ return rows;
105
+ })();
65
106
 
66
107
  // Visible width = characters after stripping SGR escapes. Everything we render inside
67
108
  // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
@@ -82,11 +123,16 @@ function clean(s: unknown): string {
82
123
  return String(s ?? "").replace(CONTROL_RE, "");
83
124
  }
84
125
 
85
- function shortCwd(): string {
86
- const cwd = process.cwd();
126
+ // Collapse $HOME to ~ in an absolute path, and strip control bytes (paths come off the
127
+ // filesystem). Shared by the cwd line and the PRIVATEER.md line.
128
+ function shortPath(p: string): string {
87
129
  const home = homedir();
88
- const path = cwd === home || cwd.startsWith(home + "/") ? "~" + cwd.slice(home.length) : cwd;
89
- return clean(path);
130
+ const short = p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
131
+ return clean(short);
132
+ }
133
+
134
+ function shortCwd(): string {
135
+ return shortPath(process.cwd());
90
136
  }
91
137
 
92
138
  // The account line under the tagline — three states, ported from tree-cli's Banner:
@@ -134,25 +180,74 @@ function updateNotice(): string {
134
180
  return "";
135
181
  }
136
182
 
137
- // Compose the framed banner: anchor column + text column, inside a rounded accent box.
183
+ // The PRIVATEER.md line under the block: green anchor when a project-context file is
184
+ // loaded (so the moat's "the agent knows this project" state is visible), otherwise a
185
+ // quiet tease that /init scaffolds one. Reads the filesystem at render time, so it
186
+ // reflects the current cwd and updates after /init (via onContextChanged → refresh).
187
+ function contextLine(): string {
188
+ const files = discoverContextFiles();
189
+ if (files.length === 0) {
190
+ return `${DIM}no PRIVATEER.md · ${OCEAN_LIGHT}/init${DIM} to add project context${RESET}`;
191
+ }
192
+ // Show the nearest (deepest, wins-last) file's path; note any additional ancestors
193
+ // with a "+N" so the header stays one line but the count isn't hidden.
194
+ const nearest = shortPath(files[files.length - 1].path);
195
+ const more = files.length > 1 ? `${DIM} +${files.length - 1}${RESET}` : "";
196
+ return `${GREEN}⚓${DIM} ${RESET}${OCEAN_LIGHT}${nearest}${RESET}${more}`;
197
+ }
198
+
199
+ // ── "What's New" — a tiny in-banner changelog ────────────────────────────────
200
+ // A hand-curated highlights list (newest first). Not the full changelog — just the two
201
+ // or three things a returning user should notice. `cmd`, when present, is rendered in the
202
+ // accent color so the actionable bit stands out from the prose. Trim this as it ages.
203
+ const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
204
+ { text: "Privateer agent CLI is live —", cmd: "npm i -g privateer-agent" },
205
+ { text: "PRIVATEER.md project context —", cmd: "/init" },
206
+ { text: "Self-update built in —", cmd: "privateer update" },
207
+ ];
208
+
209
+ function whatsNewRows(): string[] {
210
+ const head = `${BOLD}${OCEAN_LIGHT}✦ What's new${RESET}`;
211
+ const items = WHATS_NEW.map(
212
+ ({ text, cmd }) =>
213
+ `${OCEAN}·${RESET} ${DIM}${text}${RESET}${cmd ? ` ${OCEAN_LIGHT}${cmd}${RESET}` : ""}`,
214
+ );
215
+ return [head, ...items];
216
+ }
217
+
218
+ // Compose the framed banner: the mark on the left, an independent text column on the
219
+ // right. The two columns have DIFFERENT heights (the text runs longer than the 6-line
220
+ // mark), so we zip by row index and pad the short side — every text-only row lands in
221
+ // the same column as the rows beside the mark. One place owns the left gutter, so
222
+ // spacing can't drift between the mark rows and the trailing rows.
138
223
  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 = [
224
+ // Right column, top to bottom. The two leading blanks drop the wordmark down so it
225
+ // sits beside the lock body (not the shackle); the rest follows in reading order.
226
+ const text: string[] = [
142
227
  "",
143
- "",
144
- `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}`,
228
+ `${BOLD}${OCEAN_LIGHT}✻ ${OCEAN}P${OCEAN_LIGHT}RIVATEER${RESET}${DIM} privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
145
229
  `${DIM}Chart your own course privately.${RESET}`,
146
230
  "",
147
231
  accountLine(modelProvider),
148
- `${DIM}privateer-agent ${OCEAN_LIGHT}v${VERSION}${RESET}`,
149
232
  `${OCEAN_LIGHT}${shortCwd()}${RESET}`,
233
+ contextLine(),
150
234
  ];
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
235
  const notice = updateNotice();
155
- if (notice) rows.push(` ${notice}`);
236
+ if (notice) text.push(notice);
237
+ // A blank spacer, then the What's New block — set off below the identity lines.
238
+ text.push("", ...whatsNewRows());
239
+
240
+ // Zip the mark and the text column by row. Rows past the mark's height get a blank
241
+ // gutter of the mark's width, so the text stays in one column throughout.
242
+ const gap = " ";
243
+ const height = Math.max(MARK.length, text.length);
244
+ const rows: string[] = [];
245
+ for (let i = 0; i < height; i++) {
246
+ // The mark lines already carry their own per-pixel colors, so we don't wrap them.
247
+ const left = i < MARK.length ? MARK[i] : " ".repeat(MARK_W);
248
+ rows.push(`${left}${gap}${text[i] ?? ""}`.trimEnd());
249
+ }
250
+
156
251
  const cap = Math.max(20, width - 4); // 2 border cells + 2 padding
157
252
  const inner = Math.min(cap, Math.max(...rows.map(vlen)));
158
253
  const bar = "─".repeat(inner + 2);
@@ -186,15 +281,45 @@ export default function privateerBrand(pi: any): void {
186
281
  let currentModelProvider: string | undefined;
187
282
  let ctxRef: any = null;
188
283
 
284
+ // TEMP debug trace (PRIVATEER_DEBUG=1): append a line to ~/.privateer/brand-debug.log
285
+ // so we can see, from a real sign-in, whether the refresh path fires and with what state.
286
+ const dbg = (msg: string): void => {
287
+ if (!process.env.PRIVATEER_DEBUG) return;
288
+ try {
289
+ const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
290
+ appendFileSync(join(home, "brand-debug.log"), `${new Date().toISOString()} ${msg}\n`);
291
+ } catch {
292
+ /* best effort */
293
+ }
294
+ };
295
+
189
296
  const setHeader = (ctx: any) =>
190
297
  ctx?.ui?.setHeader?.(() => headerComponent(currentModelProvider));
191
298
 
192
299
  const refresh = (ctx: any) => {
300
+ dbg(`refresh: hasUI=${!!ctx?.hasUI} hasSetHeader=${typeof ctx?.ui?.setHeader} user=${priv.currentUser()?.email ?? null}`);
193
301
  if (!ctx?.hasUI) return;
194
302
  setHeader(ctx);
195
303
  ctx?.ui?.setStatus?.("account", accountBadge());
196
304
  };
197
305
 
306
+ // Drop Pi's PERSISTED account credential (the "privateer" entry in auth.json).
307
+ // Pi reuses this credential on the next launch and refreshes it only when it
308
+ // EXPIRES — never reactively on a 401 (see the LIFECYCLE HAZARD note in
309
+ // src/auth/privateer.ts). So whenever the machine login goes away — an explicit
310
+ // /signout, or a revocation/expiry we detect server-side — we MUST also drop this
311
+ // persisted copy, or the next run reuses a token that's already dead server-side
312
+ // and dead-ends on the first inference. Removing it makes the next /signin spawn a
313
+ // fresh session. Reached via the model registry (constructed with the auth
314
+ // storage; see session.ts). Best-effort: nothing persisted → nothing to do.
315
+ const dropPersistedAccount = (ctx: any): void => {
316
+ try {
317
+ ctx?.modelRegistry?.authStorage?.remove?.("privateer");
318
+ } catch {
319
+ /* no persisted credential / older Pi without this shape — nothing to do */
320
+ }
321
+ };
322
+
198
323
  // /update — run the global npm install in a child process and report the outcome via
199
324
  // notify (the TUI keeps running the OLD code; npm swaps the global bin's inode in
200
325
  // place, so replacing it under us is safe and the new version loads on next launch).
@@ -266,6 +391,7 @@ export default function privateerBrand(pi: any): void {
266
391
  if (!priv.hasCredentials()) return ctx?.ui?.notify?.("Not signed in.", "info");
267
392
  const u = priv.currentUser();
268
393
  await priv.logout();
394
+ dropPersistedAccount(ctx);
269
395
  refresh(ctx);
270
396
  ctx?.ui?.notify?.(`Signed out${u?.email ? ` (${u.email})` : ""}. Drop anchor for now.`, "info");
271
397
  }
@@ -280,9 +406,26 @@ export default function privateerBrand(pi: any): void {
280
406
  );
281
407
  }
282
408
 
409
+ dbg("extension loaded, onSignedIn listener registering");
410
+
283
411
  pi.on("session_start", (_e: any, ctx: any) => {
412
+ dbg("session_start");
284
413
  ctxRef = ctx;
285
414
  currentModelProvider = ctx?.model?.provider ?? currentModelProvider;
415
+
416
+ // Validate the machine login against the server at launch. The banner/badge
417
+ // otherwise reflect ONLY local credentials.json, so a terminal that was signed
418
+ // out from the app (or whose login expired) keeps showing "connected as …"
419
+ // indefinitely — nothing else spawns a session at startup (Pi reuses its
420
+ // persisted account credential and refreshes it only on expiry, not on a 401).
421
+ // warmSession spawns this terminal's child session from the parent refresh
422
+ // token; if that token was revoked/expired the server 401s, which clears the
423
+ // local credentials and fires onSessionExpired — flipping the banner to "not
424
+ // signed in" right here at launch instead of dead-ending on the first prompt.
425
+ // Fire-and-forget: warmSession swallows transient errors, and the
426
+ // onSessionExpired handler below owns the UI update.
427
+ void priv.warmSession();
428
+
286
429
  if (!ctx?.hasUI) return; // headless (print/json): no banner or prompts
287
430
  ctx?.ui?.setTitle?.("Privateer");
288
431
  refresh(ctx);
@@ -301,9 +444,45 @@ export default function privateerBrand(pi: any): void {
301
444
  }
302
445
  });
303
446
 
447
+ // A machine login was newly established. This fires for BOTH sign-in paths — our
448
+ // /signin command AND Pi's /login → "Use a subscription" OAuth flow. doSignIn
449
+ // already refreshes itself, but a /login sign-in has no other hook back to us, so
450
+ // without this the header/badge would keep showing "not signed in" until relaunch.
451
+ priv.onSignedIn(() => {
452
+ dbg(`onSignedIn fired; ctxRef=${ctxRef ? "set" : "null"}`);
453
+ refresh(ctxRef);
454
+ });
455
+
456
+ // /init (in privateer-context) just created or changed a PRIVATEER.md — re-render the
457
+ // banner so its context line flips from the "/init" hint to "PRIVATEER.md loaded".
458
+ onContextChanged(() => refresh(ctxRef));
459
+
460
+ // The terminal is quitting (Ctrl+C, Ctrl+D, /quit, SIGTERM …). Pi awaits this
461
+ // handler inside runtimeHost.dispose() BEFORE process.exit, so it's our one
462
+ // reliable window to revoke the server-side sessions this run created — the
463
+ // account channel Pi drives AND any child session — so the terminal drops off the
464
+ // app's Linked Devices list immediately instead of lingering until the rows expire.
465
+ // Only on "quit": the other reasons (new/resume/fork/reload) keep this process
466
+ // alive and reuse the same account credential, so revoking would kill a live session.
467
+ // Best-effort and time-bounded (see revokeLocalSessions); exit must never hang.
468
+ pi.on("session_shutdown", async (e: any) => {
469
+ if (e?.reason && e.reason !== "quit") return;
470
+ await priv.revokeLocalSessions();
471
+ // Pair the revoke with dropping Pi's persisted account credential (the contract
472
+ // in src/auth/privateer.ts): revokeLocalSessions kills the account session
473
+ // server-side, so leaving the persisted copy behind would make the NEXT launch
474
+ // reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
475
+ // refresh on a 401). Mirrors the daemon's shutdown (daemon/index.ts).
476
+ dropPersistedAccount(ctxRef);
477
+ });
478
+
304
479
  // The machine login was invalidated server-side (TTL lapsed or revoked in the app):
305
480
  // announce it and reflect it in the badge/header immediately.
306
481
  priv.onSessionExpired(() => {
482
+ // clearCredentials() has already wiped the local machine login; also drop Pi's
483
+ // persisted account credential so the next prompt/launch doesn't reuse a token
484
+ // that's now dead server-side (see dropPersistedAccount).
485
+ dropPersistedAccount(ctxRef);
307
486
  refresh(ctxRef);
308
487
  ctxRef?.ui?.notify?.("Your Privateer session expired. Run /signin to sign back in.", "warning");
309
488
  });
@@ -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
+ }