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.
- package/README.md +135 -443
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { dirname } from "node:path";
|
|
2
|
-
import type { PermissionMode } from "../config/
|
|
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
|
|
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
|
+
}
|
package/src/providers/catalog.ts
CHANGED
|
@@ -1,66 +1,78 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
+
}
|