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,73 +0,0 @@
1
- // Pure logic behind the prompt input: mode detection and autocomplete matching.
2
- // Kept free of Ink so it can be unit-tested directly.
3
-
4
- export type InputMode = "bash" | "memory" | "command" | "prompt";
5
-
6
- // A leading "/" only starts a slash command when the first word looks like a
7
- // command name — no path separators or dots. This lets absolute file paths like
8
- // "/Users/me/shot.png" be typed as a normal prompt (and attached as an image)
9
- // instead of being mistaken for a command. A bare "/" still opens the menu.
10
- export function isSlashCommand(value: string): boolean {
11
- if (value[0] !== "/") return false;
12
- const firstWord = value.slice(1).split(/\s/, 1)[0];
13
- // Command names are strictly alphanumeric (with - and _); anything else in the
14
- // first word — a "/", ".", "\", etc. — means it's a path, not a command.
15
- return firstWord.length === 0 || /^[A-Za-z0-9_-]+$/.test(firstWord);
16
- }
17
-
18
- // What the leading character of the buffer means. `!` shells out, `#` appends to
19
- // project memory, `/` runs a slash command, anything else is a model prompt.
20
- export function detectMode(value: string): InputMode {
21
- if (value[0] === "!") return "bash";
22
- if (value[0] === "#") return "memory";
23
- if (isSlashCommand(value)) return "command";
24
- return "prompt";
25
- }
26
-
27
- // The command-name fragment being typed, when the cursor sits within the first
28
- // word of a leading "/command". Null once the user moves on to typing arguments.
29
- export function slashQuery(value: string, cursor: number): string | null {
30
- if (!isSlashCommand(value)) return null;
31
- const firstSpace = value.indexOf(" ");
32
- const nameEnd = firstSpace === -1 ? value.length : firstSpace;
33
- if (cursor > nameEnd) return null;
34
- return value.slice(1, nameEnd);
35
- }
36
-
37
- // The @-mention token under the cursor: the '@' index plus the text typed after
38
- // it. Requires the '@' to start a word (so emails like a@b don't trigger it).
39
- export function mentionAt(value: string, cursor: number): { start: number; query: string } | null {
40
- let i = cursor;
41
- while (i > 0 && !/\s/.test(value[i - 1])) i--;
42
- if (value[i] !== "@") return null;
43
- return { start: i, query: value.slice(i + 1, cursor) };
44
- }
45
-
46
- export interface CommandItem {
47
- name: string;
48
- summary: string;
49
- }
50
-
51
- export function filterCommands(all: CommandItem[], query: string): CommandItem[] {
52
- const q = query.toLowerCase();
53
- return all.filter((c) => c.name.toLowerCase().startsWith(q));
54
- }
55
-
56
- // Substring file match, ranked: basename hits beat path hits, earlier and shorter
57
- // paths win. Keeps the menu small and relevant on large trees.
58
- export function filterFiles(all: string[], query: string, limit: number): string[] {
59
- const q = query.toLowerCase();
60
- if (!q) return all.slice(0, limit);
61
- const scored: { f: string; score: number }[] = [];
62
- for (const f of all) {
63
- const lower = f.toLowerCase();
64
- const idx = lower.indexOf(q);
65
- if (idx === -1) continue;
66
- const base = lower.slice(lower.lastIndexOf("/") + 1);
67
- const baseIdx = base.indexOf(q);
68
- const score = (baseIdx === -1 ? 1000 : baseIdx) + idx * 0.01 + f.length * 0.001;
69
- scored.push({ f, score });
70
- }
71
- scored.sort((a, b) => a.score - b.score);
72
- return scored.slice(0, limit).map((s) => s.f);
73
- }
@@ -1,46 +0,0 @@
1
- // A set of gerund "thinking" verbs shown beside the spinner while a turn runs.
2
- // One is chosen at random per turn.
3
- const VERBS = [
4
- "Cogitating",
5
- "Pondering",
6
- "Brewing",
7
- "Conjuring",
8
- "Forging",
9
- "Wrangling",
10
- "Synthesizing",
11
- "Percolating",
12
- "Ruminating",
13
- "Noodling",
14
- "Computing",
15
- "Crafting",
16
- "Composing",
17
- "Deliberating",
18
- "Tinkering",
19
- "Hatching",
20
- "Incubating",
21
- "Marinating",
22
- "Musing",
23
- "Orchestrating",
24
- "Puzzling",
25
- "Scheming",
26
- "Simmering",
27
- "Spelunking",
28
- "Stewing",
29
- "Summoning",
30
- "Unfurling",
31
- "Whirring",
32
- "Working",
33
- "Wibbling",
34
- "Manifesting",
35
- "Channeling",
36
- "Concocting",
37
- "Crunching",
38
- "Divining",
39
- "Finagling",
40
- "Mulling",
41
- "Plotting",
42
- "Reticulating",
43
- "Transmuting",
44
- ];
45
-
46
- export const randomVerb = (): string => VERBS[Math.floor(Math.random() * VERBS.length)];
@@ -1,57 +0,0 @@
1
- import type { PermissionMode } from "../config/schema.ts";
2
- import type { ZdrPosture } from "../providers/models.ts";
3
-
4
- // Single source of truth for TUI color. Privateer's navy/white identity drives the
5
- // accent: it marks bullets, the prompt prefix, headings, and the active permission
6
- // mode. Text is the terminal default (white on dark); metadata is dimmed gray.
7
- //
8
- // Note on navy: a true navy (#1e3a5f) is too dark to read on dark terminals, so the
9
- // accent is a brighter navy/indigo. It's one knob — tune it here and the whole UI
10
- // follows.
11
- export const theme = {
12
- accent: "#5c7cfa", // Privateer navy/indigo — the single accent hue
13
- accentDim: "#3b5b8c",
14
- text: undefined as string | undefined, // terminal default
15
- dim: "gray",
16
- success: "green",
17
- error: "red",
18
- warning: "yellow",
19
- diffAdded: "green",
20
- diffRemoved: "red",
21
- } as const;
22
-
23
- // Permission-mode accent colors (moved here from StatusBar so color lives in one place).
24
- export const MODE_COLOR: Record<PermissionMode, string> = {
25
- default: theme.warning,
26
- acceptEdits: theme.success,
27
- bypass: theme.error,
28
- plan: theme.accent,
29
- };
30
-
31
- // OpenRouter ZDR posture colors — green enforced, yellow available, red retained.
32
- // Shared by the status-bar shield and the per-model badges in the picker.
33
- export const POSTURE_COLOR: Record<ZdrPosture, string> = {
34
- green: theme.success,
35
- yellow: theme.warning,
36
- red: theme.error,
37
- };
38
-
39
- // Capitalized tool display names: read → Read, web_fetch → WebFetch.
40
- const TOOL_DISPLAY: Record<string, string> = {
41
- read: "Read",
42
- write: "Write",
43
- edit: "Edit",
44
- glob: "Glob",
45
- grep: "Grep",
46
- bash: "Bash",
47
- bash_output: "BashOutput",
48
- kill_shell: "KillShell",
49
- todo: "TodoWrite",
50
- task: "Task",
51
- web_fetch: "WebFetch",
52
- web_search: "WebSearch",
53
- ask_user: "AskUser",
54
- worktree: "Worktree",
55
- };
56
-
57
- export const toolDisplayName = (name: string): string => TOOL_DISPLAY[name] ?? name;
@@ -1,34 +0,0 @@
1
- // One rendered line/block in the conversation transcript.
2
- export type ToolStatus = "running" | "done" | "error";
3
-
4
- export interface ToolEntry {
5
- kind: "tool";
6
- id: string;
7
- name: string;
8
- input: unknown;
9
- status: ToolStatus;
10
- output?: string;
11
- error?: string;
12
- // Set only on `task` calls: the sub-agent's short description, its type (undefined =
13
- // the default read-only explorer), and run metrics filled in when it finishes. Drives
14
- // the grouped "N agents finished" rendering.
15
- agent?: { description: string; subagentType?: string; toolUses?: number; tokens?: number };
16
- }
17
-
18
- export type Entry =
19
- | { kind: "user"; text: string }
20
- | { kind: "assistant"; text: string }
21
- | { kind: "thinking"; text: string }
22
- | ToolEntry
23
- // `hint` is an optional actionable line rendered dim beneath the notice
24
- // (used by error notices to suggest a next step).
25
- | { kind: "notice"; text: string; tone?: "info" | "error"; hint?: string };
26
-
27
- // A render-time row: either a single transcript entry, or a run of two-or-more
28
- // concurrent `task` sub-agents collapsed into one grouped block. Grouping happens at
29
- // render time (see groupRows) so the underlying transcript stays a flat entry list.
30
- export interface AgentGroupRow {
31
- kind: "agent-group";
32
- agents: ToolEntry[];
33
- }
34
- export type Row = Entry | AgentGroupRow;
@@ -1,104 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import type { Config } from "../config/schema.ts";
3
- import { parseModelSpec, privateerChannel } from "../providers/resolve.ts";
4
- import {
5
- fetchAttestation,
6
- fetchAttestationViaServer,
7
- teePosture,
8
- type Attestation,
9
- type TeePosture,
10
- } from "../providers/attestation.ts";
11
-
12
- // What the status-bar shield should render for a TEE attestation. Distinct from
13
- // posture so a dim "needs a key / unknown" affordance never implies a verdict.
14
- export type TeeState =
15
- | { kind: "hidden" } // not a TEE-backed model — no badge
16
- | { kind: "no-key" } // NEAR AI (BYO key) selected but no API key to attest with
17
- | { kind: "loading" } // fetching the attestation report
18
- | { kind: "error" } // network / timeout / HTTP failure
19
- | { kind: "ready"; posture: TeePosture; attestation: Attestation };
20
-
21
- // Attestations are per-model (each model has its own enclave + signing key), so we
22
- // cache the in-flight/resolved promise per (apiKey, baseURL, modelId) and reuse it
23
- // for the session. Concurrent consumers dedupe to one fetch; a rejection is evicted
24
- // so a later selection can retry rather than being stuck in error forever.
25
- const cache = new Map<string, Promise<Attestation>>();
26
-
27
- function loadAttestation(apiKey: string, baseURL: string | undefined, modelId: string): Promise<Attestation> {
28
- const key = `${apiKey}::${baseURL ?? ""}::${modelId}`;
29
- let pending = cache.get(key);
30
- if (!pending) {
31
- pending = fetchAttestation({ apiKey, baseURL }, modelId).catch((err) => {
32
- cache.delete(key);
33
- throw err;
34
- });
35
- cache.set(key, pending);
36
- }
37
- return pending;
38
- }
39
-
40
- // Account-billed `privateer:near/*` models attest through the Privateer server
41
- // proxy (the NEAR key stays server-side), so they cache by model id alone.
42
- const serverCache = new Map<string, Promise<Attestation>>();
43
-
44
- function loadServerAttestation(modelId: string): Promise<Attestation> {
45
- let pending = serverCache.get(modelId);
46
- if (!pending) {
47
- pending = fetchAttestationViaServer(modelId).catch((err) => {
48
- serverCache.delete(modelId);
49
- throw err;
50
- });
51
- serverCache.set(modelId, pending);
52
- }
53
- return pending;
54
- }
55
-
56
- // Resolve the TEE shield state for the currently selected model. Two paths trigger
57
- // a fetch: BYO `nearai:*` models (direct gateway, needs a key) and account-billed
58
- // `privateer:near/*` models (server proxy, uses the logged-in session). Everything
59
- // else returns "hidden" before touching the network.
60
- export function useTeeShield(modelSpec: string, config: Config): TeeState {
61
- let provider = "";
62
- let modelId = "";
63
- try {
64
- ({ provider, modelId } = parseModelSpec(modelSpec));
65
- } catch {
66
- provider = "";
67
- modelId = "";
68
- }
69
- const cfg = config.providers.nearai ?? {};
70
- const isNearai = provider === "nearai";
71
- const isPrivateerTee = provider === "privateer" && privateerChannel(modelId) === "tee";
72
- const apiKey = isNearai ? cfg.apiKey : undefined;
73
- const baseURL = cfg.baseURL;
74
-
75
- const [state, setState] = useState<TeeState>({ kind: "hidden" });
76
-
77
- useEffect(() => {
78
- if (!isNearai && !isPrivateerTee) {
79
- setState({ kind: "hidden" });
80
- return;
81
- }
82
- if (isNearai && !apiKey) {
83
- setState({ kind: "no-key" });
84
- return;
85
- }
86
- let ignore = false;
87
- setState({ kind: "loading" });
88
- const pending = isPrivateerTee
89
- ? loadServerAttestation(modelId)
90
- : loadAttestation(apiKey!, baseURL, modelId);
91
- pending
92
- .then((attestation) => {
93
- if (!ignore) setState({ kind: "ready", posture: teePosture(attestation), attestation });
94
- })
95
- .catch(() => {
96
- if (!ignore) setState({ kind: "error" });
97
- });
98
- return () => {
99
- ignore = true;
100
- };
101
- }, [isNearai, isPrivateerTee, apiKey, baseURL, modelId]);
102
-
103
- return state;
104
- }
@@ -1,24 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { useStdout } from "ink";
3
-
4
- // Current terminal column count, re-read on every resize.
5
- //
6
- // The dynamic region below <Static> is erased and redrawn on each render. Ink
7
- // counts the previous frame by its newlines, so any line that soft-wraps — or
8
- // that the terminal reflows when dragged narrower — desyncs the erase and leaves
9
- // stale copies in the scrollback (the classic stack of duplicated status bars).
10
- // Subscribing to resize lets those lines re-truncate to the new width and stay a
11
- // single row, which keeps Ink's erase count correct.
12
- export function useTerminalWidth(): number {
13
- const { stdout } = useStdout();
14
- const [cols, setCols] = useState(stdout?.columns ?? 80);
15
- useEffect(() => {
16
- if (!stdout) return;
17
- const onResize = () => setCols(stdout.columns ?? 80);
18
- stdout.on("resize", onResize);
19
- return () => {
20
- stdout.off("resize", onResize);
21
- };
22
- }, [stdout]);
23
- return cols;
24
- }
@@ -1,126 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import type { Config, ProviderName } from "../config/schema.ts";
3
- import { parseModelSpec, privateerChannel } from "../providers/resolve.ts";
4
- import { fetchZdrAccount, zdrPosture, type ZdrAccountData, type ZdrPosture } from "../providers/models.ts";
5
-
6
- // What the status-bar shield should render. Distinct from posture so we can show a
7
- // dim "needs a key / unknown" affordance without ever implying a colored verdict.
8
- export type ZdrState =
9
- | { kind: "hidden" } // not a ZDR-backed model — no badge
10
- | { kind: "no-key" } // OpenRouter selected but no API key to query with
11
- | { kind: "loading" } // fetching the account snapshot
12
- | { kind: "error" } // network / timeout / HTTP failure
13
- | { kind: "ready"; posture: ZdrPosture };
14
-
15
- // The ZDR snapshot (Z, U, enforcement) is global per account, not per model, so we
16
- // fetch it once per (apiKey, baseURL) and reuse it for the whole session — switching
17
- // models re-evaluates synchronously. The cache holds the in-flight/resolved promise so
18
- // concurrent consumers dedupe to a single fetch pair; a rejection is evicted so a later
19
- // selection can retry rather than being stuck in error forever.
20
- const cache = new Map<string, Promise<ZdrAccountData>>();
21
-
22
- function loadAccount(apiKey: string, baseURL: string | undefined): Promise<ZdrAccountData> {
23
- const key = `${apiKey}::${baseURL ?? ""}`;
24
- let pending = cache.get(key);
25
- if (!pending) {
26
- pending = fetchZdrAccount({ apiKey, baseURL }).catch((err) => {
27
- cache.delete(key);
28
- throw err;
29
- });
30
- cache.set(key, pending);
31
- }
32
- return pending;
33
- }
34
-
35
- // Resolve the ZDR shield state for the currently selected model. Only OpenRouter
36
- // models trigger a fetch; everything else returns "hidden" before touching the network.
37
- export function useZdrShield(modelSpec: string, config: Config): ZdrState {
38
- let provider = "";
39
- let modelId = "";
40
- try {
41
- ({ provider, modelId } = parseModelSpec(modelSpec));
42
- } catch {
43
- provider = "";
44
- modelId = "";
45
- }
46
- const cfg = config.providers.openrouter ?? {};
47
- const isOpenRouter = provider === "openrouter";
48
- // Account-billed Privateer models that aren't NEAR/TEE route through the server's
49
- // ZDR-pinned OpenRouter proxy — zero retention is guaranteed server-side.
50
- const isPrivateerZdr = provider === "privateer" && privateerChannel(modelId) === "zdr";
51
- const apiKey = isOpenRouter ? cfg.apiKey : undefined;
52
- const baseURL = cfg.baseURL;
53
- const enforced = Boolean(cfg.enforceZdr);
54
-
55
- const [state, setState] = useState<ZdrState>({ kind: "hidden" });
56
-
57
- useEffect(() => {
58
- if (isPrivateerZdr) {
59
- // The proxy always pins ZDR endpoints, so the posture is green without a
60
- // client-side account query (there's no OpenRouter key on this side).
61
- setState({ kind: "ready", posture: "green" });
62
- return;
63
- }
64
- if (!isOpenRouter) {
65
- setState({ kind: "hidden" });
66
- return;
67
- }
68
- if (!apiKey) {
69
- setState({ kind: "no-key" });
70
- return;
71
- }
72
- let ignore = false;
73
- setState({ kind: "loading" });
74
- loadAccount(apiKey, baseURL)
75
- .then((acct) => {
76
- if (!ignore) setState({ kind: "ready", posture: zdrPosture(modelId, acct, enforced) });
77
- })
78
- .catch(() => {
79
- if (!ignore) setState({ kind: "error" });
80
- });
81
- return () => {
82
- ignore = true;
83
- };
84
- }, [isOpenRouter, isPrivateerZdr, apiKey, baseURL, modelId, enforced]);
85
-
86
- return state;
87
- }
88
-
89
- // The account snapshot itself, for callers that score many models at once (the
90
- // OpenRouter model picker renders a per-row badge). Same fetch + cache as the
91
- // shield, but it hands back the raw ZdrAccountData so the consumer can call
92
- // zdrPosture(id, account) for each model synchronously, without a fetch per row.
93
- export type ZdrAccountState =
94
- | { kind: "idle" } // not OpenRouter, or no key — no badges
95
- | { kind: "loading" }
96
- | { kind: "error" }
97
- | { kind: "ready"; account: ZdrAccountData };
98
-
99
- export function useZdrAccount(provider: ProviderName | string, config: Config): ZdrAccountState {
100
- const cfg = config.providers.openrouter ?? {};
101
- const apiKey = provider === "openrouter" ? cfg.apiKey : undefined;
102
- const baseURL = cfg.baseURL;
103
-
104
- const [state, setState] = useState<ZdrAccountState>({ kind: "idle" });
105
-
106
- useEffect(() => {
107
- if (provider !== "openrouter" || !apiKey) {
108
- setState({ kind: "idle" });
109
- return;
110
- }
111
- let ignore = false;
112
- setState({ kind: "loading" });
113
- loadAccount(apiKey, baseURL)
114
- .then((account) => {
115
- if (!ignore) setState({ kind: "ready", account });
116
- })
117
- .catch(() => {
118
- if (!ignore) setState({ kind: "error" });
119
- });
120
- return () => {
121
- ignore = true;
122
- };
123
- }, [provider, apiKey, baseURL]);
124
-
125
- return state;
126
- }
@@ -1,115 +0,0 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
2
- import { Config, type Config as ConfigT, type ProviderName } from "./schema.ts";
3
- import { globalPaths, projectPaths, managedSettingsPath } from "./paths.ts";
4
-
5
- // Back-compat re-exports: existing callers import these from here.
6
- export { globalDir } from "./paths.ts";
7
- export function globalConfigPath(): string {
8
- return globalPaths().config;
9
- }
10
- export function projectConfigPath(): string {
11
- return projectPaths().config;
12
- }
13
-
14
- function readJsonIfExists(path: string): unknown {
15
- if (!existsSync(path)) return undefined;
16
- try {
17
- return JSON.parse(readFileSync(path, "utf8"));
18
- } catch (err) {
19
- throw new Error(`Failed to parse config at ${path}: ${(err as Error).message}`);
20
- }
21
- }
22
-
23
- function isPlainObject(v: unknown): v is Record<string, unknown> {
24
- return typeof v === "object" && v !== null && !Array.isArray(v);
25
- }
26
-
27
- // Recursively merge raw config layers: objects merge per-key, everything else
28
- // (scalars, arrays) is replaced by the higher-precedence layer.
29
- function deepMerge(base: unknown, over: unknown): unknown {
30
- if (over === undefined) return base;
31
- if (!isPlainObject(base) || !isPlainObject(over)) return over;
32
- const out: Record<string, unknown> = { ...base };
33
- for (const [k, v] of Object.entries(over)) {
34
- if (v === undefined) continue;
35
- out[k] = k in out ? deepMerge(out[k], v) : v;
36
- }
37
- return out;
38
- }
39
-
40
- export interface ConfigLayer {
41
- label: string;
42
- path: string;
43
- present: boolean;
44
- }
45
-
46
- // The precedence chain, ordered low → high. Each scope contributes its
47
- // config.json (credentials + prefs) then its settings file(s) on top; managed
48
- // enterprise settings, if present, win over everything.
49
- function layerSpecs(): { label: string; path: string }[] {
50
- const g = globalPaths();
51
- const p = projectPaths();
52
- const specs = [
53
- { label: "user config", path: g.config },
54
- { label: "user settings", path: g.settings },
55
- { label: "project config", path: p.config },
56
- { label: "project settings", path: p.settings },
57
- { label: "project settings (local)", path: p.settingsLocal },
58
- ];
59
- const managed = managedSettingsPath();
60
- if (managed) specs.push({ label: "managed", path: managed });
61
- return specs;
62
- }
63
-
64
- // Resolved layer presence, for `/doctor` and `/config`.
65
- export function configLayers(): ConfigLayer[] {
66
- return layerSpecs().map(({ label, path }) => ({ label, path, present: existsSync(path) }));
67
- }
68
-
69
- // Environment fallbacks for provider credentials. Applied only when config omits them.
70
- function applyEnv(cfg: ConfigT): ConfigT {
71
- const p = cfg.providers;
72
- const set = (name: ProviderName, key: "apiKey" | "baseURL", val?: string) => {
73
- if (!val) return;
74
- p[name] = { ...(p[name] ?? {}), [key]: (p[name] as any)?.[key] ?? val };
75
- };
76
- set("openrouter", "apiKey", process.env.OPENROUTER_API_KEY);
77
- set("anthropic", "apiKey", process.env.ANTHROPIC_API_KEY);
78
- set("openai", "apiKey", process.env.OPENAI_API_KEY);
79
- set("ollama", "baseURL", process.env.OLLAMA_BASE_URL);
80
- set("nearai", "apiKey", process.env.NEAR_AI_API_KEY ?? process.env.NEARAI_API_KEY);
81
- return cfg;
82
- }
83
-
84
- export function loadConfig(): ConfigT {
85
- // Merge raw layers first (so per-layer files stay partial), then parse once so
86
- // schema defaults are applied to the resolved object rather than each layer.
87
- let raw: unknown = {};
88
- for (const { path } of layerSpecs()) {
89
- raw = deepMerge(raw, readJsonIfExists(path));
90
- }
91
- const cfg = Config.parse(raw ?? {});
92
- return applyEnv(cfg);
93
- }
94
-
95
- // Persist the global config (used by /model, /provider, /permissions to remember
96
- // choices). The file holds provider API keys, so it is written owner-only (0600)
97
- // inside an owner-only directory (0700). `chmod` is best-effort: it's a no-op on
98
- // filesystems/platforms (e.g. Windows) that don't honour POSIX modes.
99
- export function saveGlobalConfig(cfg: ConfigT): void {
100
- const g = globalPaths();
101
- mkdirSync(g.dir, { recursive: true });
102
- tryChmod(g.dir, 0o700);
103
- // `mode` on writeFileSync only applies when creating the file, so chmod after
104
- // to also tighten a pre-existing, looser config.
105
- writeFileSync(g.config, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
106
- tryChmod(g.config, 0o600);
107
- }
108
-
109
- function tryChmod(path: string, mode: number): void {
110
- try {
111
- chmodSync(path, mode);
112
- } catch {
113
- /* non-POSIX filesystem or insufficient perms — nothing we can do */
114
- }
115
- }
@@ -1,94 +0,0 @@
1
- import { z } from "zod";
2
- import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
3
-
4
- // A provider's credentials/endpoint. All fields optional so config can be sparse
5
- // and filled in from environment variables at load time.
6
- export const ProviderConfig = z.object({
7
- apiKey: z.string().optional(),
8
- baseURL: z.string().optional(),
9
- // OpenRouter only: when true, every request is pinned to a Zero-Data-Retention
10
- // endpoint (provider.zdr) so prompts can't be retained. Models without a ZDR
11
- // endpoint then become unusable (and render red in the picker). Ignored by
12
- // other providers.
13
- enforceZdr: z.boolean().optional(),
14
- });
15
- export type ProviderConfig = z.infer<typeof ProviderConfig>;
16
-
17
- export const PERMISSION_MODES = ["default", "acceptEdits", "bypass", "plan"] as const;
18
- export type PermissionMode = (typeof PERMISSION_MODES)[number];
19
-
20
- export const Config = z.object({
21
- // "provider:model", e.g. "openrouter:anthropic/claude-opus-4.8".
22
- defaultModel: z.string().default("anthropic:claude-opus-4-8"),
23
- permissionMode: z.enum(PERMISSION_MODES).default("acceptEdits"),
24
- providers: z
25
- .object({
26
- openrouter: ProviderConfig.optional(),
27
- anthropic: ProviderConfig.optional(),
28
- openai: ProviderConfig.optional(),
29
- ollama: ProviderConfig.optional(),
30
- nearai: ProviderConfig.optional(),
31
- // Privateer account: inference billed to the user's account, no API key.
32
- // Auth lives in ~/.privateer/credentials.json (see src/auth/privateer.ts),
33
- // not here; this entry only carries an optional server baseURL override.
34
- privateer: ProviderConfig.optional(),
35
- })
36
- .default({}),
37
- // Confine file access to the working directory. When true (the default), the agent
38
- // reads/searches/edits only within cwd; reaching outside (an absolute path or `../`
39
- // escape) requires explicit per-location approval. Set false to let it roam freely.
40
- confineToCwd: z.boolean().default(true),
41
- // Bash command prefixes that are auto-approved (e.g. "git status", "ls").
42
- allowlist: z.array(z.string()).default([]),
43
- // Regex sources for commands that ALWAYS require confirmation, even under
44
- // bypass or an allowlist entry (destructive/exfil shapes). Extend per-project.
45
- denylist: z.array(z.string()).default(DEFAULT_DENYLIST),
46
- // Hard cap on agent tool-loop steps per turn.
47
- maxSteps: z.number().int().positive().default(50),
48
- // Approx token budget for the conversation context (used to trigger auto-compaction).
49
- contextBudget: z.number().int().positive().default(120_000),
50
- // Fraction of contextBudget at which to auto-compact older history (0–1).
51
- compactRatio: z.number().positive().max(1).default(0.8),
52
- // Modal (vim) editing in the prompt input.
53
- vim: z.boolean().default(false),
54
- // Active output style (persona) by name; loaded from .privateer/output-styles.
55
- outputStyle: z.string().optional(),
56
- // Max `task` sub-agents allowed to run concurrently when the model fans them out.
57
- maxSubagents: z.number().int().positive().default(4),
58
- // Anthropic extended-thinking budget in tokens (opt-in; Anthropic models only).
59
- thinkingBudget: z.number().int().positive().optional(),
60
- // Per-turn model routing. The `default` route is `defaultModel` above; these are
61
- // the specialized routes the router switches to based on the turn's data/shape.
62
- // See src/engine/router.ts for the selection rules (vision > long > fast > default).
63
- router: z
64
- .object({
65
- // Per-modality routes — turns whose input includes that kind go to this model.
66
- vision: z.string().optional(), // image input
67
- document: z.string().optional(), // PDF / document input
68
- audio: z.string().optional(), // audio input
69
- video: z.string().optional(), // video input
70
- long: z.string().optional(), // large conversations
71
- fast: z.string().optional(), // short, cheap turns
72
- // Estimated-token threshold that triggers the `long` route. Defaults to half
73
- // the contextBudget when unset (resolved in the session, not here).
74
- longThreshold: z.number().int().positive().optional(),
75
- // Prompts at or below this many characters are eligible for the `fast` route.
76
- fastMaxChars: z.number().int().positive().default(280),
77
- // Referenced text/code files at or below this many bytes are inlined into the
78
- // prompt (read-as-text); larger ones are left as a path for the read tool.
79
- inlineTextMaxBytes: z.number().int().positive().default(65_536),
80
- // Hybrid auto-detect: when a modality route is unset and the default model can't
81
- // handle that modality, pick a capable model automatically.
82
- auto: z.boolean().default(true),
83
- })
84
- .optional(),
85
- // Shell command whose stdout becomes the status line; receives session JSON on stdin.
86
- statusLine: z.string().optional(),
87
- })
88
- // Preserve unknown keys so layered settings files can carry forward-compatible
89
- // sections (hooks, mcpServers, statusLine, …) before they have explicit schemas.
90
- .catchall(z.unknown());
91
- export type Config = z.infer<typeof Config>;
92
-
93
- export const KNOWN_PROVIDERS = ["openrouter", "anthropic", "openai", "ollama", "nearai", "privateer"] as const;
94
- export type ProviderName = (typeof KNOWN_PROVIDERS)[number];