privateer-agent 0.2.1 → 0.3.1

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 (111) hide show
  1. package/README.md +167 -442
  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 +57 -0
  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 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -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 +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +77 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. 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.2.1",
4
- "description": "Privateer — a provider-agnostic terminal coding agent. Bring your own model (OpenRouter, Anthropic, OpenAI, Ollama, NEAR AI).",
3
+ "version": "0.3.1",
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.3.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
  }
@@ -448,6 +448,63 @@ export async function logout(): Promise<void> {
448
448
  clearCredentials();
449
449
  }
450
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
+
451
508
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
452
509
  return new Promise((resolve, reject) => {
453
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>;