privateer-agent 0.1.1 → 0.3.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 (106) hide show
  1. package/README.md +143 -398
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +128 -1
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # Launch Pi's full interactive TUI with the Privateer moat + tool packs. The moat is
3
+ # installed as re-export SHIMS in the agent dir's extensions/, so BOTH this TUI and
4
+ # any subagents it spawns (which run as child `pi` processes reading the agent dir)
5
+ # load the identical set — including our permission gate. So subagent tool calls are
6
+ # gated too (the gate runs bypass-within-restricted-tools headlessly; danger.ts still
7
+ # blocks destructive shell). One source of truth via discovery — no -e (which would
8
+ # double-load vs discovery).
9
+ #
10
+ # Runs in the current directory. Model via PRIVATEER_MODEL=provider/id.
11
+ set -euo pipefail
12
+ # Resolve through symlinks so REPO points at the real repo, not the symlink dir.
13
+ SOURCE="${BASH_SOURCE[0]}"
14
+ while [ -h "$SOURCE" ]; do
15
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
16
+ SOURCE="$(readlink "$SOURCE")"
17
+ [ "${SOURCE#/}" = "$SOURCE" ] && SOURCE="$DIR/$SOURCE"
18
+ done
19
+ REPO="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
20
+
21
+ pick_node() {
22
+ if command -v node >/dev/null 2>&1 \
23
+ && node -e 'process.exit((+process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' 2>/dev/null; then
24
+ command -v node; return
25
+ fi
26
+ for c in "$HOME"/.nvm/versions/node/v22*/bin/node; do
27
+ [ -x "$c" ] && { echo "$c"; return; }
28
+ done
29
+ echo "node"
30
+ }
31
+
32
+ AGENT_DIR="${PRIVATEER_HOME:-$HOME/.privateer}/agent"
33
+ EXT_DIR="$AGENT_DIR/extensions"
34
+ mkdir -p "$EXT_DIR"
35
+
36
+ # Install/refresh the moat + tool-pack shims. Each shim re-exports its target by
37
+ # ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
38
+ # symlink would resolve them relative to the shim's location and break). We remove
39
+ # any shim we previously managed first, so a dropped package can't linger and reload.
40
+ 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"
41
+ for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
42
+ shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
43
+ # Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
44
+ shim privateer-brand "$REPO/extensions/privateer-brand.ts"
45
+ shim privateer-gate "$REPO/extensions/privateer-gate.ts"
46
+ shim privateer-account "$REPO/extensions/privateer-account.ts"
47
+ shim privateer-posture "$REPO/extensions/privateer-posture.ts"
48
+ shim privateer-tools "$REPO/extensions/privateer-tools.ts"
49
+ # pi-privacy wrapped with the account-channel tier resolver (privateer/near… = TEE),
50
+ # so it replaces loading pi-privacy's default entry.
51
+ shim privateer-privacy "$REPO/extensions/privateer-privacy.ts"
52
+ # Privacy-aligned web tools: pluggable backends (SearXNG self-hosted = private; no
53
+ # WebView). Pick the backend with /web-tools, or set SEARXNG_URL. Replaces
54
+ # pi-web-access (opened a WebView + defaulted to Exa, a 3rd-party search).
55
+ shim rpiv-web-tools "$REPO/node_modules/@juicesharp/rpiv-web-tools/index.ts"
56
+ shim pi-mcp-adapter "$REPO/node_modules/pi-mcp-adapter/index.ts"
57
+ shim pi-hypa "$REPO/node_modules/@hypabolic/pi-hypa/extensions/index.ts"
58
+ shim pi-subagents "$REPO/node_modules/pi-subagents/src/extension/index.ts"
59
+
60
+ CLI="$REPO/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
61
+ export PI_CODING_AGENT_DIR="$AGENT_DIR"
62
+ NODE_BIN="$(pick_node)"
63
+
64
+ # Quiet Pi's built-in startup resource listing ([Extensions]/[Context]/[Skills]/…) by
65
+ # default — our banner is the intended startup surface, and the extension list is noise.
66
+ # Only set it when unset, so a later `--verbose` or a user's own settings toggle sticks.
67
+ "$NODE_BIN" -e '
68
+ const fs = require("fs"), p = process.argv[1];
69
+ let s = {}; try { s = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
70
+ if (s.quietStartup === undefined) { s.quietStartup = true; fs.writeFileSync(p, JSON.stringify(s, null, 2) + "\n"); }
71
+ ' "$AGENT_DIR/settings.json" 2>/dev/null || true
72
+
73
+ # Default model: when signed in to a Privateer account, default to GLM 5.1 on the
74
+ # account's NEAR confidential-compute (TEE) channel — attestable, strongest privacy
75
+ # tier. Otherwise (BYO key, no account) fall back to a cheap OpenRouter model. An
76
+ # explicit PRIVATEER_MODEL always wins.
77
+ CRED="${PRIVATEER_HOME:-$HOME/.privateer}/credentials.json"
78
+ if [ -n "${PRIVATEER_MODEL:-}" ]; then
79
+ MODEL="$PRIVATEER_MODEL"
80
+ elif [ -f "$CRED" ]; then
81
+ MODEL="privateer/near/zai-org/GLM-5.1-FP8"
82
+ else
83
+ MODEL="openrouter/openai/gpt-4o-mini"
84
+ fi
85
+
86
+ exec "$NODE_BIN" --env-file="$REPO/.env" "$CLI" --model "$MODEL" "$@"
package/bin/privateer.mjs CHANGED
@@ -1,11 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // Privateer launcher. Registers tsx's ESM loader so the TypeScript/JSX
3
- // entrypoint runs with no build step. (Phase 6 can add a compiled binary path.)
2
+ // Launcher for the privateer 0.3 agent REPL. It runs in the DIRECTORY YOU INVOKE
3
+ // IT FROM (process.cwd() that's the agent's working dir), while resolving the
4
+ // code and dev keys from the repo. Prefer the `bin/pv` wrapper, which also picks a
5
+ // Node >= 22 (the Pi stack's floor).
6
+ import { register } from "tsx/esm/api";
4
7
  import { fileURLToPath } from "node:url";
5
8
  import { dirname, resolve } from "node:path";
6
- import { register } from "tsx/esm/api";
7
9
 
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ const repo = resolve(here, "..");
12
+
13
+ // Dev convenience: load provider keys from the repo's .env if present, WITHOUT
14
+ // changing cwd. (A real install would rely on env vars / ~/.privateer instead.)
15
+ try {
16
+ process.loadEnvFile(resolve(repo, ".env"));
17
+ } catch {
18
+ /* no .env — fall back to the ambient environment */
19
+ }
9
20
 
10
- register();
11
- await import(resolve(__dirname, "../src/main.tsx"));
21
+ register(); // resolves the repo's tsx regardless of the invocation cwd
22
+ await import(resolve(repo, "src/cli/chat.ts"));
package/bin/pv ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # Launch the privateer 0.3 dev agent in the CURRENT directory. Picks a Node >= 22
3
+ # (the Pi stack's floor): the active node if new enough, else an nvm-installed v22.
4
+ # Symlink this onto your PATH under whatever name you like (it does NOT have to be
5
+ # `pv`), e.g.: ln -s "$PWD/bin/pv" /usr/local/bin/pv
6
+ set -euo pipefail
7
+ # Resolve through symlinks so REPO points at the real repo, not the symlink dir.
8
+ SOURCE="${BASH_SOURCE[0]}"
9
+ while [ -h "$SOURCE" ]; do
10
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
11
+ SOURCE="$(readlink "$SOURCE")"
12
+ [ "${SOURCE#/}" = "$SOURCE" ] && SOURCE="$DIR/$SOURCE"
13
+ done
14
+ REPO="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
15
+
16
+ pick_node() {
17
+ if command -v node >/dev/null 2>&1 \
18
+ && node -e 'process.exit((+process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' 2>/dev/null; then
19
+ command -v node
20
+ return
21
+ fi
22
+ for c in "$HOME"/.nvm/versions/node/v22*/bin/node; do
23
+ [ -x "$c" ] && { echo "$c"; return; }
24
+ done
25
+ echo "node" # last resort — errors clearly if it's too old
26
+ }
27
+
28
+ exec "$(pick_node)" "$REPO/bin/privateer.mjs" "$@"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.1.1",
4
- "description": "Privateer — a provider-agnostic terminal coding agent. Bring your own model (OpenRouter, Anthropic, OpenAI, Ollama, NEAR AI).",
3
+ "version": "0.3.0",
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",
7
7
  "author": "Patrick (zahnno)",
@@ -15,15 +15,17 @@
15
15
  "cli",
16
16
  "tui",
17
17
  "terminal",
18
+ "pi",
19
+ "tee",
20
+ "confidential-computing",
21
+ "attestation",
22
+ "tinfoil",
18
23
  "openrouter",
19
24
  "anthropic",
20
25
  "openai",
21
26
  "ollama",
22
27
  "nearai",
23
- "tee",
24
- "confidential-computing",
25
- "ai-sdk",
26
- "ink"
28
+ "venice"
27
29
  ],
28
30
  "bin": {
29
31
  "privateer": "./bin/privateer.mjs"
@@ -38,37 +40,38 @@
38
40
  "LICENSE"
39
41
  ],
40
42
  "scripts": {
41
- "start": "tsx src/main.tsx",
42
- "dev": "tsx watch src/main.tsx",
43
+ "start": "tsx src/main.ts",
44
+ "chat": "node --env-file=.env --import tsx src/cli/chat.ts",
45
+ "dev": "tsx watch src/main.ts",
43
46
  "typecheck": "tsc --noEmit",
44
47
  "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
45
48
  },
46
49
  "engines": {
47
- "node": ">=20"
50
+ "node": ">=22.19.0"
48
51
  },
49
52
  "dependencies": {
50
- "@ai-sdk/anthropic": "^2.0.0",
51
- "@ai-sdk/openai": "^2.0.0",
52
- "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@openrouter/ai-sdk-provider": "^1.0.0",
54
- "@types/picomatch": "^4.0.3",
55
- "ai": "^5.0.0",
56
- "commander": "^12.0.0",
57
- "ink": "^5.0.0",
58
- "ink-spinner": "^5.0.0",
59
- "ink-text-input": "^6.0.0",
60
- "ollama-ai-provider-v2": "^1.2.0",
53
+ "@earendil-works/pi-ai": "^0.80.3",
54
+ "@earendil-works/pi-coding-agent": "^0.80.3",
55
+ "@earendil-works/pi-tui": "^0.80.3",
56
+ "@hypabolic/pi-hypa": "^0.1.6",
57
+ "@juicesharp/rpiv-web-tools": "^1.20.0",
58
+ "@noble/ciphers": "^2.1.1",
59
+ "@noble/curves": "^1.9.7",
60
+ "@noble/hashes": "^1.7.1",
61
+ "pi-mcp-adapter": "^2.11.0",
62
+ "pi-privacy": "^0.2.0",
63
+ "pi-subagents": "^0.34.0",
61
64
  "picomatch": "^4.0.4",
62
- "react": "^18.3.1",
63
65
  "tsx": "^4.16.0",
66
+ "typebox": "^1.3.4",
67
+ "undici": "^7.28.0",
64
68
  "ws": "^8.21.0",
65
69
  "zod": "^4.0.16"
66
70
  },
67
71
  "devDependencies": {
68
72
  "@types/node": "^20.14.0",
69
- "@types/react": "^18.3.3",
73
+ "@types/picomatch": "^4.0.3",
70
74
  "@types/ws": "^8.18.1",
71
- "ink-testing-library": "^4.0.0",
72
75
  "typescript": "^5.5.0"
73
76
  }
74
77
  }
@@ -145,6 +145,32 @@ function tryChmod(path: string, mode: number): void {
145
145
  }
146
146
  }
147
147
 
148
+ // ── Session-expiry notification ──────────────────────────────────────────────
149
+ // Fired when the machine login is invalidated server-side: the parent refresh
150
+ // token's TTL lapsed (14 days for email logins, 60 for wallet — each session
151
+ // spawn slides it forward, so this means the machine sat unused that long), or
152
+ // it was revoked (from the app's Linked Devices, or by reuse detection).
153
+ // Without a listener the credentials are wiped silently and Privateer just
154
+ // stops working; the UI subscribes to announce the sign-out prominently.
155
+
156
+ type SessionExpiredListener = () => void;
157
+ const _expiredListeners = new Set<SessionExpiredListener>();
158
+
159
+ export function onSessionExpired(listener: SessionExpiredListener): () => void {
160
+ _expiredListeners.add(listener);
161
+ return () => _expiredListeners.delete(listener);
162
+ }
163
+
164
+ function notifySessionExpired(): void {
165
+ for (const listener of _expiredListeners) {
166
+ try {
167
+ listener();
168
+ } catch {
169
+ /* a failing listener must not break the auth path */
170
+ }
171
+ }
172
+ }
173
+
148
174
  // ── Device authorization flow ────────────────────────────────────────────────
149
175
 
150
176
  export interface DeviceCode {
@@ -271,7 +297,10 @@ async function spawnChildSession(): Promise<ChildSession> {
271
297
  });
272
298
  if (!res.ok) {
273
299
  // Parent refresh token invalid/expired → the machine login is gone.
274
- if (res.status === 401) clearCredentials();
300
+ if (res.status === 401) {
301
+ clearCredentials();
302
+ notifySessionExpired();
303
+ }
275
304
  throw new Error("Your Privateer session expired. Run /login to sign in again.");
276
305
  }
277
306
  const { accessToken, refreshToken } = (await res.json()) as ChildSession;
@@ -279,6 +308,22 @@ async function spawnChildSession(): Promise<ChildSession> {
279
308
  return _child;
280
309
  }
281
310
 
311
+ /**
312
+ * Eagerly spawn this terminal's child session at startup so an expired machine
313
+ * login is announced (via onSessionExpired) at launch rather than surfacing as
314
+ * an inference error on the first prompt of the day. Best effort: transient
315
+ * network failures stay silent here — the first real request retries the spawn
316
+ * and reports through the normal error path.
317
+ */
318
+ export async function warmSession(): Promise<void> {
319
+ if (!hasCredentials()) return;
320
+ try {
321
+ await ensureChildSession();
322
+ } catch {
323
+ /* expiry is announced via onSessionExpired; other failures retry on use */
324
+ }
325
+ }
326
+
282
327
  // Ensure a child session exists, de-duping concurrent spawns within this process.
283
328
  function ensureChildSession(): Promise<ChildSession> {
284
329
  if (_child) return Promise.resolve(_child);
@@ -363,6 +408,31 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promise<
363
408
  return authedFetch(`${base}${path}`, init);
364
409
  }
365
410
 
411
+ /**
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.
420
+ */
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
425
+ try {
426
+ await fetch(`${serverBaseUrl()}/auth/session/current`, {
427
+ method: "DELETE",
428
+ headers: { Authorization: `Bearer ${child.accessToken}` },
429
+ signal: AbortSignal.timeout(timeoutMs),
430
+ });
431
+ } catch {
432
+ /* best effort — the session expires server-side regardless */
433
+ }
434
+ }
435
+
366
436
  // ── Logout ───────────────────────────────────────────────────────────────────
367
437
 
368
438
  /**
@@ -378,6 +448,63 @@ export async function logout(): Promise<void> {
378
448
  clearCredentials();
379
449
  }
380
450
 
451
+ // ── Account provider (Pi OAuth) credential helpers ───────────────────────────
452
+ // The `privateer` account inference provider registers with Pi via the OAuth path
453
+ // (ProviderConfigInput.oauth). Pi treats a credential as { access, refresh, expires }
454
+ // and refreshes when Date.now() >= expires. We map a per-terminal CHILD session onto
455
+ // that shape: `access` = child access JWT, `refresh` = child refresh token, `expires`
456
+ // = the JWT's exp (so Pi refreshes just before the server would reject it). These are
457
+ // independent of authedFetch's in-memory _child (Pi owns this credential's lifecycle).
458
+
459
+ interface AccountCredential {
460
+ access: string;
461
+ refresh: string;
462
+ expires: number; // ms epoch
463
+ }
464
+
465
+ // Decode a JWT's `exp` (seconds) → ms epoch minus a safety skew, or a short fallback
466
+ // so an undecodable token still gets refreshed frequently rather than never.
467
+ function jwtExpMs(token: string, fallbackMs = 5 * 60_000): number {
468
+ try {
469
+ const seg = token.split(".")[1];
470
+ const payload = JSON.parse(Buffer.from(seg, "base64").toString("utf8")) as { exp?: unknown };
471
+ if (typeof payload.exp === "number") return payload.exp * 1000 - 30_000; // 30s skew
472
+ } catch {
473
+ /* not a decodable JWT */
474
+ }
475
+ return Date.now() + fallbackMs;
476
+ }
477
+
478
+ // Spawn a fresh child session (from the parent machine login) as an account credential.
479
+ export async function spawnAccountCredentials(): Promise<AccountCredential> {
480
+ const parent = loadCredentials();
481
+ if (!parent) throw new Error("Not logged in to Privateer. Run /login.");
482
+ const res = await postJson(
483
+ serverBaseUrl(),
484
+ "/auth/session/spawn",
485
+ { refreshToken: parent.refreshToken, deviceLabel: defaultDeviceLabel() },
486
+ { headers: { Authorization: `Bearer ${parent.accessToken}` } },
487
+ );
488
+ if (!res.ok) {
489
+ if (res.status === 401) {
490
+ clearCredentials();
491
+ notifySessionExpired();
492
+ }
493
+ throw new Error("Your Privateer session expired. Run /login to sign in again.");
494
+ }
495
+ const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
496
+ return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
497
+ }
498
+
499
+ // Rotate this account credential's own refresh token; caller falls back to a fresh
500
+ // spawn if this throws (expired/reused child token).
501
+ export async function refreshAccountCredentials(refresh: string): Promise<AccountCredential> {
502
+ const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
503
+ if (!res.ok) throw new Error(`account refresh failed (${res.status})`);
504
+ const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
505
+ return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
506
+ }
507
+
381
508
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
382
509
  return new Promise((resolve, reject) => {
383
510
  const t = setTimeout(resolve, ms);
package/src/boot.ts ADDED
@@ -0,0 +1,43 @@
1
+ // BOOT — the first module loaded, before any Pi module exists in the graph.
2
+ //
3
+ // Two load-bearing side effects, both of which MUST happen before Pi is
4
+ // imported/evaluated (see docs/pi-migration-plan.md §0 and §Appendix A.3):
5
+ //
6
+ // 1. Pin PI_CODING_AGENT_DIR to $PRIVATEER_HOME/agent. Many Pi internals
7
+ // (settings-manager, auth-storage, model-registry, migrations, sdk) call
8
+ // getAgentDir() directly at import/eval time and IGNORE the agentDir option,
9
+ // so the env var — not the option — is the real lever. Set it here, once.
10
+ //
11
+ // 2. Install the process-wide undici attestation dispatcher, so Pi's provider
12
+ // TLS handshakes flow through a connector we can inspect (the TEE/Tinfoil
13
+ // SPKI capture). Pi's extension hooks can't reach the TLS layer; this can.
14
+ //
15
+ // ORDERING CONTRACT: this module imports ONLY node builtins + our own paths and
16
+ // dispatcher modules. It must NEVER import anything from @earendil-works/pi-*.
17
+ // Entrypoints do `import "./boot.ts";` and then DYNAMICALLY import all
18
+ // Pi-touching code, so these side effects are guaranteed to run first regardless
19
+ // of ESM hoisting. Keep this file dependency-light and import-order-safe.
20
+
21
+ import { agentDir } from "./config/paths.ts";
22
+ // The attestation dispatcher now lives in the pi-privacy package (the publishable
23
+ // moat). boot still installs it pre-Pi as belt-and-suspenders; the package's
24
+ // extension would also install it at extension-init (idempotent — same module
25
+ // instance, so the captured-cert map is shared).
26
+ import { installAttestationDispatcher } from "pi-privacy/attest";
27
+
28
+ // (1) Pin Pi's agent dir under the privateer home. `??=` so an explicit
29
+ // PI_CODING_AGENT_DIR from the environment still wins (tests, power users).
30
+ //
31
+ // NOTE: confirm the literal env var name against the installed Pi constant —
32
+ // ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`, which is
33
+ // "PI_CODING_AGENT_DIR" for APP_NAME="pi". Pinned here as a constant so a Pi
34
+ // rename surfaces as one edit, not a silent config-home split.
35
+ export const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
36
+ process.env[PI_AGENT_DIR_ENV] ??= agentDir();
37
+
38
+ // (2) Install the attestation dispatcher (idempotent).
39
+ installAttestationDispatcher();
40
+
41
+ // Marker other modules can assert on to catch an accidental "imported Pi before
42
+ // boot" regression during development.
43
+ export const BOOTED = true;
@@ -0,0 +1,182 @@
1
+ // The load-bearing bridge: translate Pi's session.subscribe() events into the
2
+ // privateer `EngineEvent` vocabulary that RelayClient.sendEvent() already speaks.
3
+ // This is the ENTIRE coupling between Pi and the preserved connection layer — the
4
+ // relay/app see EngineEvents and never learn the loop underneath changed.
5
+ //
6
+ // Promoted from ../../ pi-spike/adapter.mjs (spike-B proven) and HARDENED per
7
+ // Phase 1 of docs/pi-migration-plan.md:
8
+ // - stateful so `usage` carries BOTH the cumulative session total and this
9
+ // turn's delta (the real EngineEvent shape, unlike the stateless spike),
10
+ // - `finish` carries usage + finishReason,
11
+ // - compaction / auto-retry / agent-end-error mapped through.
12
+ //
13
+ // Field names for the compaction/retry/error events are best-effort against Pi
14
+ // 0.80 and marked TODO(verify) — Phase 1's test (`tests/engine.test.ts`, ported
15
+ // from tree-cli) asserts the mapping against a real event stream and pins them.
16
+
17
+ import {
18
+ addUsage,
19
+ emptyUsage,
20
+ type EngineEvent,
21
+ type UsageTotals,
22
+ } from "../engine/events.ts";
23
+
24
+ // Minimal structural view of the Pi session events we read. Kept loose (not the
25
+ // full Pi union) so this module type-checks without pinning to Pi's internals;
26
+ // the runtime shape is what the spike observed.
27
+ export interface PiSessionEvent {
28
+ type: string;
29
+ [k: string]: unknown;
30
+ }
31
+
32
+ interface PiUsage {
33
+ input?: number;
34
+ output?: number;
35
+ cacheRead?: number;
36
+ totalTokens?: number;
37
+ cost?: { total?: number };
38
+ }
39
+
40
+ function normUsage(u: PiUsage | undefined): UsageTotals {
41
+ const input = u?.input ?? 0;
42
+ const output = u?.output ?? 0;
43
+ return {
44
+ inputTokens: input,
45
+ outputTokens: output,
46
+ cachedInputTokens: u?.cacheRead ?? 0,
47
+ totalTokens: u?.totalTokens ?? input + output,
48
+ };
49
+ }
50
+
51
+ function textOf(result: unknown): string {
52
+ if (typeof result === "string") return result;
53
+ const parts = (result as any)?.content;
54
+ if (Array.isArray(parts)) return parts.map((p: any) => p.text ?? `[${p.type}]`).join("");
55
+ try {
56
+ return JSON.stringify(result);
57
+ } catch {
58
+ return String(result);
59
+ }
60
+ }
61
+
62
+ function num(v: unknown, fallback = 0): number {
63
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
64
+ }
65
+
66
+ // Create a stateful adapter. One instance per session (it accumulates the
67
+ // running usage total). `toEngineEvents` returns zero-or-more EngineEvents for
68
+ // each Pi event; feed each through in `session.subscribe`.
69
+ export function createEngineEventAdapter() {
70
+ let sessionTotal: UsageTotals = emptyUsage();
71
+
72
+ function toEngineEvents(ev: PiSessionEvent): EngineEvent[] {
73
+ switch (ev.type) {
74
+ case "message_update": {
75
+ const a = ev.assistantMessageEvent as any;
76
+ if (a?.type === "text_delta") return [{ type: "text", text: a.delta }];
77
+ if (a?.type === "thinking_delta") return [{ type: "reasoning", text: a.delta }];
78
+ return [];
79
+ }
80
+
81
+ case "tool_execution_start":
82
+ return [
83
+ {
84
+ type: "tool-call",
85
+ id: ev.toolCallId as string,
86
+ name: ev.toolName as string,
87
+ input: ev.args,
88
+ },
89
+ ];
90
+
91
+ case "tool_execution_end":
92
+ return ev.isError
93
+ ? [
94
+ {
95
+ type: "tool-error",
96
+ id: ev.toolCallId as string,
97
+ name: ev.toolName as string,
98
+ error: textOf(ev.result),
99
+ },
100
+ ]
101
+ : [
102
+ {
103
+ type: "tool-result",
104
+ id: ev.toolCallId as string,
105
+ name: ev.toolName as string,
106
+ output: textOf(ev.result),
107
+ },
108
+ ];
109
+
110
+ case "turn_end": {
111
+ const turn = normUsage((ev.message as any)?.usage);
112
+ sessionTotal = addUsage(sessionTotal, turn);
113
+ const finishReason =
114
+ (ev.finishReason as string) ??
115
+ ((ev.message as any)?.stopReason as string) ??
116
+ "stop";
117
+ return [
118
+ { type: "usage", usage: sessionTotal, turn },
119
+ { type: "finish", usage: sessionTotal, finishReason },
120
+ ];
121
+ }
122
+
123
+ // Compaction: Pi collapses history to free context. TODO(verify) field
124
+ // names for before/after token counts against a real compaction event.
125
+ case "compaction_start":
126
+ case "compaction_end":
127
+ return [
128
+ {
129
+ type: "compacted",
130
+ before: num(ev.beforeTokens ?? (ev as any).before),
131
+ after: num(ev.afterTokens ?? (ev as any).after),
132
+ },
133
+ ];
134
+
135
+ // Automatic transient-failure retry before any output streamed.
136
+ // TODO(verify) Pi's auto-retry event name + fields.
137
+ case "auto_retry":
138
+ case "auto_retry_start":
139
+ return [
140
+ {
141
+ type: "retrying",
142
+ attempt: num(ev.attempt, 1),
143
+ max: num(ev.max ?? (ev as any).maxAttempts, 1),
144
+ delayMs: num(ev.delayMs ?? (ev as any).delay, 0),
145
+ reason: String(ev.reason ?? (ev as any).error ?? "transient failure"),
146
+ },
147
+ ];
148
+
149
+ // Terminal error surfaced at the end of an agent run. TODO(verify) shape;
150
+ // Phase 1 re-points errors/errors.ts (describeError) here.
151
+ case "agent_end": {
152
+ const err = (ev as any).error;
153
+ if (!err) return [];
154
+ return [
155
+ {
156
+ type: "error",
157
+ error: typeof err === "string" ? err : (err.message ?? String(err)),
158
+ retryable: Boolean((ev as any).retryable),
159
+ },
160
+ ];
161
+ }
162
+
163
+ case "abort":
164
+ case "aborted":
165
+ return [{ type: "aborted" }];
166
+
167
+ default:
168
+ return [];
169
+ }
170
+ }
171
+
172
+ return {
173
+ toEngineEvents,
174
+ // Current running total, for callers that want to snapshot session usage
175
+ // outside the event stream.
176
+ get sessionUsage(): UsageTotals {
177
+ return sessionTotal;
178
+ },
179
+ };
180
+ }
181
+
182
+ export type EngineEventAdapter = ReturnType<typeof createEngineEventAdapter>;