privateer-agent 0.2.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 (111) hide show
  1. package/README.md +135 -443
  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 +73 -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
@@ -1,9 +1,14 @@
1
1
  import { dirname } from "node:path";
2
- import type { PermissionMode } from "../config/schema.ts";
2
+ import type { PermissionMode } from "../config/permissionMode.ts";
3
3
  import type { PermissionGate, PermissionRequest, PermissionDecision } from "./gate.ts";
4
4
  import { decideAuto } from "./mode.ts";
5
5
  import { isDangerousCommand } from "./danger.ts";
6
6
 
7
+ // Ported verbatim from tree-cli/src/permissions/uiGate.ts (renamed modeGate;
8
+ // only import paths changed). The mode/allowlist/remote policy engine — the moat.
9
+ // In the Pi rewrite the extension (../ext/permissionGate.ts) builds one of these
10
+ // per tool_call, with `ask` bound to that call's ctx.ui (local) or the relay (remote).
11
+
7
12
  // What the interactive prompt can return. "always" means allow now and remember:
8
13
  // for bash, add the command to the session allowlist; for edits, switch to acceptEdits.
9
14
  export type AskOutcome = "allow" | "deny" | "always";
@@ -15,8 +20,7 @@ export interface ModeGateDeps {
15
20
  allowlist: string[]; // session-scoped, mutated in place on "always"
16
21
  denylist?: string[]; // dangerous-command patterns that always require a prompt
17
22
  // Out-of-cwd directories approved this session ("always" on an outside prompt),
18
- // mutated in place. Shared with the tool context so approved locations stop
19
- // re-prompting. The same array instance must be handed to the tools.
23
+ // mutated in place. Shared with the tool scope so approved locations stop re-prompting.
20
24
  allowedOutsideRoots?: string[];
21
25
  ask: Asker;
22
26
  // True while the active turn was injected by a remote controller (the app, via
@@ -25,6 +29,11 @@ export interface ModeGateDeps {
25
29
  // an unattended terminal can't silently run a remote party's bash or edits.
26
30
  // Hard denies (e.g. plan mode) are still honored without bothering the phone.
27
31
  getRemote?: () => boolean;
32
+ // True while the controller has toggled no-quarter (unattended) mode: remote
33
+ // turns auto-approve like bypass mode so the agent runs to completion without
34
+ // pinging the phone. Dangerous shell and alwaysAsk-destructive actions rank
35
+ // above bypass in decideAuto, so those still relay for an explicit Allow/Deny.
36
+ getNoQuarter?: () => boolean;
28
37
  }
29
38
 
30
39
  // The permission gate used by the live TUI. It first applies the mode/allowlist
@@ -43,6 +52,12 @@ export class ModeGate implements PermissionGate {
43
52
  // remembered — we don't let a remote operator mutate local allowlist/mode.
44
53
  if (this.deps.getRemote?.()) {
45
54
  if (auto === "deny") return "deny";
55
+ // No-quarter: re-evaluate as if in bypass mode. Dangerous/destructive
56
+ // actions still come back "ask" (they sit above bypass) and fall through
57
+ // to the relayed prompt; everything else runs unattended.
58
+ if (this.deps.getNoQuarter?.() && decideAuto(req, "bypass", this.deps.allowlist, denylist) === "allow") {
59
+ return "allow";
60
+ }
46
61
  return (await this.deps.ask(req)) === "deny" ? "deny" : "allow";
47
62
  }
48
63
 
@@ -0,0 +1,170 @@
1
+ // The `privateer` account provider — run inference billed to the user's Privateer
2
+ // subscription instead of a BYO key. This is the one provider the plan flags as a
3
+ // genuine code-blocker (Appendix A): it can't be a static key — it uses a rotating,
4
+ // 401-refreshing child session against the account channel (`${server}/api/agent/v1`).
5
+ //
6
+ // We register it via Pi's OAuth path (ProviderConfigInput.oauth). Pi drives the
7
+ // credential lifecycle: it calls getApiKey(cred) per request and refreshToken(cred)
8
+ // when Date.now() >= cred.expires. We map a per-terminal child session onto that
9
+ // shape (see auth/privateer.ts spawn/refreshAccountCredentials). Because the machine
10
+ // login already exists (~/.privateer/credentials.json), login() just spawns a child;
11
+ // only a first-ever machine login runs the device-code flow.
12
+
13
+ import {
14
+ serverBaseUrl,
15
+ hasCredentials,
16
+ runDeviceLogin,
17
+ authedFetch,
18
+ spawnAccountCredentials,
19
+ refreshAccountCredentials,
20
+ } from "../auth/privateer.ts";
21
+ import { interpretReport, teePosture, type PrivacyTier } from "pi-privacy";
22
+
23
+ // Seed/fallback catalog: registered synchronously so the account provider has real
24
+ // models the instant it loads (before the live /api/models fetch resolves) — in
25
+ // particular the signed-in default, near/zai-org/GLM-5.1-FP8, resolves at startup
26
+ // without a "model not found" warning. The first entry is that default: a NEAR
27
+ // confidential-compute (TEE, attestable) model — the strongest privacy tier. Also the
28
+ // fallback list if the live listing can't be reached.
29
+ const DEFAULT_MODELS = [
30
+ "near/zai-org/GLM-5.1-FP8",
31
+ "anthropic/claude-sonnet-4.6",
32
+ "openai/gpt-5.5",
33
+ "deepseek/deepseek-v4-flash",
34
+ ];
35
+
36
+ function seedModel(id: string) {
37
+ return {
38
+ id,
39
+ name: id,
40
+ reasoning: false,
41
+ input: ["text"] as ("text" | "image")[],
42
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
43
+ contextWindow: 128000,
44
+ maxTokens: 16384,
45
+ };
46
+ }
47
+
48
+ // Fetch the account channel's enabled model catalog. `GET /api/models` is the server's
49
+ // public list of billable models (`{ models: [{ modelId }] }`) — the same set the app
50
+ // shows. (The `/api/agent/v1` base only implements chat/completions, no /models route.)
51
+ // Falls back to DEFAULT_MODELS on any failure.
52
+ export async function fetchAccountModels(): Promise<string[]> {
53
+ try {
54
+ const res = await fetch(`${serverBaseUrl()}/api/models`);
55
+ if (!res.ok) return DEFAULT_MODELS;
56
+ const data = (await res.json()) as { models?: { modelId?: string }[] };
57
+ const ids = (data.models ?? []).map((m) => m.modelId).filter((x): x is string => !!x);
58
+ return ids.length ? ids : DEFAULT_MODELS;
59
+ } catch {
60
+ return DEFAULT_MODELS;
61
+ }
62
+ }
63
+
64
+ // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
65
+ // the provider name). login/refreshToken/getApiKey are the whole contract.
66
+ export const privateerOAuthProvider = {
67
+ name: "Privateer account",
68
+ usesCallbackServer: false,
69
+ async login(cb: { onDeviceCode?: (info: unknown) => void }) {
70
+ if (!hasCredentials()) {
71
+ await runDeviceLogin({
72
+ onCode: (code) =>
73
+ cb.onDeviceCode?.({
74
+ userCode: code.user_code,
75
+ verificationUri: code.verification_uri_complete ?? code.verification_uri ?? "",
76
+ intervalSeconds: code.interval,
77
+ expiresInSeconds: code.expires_in,
78
+ }),
79
+ });
80
+ }
81
+ return spawnAccountCredentials();
82
+ },
83
+ async refreshToken(creds: { refresh: string }) {
84
+ try {
85
+ return await refreshAccountCredentials(creds.refresh);
86
+ } catch {
87
+ // child token expired/reused → spawn a fresh one from the parent login.
88
+ return spawnAccountCredentials();
89
+ }
90
+ },
91
+ getApiKey(creds: { access: string }): string {
92
+ return creds.access;
93
+ },
94
+ };
95
+
96
+ // Which privacy channel an account model routes through: NEAR confidential-compute
97
+ // (TEE, attestable) for `near/`-prefixed ids, else a server-side ZDR channel.
98
+ // Ported from tree-cli resolve.ts.
99
+ export function privateerChannel(modelId: string): "tee" | "zdr" {
100
+ return modelId.startsWith("near/") ? "tee" : "zdr";
101
+ }
102
+
103
+ export interface AccountPosture {
104
+ tier: PrivacyTier;
105
+ teePosture?: "green" | "yellow" | "red";
106
+ error?: string;
107
+ }
108
+
109
+ // Posture for an account-channel model. For NEAR models the attestation is fetched
110
+ // through the SERVER proxy (the account's NEAR key stays server-side): the server
111
+ // mints the nonce AND returns the report, so the CLIENT contributes no freshness and
112
+ // can't bind the report to the live TLS key. That is a trust-the-server posture, not
113
+ // client-verified — materially weaker than the direct nearai path (which supplies its
114
+ // own randomNonce). So we must NOT promote it to `tee-verified`: that tier reads as
115
+ // cryptographically proven and, critically, disables pi-privacy's PII gate. We cap a
116
+ // green server posture at `tee-unverified` (honest yellow — the PII gate stays on) and
117
+ // still surface the raw teePosture for display. ZDR-channel models route to
118
+ // zero-retention endpoints server-side, which we can't observe here — a policy claim.
119
+ export async function accountPosture(modelId: string): Promise<AccountPosture> {
120
+ if (privateerChannel(modelId) === "zdr") {
121
+ return { tier: "zdr-policy" };
122
+ }
123
+ try {
124
+ const res = await authedFetch(
125
+ `${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
126
+ );
127
+ if (!res.ok) return { tier: "tee-unverified", error: `HTTP ${res.status}` };
128
+ const data = (await res.json()) as { nonce?: string; report?: unknown };
129
+ const att = interpretReport(modelId, data.nonce ?? "", data.report ?? {});
130
+ const tp = teePosture(att);
131
+ // Cap at client-unverifiable: server-proxied green never earns `tee-verified`.
132
+ const tier: PrivacyTier = tp === "red" ? "standard" : "tee-unverified";
133
+ return { tier, teePosture: tp };
134
+ } catch (e) {
135
+ return { tier: "tee-unverified", error: (e as Error).message };
136
+ }
137
+ }
138
+
139
+ // Extension factory: registers the account provider when a machine login exists. No
140
+ // login → nothing registered (BYO-key only).
141
+ //
142
+ // Registration is SYNCHRONOUS (seeded with the fallback), then refined once the live
143
+ // catalog is fetched. This matters: Pi flushes provider registrations made during the
144
+ // synchronous extension-init pass before it binds extensions, so the `privateer`
145
+ // provider (and its OAuth login path) exist immediately — before the model picker can
146
+ // open. If we instead awaited the network fetch first, the registration could land
147
+ // after the picker built its list, and privateer models would be missing until reopen.
148
+ // registerProvider replaces the provider's models on the second call, and the picker's
149
+ // refresh() re-applies registered providers, so the full list appears once fetched.
150
+ export function makeAccountProvider() {
151
+ return (pi: {
152
+ registerProvider?: (name: string, config: unknown) => void;
153
+ }): void => {
154
+ if (!hasCredentials() || typeof pi.registerProvider !== "function") return;
155
+ const register = (ids: string[]): void =>
156
+ pi.registerProvider!("privateer", {
157
+ name: "Privateer account",
158
+ baseUrl: `${serverBaseUrl()}/api/agent/v1`,
159
+ api: "openai-completions",
160
+ oauth: privateerOAuthProvider,
161
+ models: ids.map(seedModel),
162
+ });
163
+ register(DEFAULT_MODELS); // immediate: provider exists this tick
164
+ void fetchAccountModels()
165
+ .then((ids) => ids.length && register(ids)) // refine to the live catalog
166
+ .catch(() => {
167
+ /* keep the fallback model */
168
+ });
169
+ };
170
+ }
@@ -1,66 +1,78 @@
1
- import { KNOWN_PROVIDERS, type ProviderName } from "../config/schema.ts";
2
- import { providerRequiresKey } from "./registry.ts";
1
+ // The provider landscape the single map of how each of privateer's 21 providers
2
+ // becomes reachable under Pi. Written after verifying (2026-07-07) that:
3
+ // - pi-ai ships STATIC model catalogs for its 14 built-in providers, so they're
4
+ // selectable once a key is present — privateer emits NO models.json entry for them;
5
+ // - the `pi-privacy` extension registers the 5 privacy providers (tinfoil, nearai,
6
+ // venice, ollama, custom) at load — privateer emits NO entry for them either;
7
+ // - so the ONLY provider this generator must emit is `qwen` (config-only,
8
+ // non-privacy, no built-in catalog), and `privateer` (the account OAuth channel)
9
+ // is handled in code, not config (Phase 4).
10
+ //
11
+ // The plan (Appendix A) assumed we'd generate the whole models.json; Pi's built-in
12
+ // catalogs + pi-privacy made most of that redundant. This map keeps the accounting
13
+ // explicit so nothing silently falls through.
3
14
 
4
- // Human-facing metadata for each provider, used by the onboarding flow: a display
5
- // label, where to get an API key, and the model to default to when the provider is
6
- // chosen first. Keeps the registry (wiring) separate from presentation.
7
- export interface ProviderMeta {
8
- name: ProviderName;
9
- label: string;
10
- requiresKey: boolean;
11
- defaultModel: string; // "provider:model" picked when this provider is selected first
12
- keyHint: string; // where to obtain a key, or a note for keyless providers
13
- baseURLDefault?: string; // shown as the placeholder for keyless/local providers
15
+ export type ProviderSource =
16
+ | "pi-builtin" // pi-ai ships the provider + its model catalog (needs only a key)
17
+ | "pi-privacy" // registered by the pi-privacy extension at load
18
+ | "generate" // privateer must emit a models.json entry (config-only, no built-in)
19
+ | "account"; // the privateer account channel — OAuth in code, not config
20
+
21
+ export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages";
22
+
23
+ export interface ProviderEntry {
24
+ id: string;
25
+ source: ProviderSource;
26
+ // For `generate` providers: the config a models.json entry needs.
27
+ baseUrl?: string;
28
+ api?: ProviderApi;
29
+ keyEnv?: string; // env template ${...}; Pi resolves it (or auth.json wins)
30
+ compat?: Record<string, unknown>;
31
+ seedModels?: string[]; // provisional ids until live listing refines them
32
+ // Optional nuance override for a built-in (api/compat differs from pi-ai default).
33
+ // Not emitted yet — overriding a built-in can drop its model catalog; revisit once
34
+ // Pi's override-merge semantics are confirmed. Documented here so it's not lost.
35
+ overrideNote?: string;
14
36
  }
15
37
 
16
- export const PROVIDER_META: Record<ProviderName, ProviderMeta> = {
17
- anthropic: {
18
- name: "anthropic",
19
- label: "Anthropic",
20
- requiresKey: providerRequiresKey("anthropic"),
21
- defaultModel: "anthropic:claude-opus-4-8",
22
- keyHint: "console.anthropic.com/settings/keys",
23
- },
24
- openai: {
25
- name: "openai",
26
- label: "OpenAI",
27
- requiresKey: providerRequiresKey("openai"),
28
- defaultModel: "openai:gpt-4o",
29
- keyHint: "platform.openai.com/api-keys",
30
- },
31
- openrouter: {
32
- name: "openrouter",
33
- label: "OpenRouter",
34
- requiresKey: providerRequiresKey("openrouter"),
35
- defaultModel: "openrouter:anthropic/claude-opus-4.8",
36
- keyHint: "openrouter.ai/keys",
37
- },
38
- ollama: {
39
- name: "ollama",
40
- label: "Ollama (local)",
41
- requiresKey: providerRequiresKey("ollama"),
42
- defaultModel: "ollama:llama3.1",
43
- keyHint: "runs locally — no key needed",
44
- baseURLDefault: "http://localhost:11434/api",
38
+ // Ordered like KNOWN_PROVIDERS (tree-cli schema.ts) for a stable picker order.
39
+ export const PROVIDERS: ProviderEntry[] = [
40
+ { id: "openrouter", source: "pi-builtin", overrideNote: "ZDR routing handled by pi-privacy" },
41
+ { id: "anthropic", source: "pi-builtin" },
42
+ { id: "openai", source: "pi-builtin", overrideNote: "0.2 used chat-completions, not responses" },
43
+ { id: "google", source: "pi-builtin" },
44
+ { id: "xai", source: "pi-builtin" },
45
+ { id: "groq", source: "pi-builtin" },
46
+ { id: "mistral", source: "pi-builtin" },
47
+ { id: "zai", source: "pi-builtin", overrideNote: "0.2 baseUrl …/api/paas/v4; compat.thinkingFormat zai" },
48
+ { id: "moonshot", source: "pi-builtin" },
49
+ { id: "cerebras", source: "pi-builtin" },
50
+ { id: "fireworks", source: "pi-builtin" },
51
+ { id: "together", source: "pi-builtin", overrideNote: "compat.thinkingFormat together" },
52
+ { id: "deepseek", source: "pi-builtin", overrideNote: "compat.thinkingFormat deepseek" },
53
+ { id: "minimax", source: "pi-builtin", overrideNote: "0.2 used …/v1 openai-compat, not anthropic-messages" },
54
+ {
55
+ id: "qwen",
56
+ source: "generate",
57
+ baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
58
+ api: "openai-completions",
59
+ keyEnv: "${DASHSCOPE_API_KEY}",
60
+ compat: { thinkingFormat: "qwen" },
61
+ seedModels: ["qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
45
62
  },
46
- nearai: {
47
- name: "nearai",
48
- label: "NEAR AI (private TEE inference)",
49
- requiresKey: providerRequiresKey("nearai"),
50
- defaultModel: "nearai:zai-org/GLM-5.1-FP8",
51
- keyHint: "cloud.near.ai API Keys",
52
- },
53
- privateer: {
54
- name: "privateer",
55
- // Default to a NEAR confidential-compute (TEE) model: it's the strongest
56
- // privacy guarantee, runs through the same billed agent endpoint, and was
57
- // verified to pass agent tool_calls. Switch to any listed model with /model.
58
- label: "Privateer account (billed to your subscription)",
59
- requiresKey: providerRequiresKey("privateer"),
60
- defaultModel: "privateer:near/deepseek-ai/DeepSeek-V4-Flash",
61
- keyHint: "sign in with /login — no API key needed",
62
- },
63
- };
63
+ { id: "ollama", source: "pi-privacy" },
64
+ { id: "nearai", source: "pi-privacy" },
65
+ { id: "tinfoil", source: "pi-privacy" },
66
+ { id: "venice", source: "pi-privacy" },
67
+ { id: "custom", source: "pi-privacy" },
68
+ { id: "privateer", source: "account" },
69
+ ];
64
70
 
65
- // Provider metadata in display order.
66
- export const PROVIDER_LIST: ProviderMeta[] = KNOWN_PROVIDERS.map((n) => PROVIDER_META[n]);
71
+ export const PROVIDER_BY_ID: Record<string, ProviderEntry> = Object.fromEntries(
72
+ PROVIDERS.map((p) => [p.id, p]),
73
+ );
74
+
75
+ // Providers this generator is responsible for emitting into models.json.
76
+ export function providersToGenerate(): ProviderEntry[] {
77
+ return PROVIDERS.filter((p) => p.source === "generate");
78
+ }
@@ -0,0 +1,97 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { agentDir } from "../config/paths.ts";
4
+ import { providersToGenerate, type ProviderEntry } from "./catalog.ts";
5
+
6
+ // Emit models.json entries for the config-only providers Pi doesn't ship and
7
+ // pi-privacy doesn't register (today: just `qwen`). Built-ins come from pi-ai's
8
+ // static catalogs; privacy providers from the pi-privacy extension; the privateer
9
+ // account channel from code. See catalog.ts for the full accounting.
10
+ //
11
+ // Model metadata defaults follow the migration plan Appendix A.4 (used until a live
12
+ // listing refines them): contextWindow 128000, maxTokens 16384, zero cost, text-only.
13
+
14
+ export interface ModelsJsonModel {
15
+ id: string;
16
+ name: string;
17
+ reasoning: boolean;
18
+ input: ("text" | "image")[];
19
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
20
+ contextWindow: number;
21
+ maxTokens: number;
22
+ compat?: Record<string, unknown>;
23
+ }
24
+
25
+ export interface ModelsJsonProvider {
26
+ name: string;
27
+ baseUrl: string;
28
+ api: string;
29
+ apiKey?: string;
30
+ authHeader?: boolean;
31
+ compat?: Record<string, unknown>;
32
+ models: ModelsJsonModel[];
33
+ }
34
+
35
+ export interface ModelsJson {
36
+ providers: Record<string, ModelsJsonProvider>;
37
+ }
38
+
39
+ function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonModel {
40
+ return {
41
+ id,
42
+ name: id,
43
+ reasoning: false,
44
+ input: ["text"],
45
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
46
+ contextWindow: 128000,
47
+ maxTokens: 16384,
48
+ ...(compat ? { compat } : {}),
49
+ };
50
+ }
51
+
52
+ function providerConfig(p: ProviderEntry): ModelsJsonProvider {
53
+ return {
54
+ name: p.id,
55
+ baseUrl: p.baseUrl!,
56
+ api: p.api ?? "openai-completions",
57
+ // Env-template key (Pi resolves ${...}; auth.json takes precedence when present).
58
+ // Never a literal key — and never emitted for the account/venice providers.
59
+ ...(p.keyEnv ? { apiKey: p.keyEnv, authHeader: true } : {}),
60
+ ...(p.compat ? { compat: p.compat } : {}),
61
+ models: (p.seedModels ?? []).map((id) => seedModel(id, p.compat)),
62
+ };
63
+ }
64
+
65
+ // Build the models.json object for all `generate`-sourced providers.
66
+ export function generateModelsJson(providers: ProviderEntry[] = providersToGenerate()): ModelsJson {
67
+ const out: ModelsJson = { providers: {} };
68
+ for (const p of providers) {
69
+ if (p.source !== "generate" || !p.baseUrl) continue;
70
+ out.providers[p.id] = providerConfig(p);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // Merge generated providers into an existing models.json WITHOUT clobbering
76
+ // user-added or extension-registered providers. Generated ids are refreshed; every
77
+ // other provider in the file is preserved. Writes to $PRIVATEER_HOME/agent/models.json
78
+ // by default. Returns the merged object.
79
+ export function writeModelsJson(
80
+ generated: ModelsJson = generateModelsJson(),
81
+ path: string = join(agentDir(), "models.json"),
82
+ ): ModelsJson {
83
+ let existing: ModelsJson = { providers: {} };
84
+ if (existsSync(path)) {
85
+ try {
86
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
87
+ if (parsed && typeof parsed === "object" && parsed.providers) existing = parsed;
88
+ } catch {
89
+ // Unreadable/corrupt file → treat as empty rather than throwing; we only add
90
+ // our generated providers and never drop the user's.
91
+ }
92
+ }
93
+ const merged: ModelsJson = { providers: { ...existing.providers, ...generated.providers } };
94
+ mkdirSync(dirname(path), { recursive: true });
95
+ writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
96
+ return merged;
97
+ }
@@ -61,6 +61,10 @@ export interface RelayCallbacks {
61
61
  onTerminate?: () => void;
62
62
  // The app answered a relayed approval request.
63
63
  onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
64
+ // The app toggled no-quarter (unattended) mode: remote turns auto-approve like
65
+ // bypass mode instead of relaying every action, so the agent runs to completion.
66
+ // Dangerous/destructive actions still relay — they sit above bypass locally too.
67
+ onNoQuarter?: (on: boolean) => void;
64
68
  // A controller attached — push a transcript snapshot so it can catch up.
65
69
  onControllerAttached: () => void;
66
70
  // A file finished transferring from the app (reassembled from chunks). Held to
@@ -250,6 +254,7 @@ export class RelayClient {
250
254
  size?: number;
251
255
  seq?: number;
252
256
  data?: string;
257
+ on?: boolean;
253
258
  };
254
259
  try {
255
260
  frame = JSON.parse(data.toString());
@@ -273,6 +278,9 @@ export class RelayClient {
273
278
  case "approval_response":
274
279
  if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
275
280
  break;
281
+ case "no_quarter":
282
+ this.cb.onNoQuarter?.(frame.on === true);
283
+ break;
276
284
  case "controller_attached":
277
285
  this.cb.onControllerAttached();
278
286
  break;
@@ -407,6 +415,13 @@ export class RelayClient {
407
415
  return { ok: true };
408
416
  }
409
417
 
418
+ // Echo the no-quarter (unattended auto-approve) state up to the controller so
419
+ // its toggle reflects what this terminal is actually doing. Sent on every
420
+ // change (ack) and on controller attach (a re-attaching app resyncs).
421
+ sendNoQuarter(on: boolean): void {
422
+ this.rawSend({ type: "no_quarter", on });
423
+ }
424
+
410
425
  requestApproval(id: string, req: PermissionRequest): void {
411
426
  this.rawSend({
412
427
  type: "approval_request",
@@ -0,0 +1,152 @@
1
+ // The wiring between the relay and the rest of the agent — NEW code for the Pi
2
+ // rewrite. It connects three things that were built to meet here:
3
+ // - the Phase-1 adapter's EngineEvents → up to the app (relay.sendEvent)
4
+ // - the Phase-2 gate's remote branch → each tool relayed to the app for
5
+ // allow/deny via `remoteAsk` + `getRemote` (the slots makePermissionGate stubbed)
6
+ // - the app's prompts/interrupts → down into the turn loop (cfg callbacks)
7
+ //
8
+ // The RelayClient itself is KEEP (verbatim 0.2); this bridge is the only new part,
9
+ // so it's what the tests exercise (against a fake relay). Fail-closed throughout:
10
+ // no controller, a disconnect, or an aborted turn all resolve a pending approval
11
+ // to "deny".
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import type { EngineEvent } from "../engine/events.ts";
15
+ import type { PermissionRequest } from "../permissions/gate.ts";
16
+ import type { AskOutcome } from "../permissions/modeGate.ts";
17
+ import type { RelayCallbacks } from "./relayClient.ts";
18
+
19
+ // The outbound surface the bridge needs; RelayClient implements all of it.
20
+ export interface RelayLike {
21
+ requestApproval(id: string, req: PermissionRequest): void;
22
+ sendEvent(ev: EngineEvent): void;
23
+ isConnected(): boolean;
24
+ sendNoQuarter(on: boolean): void;
25
+ sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
26
+ }
27
+
28
+ export interface RemoteAttachment {
29
+ name: string;
30
+ mediaType: string;
31
+ base64: string;
32
+ }
33
+
34
+ export interface RemoteBridgeConfig {
35
+ // A prompt arrived from the app — drive the turn loop (tagged remote). Any files
36
+ // the app sent ahead of the prompt ride along.
37
+ onPrompt: (text: string, attachments: RemoteAttachment[]) => void;
38
+ onInterrupt?: () => void;
39
+ onTerminate?: () => void;
40
+ // A controller (re)attached — the owner should push a transcript snapshot.
41
+ onControllerAttached?: () => void;
42
+ onStatus?: (text: string) => void;
43
+ // A file finished transferring down from the app. The owner registers it (e.g. into
44
+ // an AttachmentStore) so the save_attachment tool can persist it.
45
+ onAttachment?: (file: RemoteAttachment) => void;
46
+ }
47
+
48
+ export class RemoteBridge {
49
+ private relay?: RelayLike;
50
+ private remote = false;
51
+ private noQuarter = false;
52
+ private readonly pending = new Map<string, (d: AskOutcome) => void>();
53
+ private pendingAttachments: RemoteAttachment[] = [];
54
+
55
+ constructor(private readonly cfg: RemoteBridgeConfig) {}
56
+
57
+ // Wire the outbound relay once it's constructed (RelayClient needs `callbacks`
58
+ // at construction, so the relay is attached right after).
59
+ attachRelay(relay: RelayLike): void {
60
+ this.relay = relay;
61
+ }
62
+
63
+ // Hand this to `new RelayClient(bridge.callbacks)`. Typed Required so every hook
64
+ // (including the ones RelayCallbacks marks optional) is defined — the bridge wires
65
+ // them all.
66
+ readonly callbacks: Required<RelayCallbacks> = {
67
+ onPrompt: (text) => {
68
+ this.remote = true; // a remote turn is now in flight → gate relays each action
69
+ const attachments = this.pendingAttachments;
70
+ this.pendingAttachments = [];
71
+ this.cfg.onPrompt(text, attachments);
72
+ },
73
+ onInterrupt: () => this.cfg.onInterrupt?.(),
74
+ onTerminate: () => this.cfg.onTerminate?.(),
75
+ onApprovalResponse: (id, decision) => {
76
+ const resolve = this.pending.get(id);
77
+ if (resolve) resolve(decision);
78
+ },
79
+ onNoQuarter: (on) => {
80
+ this.noQuarter = on;
81
+ this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
82
+ },
83
+ onControllerAttached: () => this.cfg.onControllerAttached?.(),
84
+ onAttachment: (file) => {
85
+ this.pendingAttachments.push(file);
86
+ this.cfg.onAttachment?.(file);
87
+ },
88
+ onStatus: (text) => this.cfg.onStatus?.(text),
89
+ onDisconnected: () => {
90
+ this.remote = false;
91
+ // Any approval waiting on a now-gone controller fails closed.
92
+ this.rejectAllPending();
93
+ },
94
+ };
95
+
96
+ // ── gate hooks (passed into the GateController) ─────────────────────────────
97
+
98
+ getRemote = (): boolean => this.remote;
99
+ getNoQuarter = (): boolean => this.noQuarter;
100
+
101
+ // The gate's remote approver: relay the request to the app and await its
102
+ // allow/deny. Fail closed if no controller, on abort, or on disconnect. (The gate
103
+ // also wraps this in its own timeout, so a silent app can't wedge the turn.)
104
+ remoteAsk = (req: PermissionRequest, signal?: AbortSignal): Promise<AskOutcome> => {
105
+ if (!this.relay || !this.relay.isConnected()) return Promise.resolve("deny");
106
+ const id = randomUUID();
107
+ return new Promise<AskOutcome>((resolve) => {
108
+ const onAbort = () => settle("deny");
109
+ const settle = (d: AskOutcome) => {
110
+ this.pending.delete(id);
111
+ signal?.removeEventListener("abort", onAbort);
112
+ resolve(d);
113
+ };
114
+ this.pending.set(id, settle);
115
+ if (signal) {
116
+ if (signal.aborted) return onAbort();
117
+ signal.addEventListener("abort", onAbort, { once: true });
118
+ }
119
+ this.relay!.requestApproval(id, req);
120
+ });
121
+ };
122
+
123
+ // ── turn lifecycle + event forwarding ───────────────────────────────────────
124
+
125
+ // Mark the end of a turn so the next (possibly local) turn isn't treated as
126
+ // remote. Call after each driven turn completes.
127
+ settleTurn(): void {
128
+ this.remote = false;
129
+ }
130
+
131
+ // Forward an EngineEvent up to the app. Safe to call for every event of every
132
+ // turn (local included) — the relay only sends when a socket is open.
133
+ forwardEvent(ev: EngineEvent): void {
134
+ this.relay?.sendEvent(ev);
135
+ }
136
+
137
+ // Is a controller actually reachable right now? (Relay socket open.)
138
+ isConnected(): boolean {
139
+ return !!this.relay?.isConnected();
140
+ }
141
+
142
+ // Stream a file up to the connected app (the send_file_to_client tool).
143
+ async sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }> {
144
+ if (!this.relay) return { ok: false, reason: "remote access is not enabled" };
145
+ return this.relay.sendFile(file);
146
+ }
147
+
148
+ private rejectAllPending(): void {
149
+ for (const resolve of this.pending.values()) resolve("deny");
150
+ this.pending.clear();
151
+ }
152
+ }