privateer-agent 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +65 -24
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +384 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +395 -32
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +516 -48
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/providers/account.ts +7 -1
  27. package/src/providers/defaultModel.ts +119 -0
  28. package/src/remote/channelsControl.ts +192 -0
  29. package/src/remote/controlAuth.ts +67 -0
  30. package/src/remote/extensionsControl.ts +140 -0
  31. package/src/remote/liveTaskSession.ts +218 -0
  32. package/src/remote/relayClient.ts +512 -1
  33. package/src/remote/remoteBridge.ts +172 -0
  34. package/src/remote/routinesControl.ts +216 -0
  35. package/src/remote/skillsControl.ts +205 -0
  36. package/src/remote/subagentChannel.ts +261 -0
  37. package/src/remote/subagentRelay.ts +126 -0
  38. package/src/remote/workflowsControl.ts +132 -0
  39. package/src/routines/store.ts +5 -1
  40. package/src/workflows/expr.ts +4 -0
  41. package/src/workflows/runner.ts +8 -0
  42. package/src/workflows/schema.ts +5 -0
  43. package/src/workflows/store.ts +108 -0
@@ -0,0 +1,232 @@
1
+ // Install the resident daemon as a per-user OS service so it auto-starts at login
2
+ // and survives the terminal closing — the difference between "the CLI is running"
3
+ // and "the daemon is reachable from the app even when no CLI is". macOS → launchd
4
+ // user agent; Linux → systemd --user unit. No root: everything lives under the
5
+ // user's own home and login session.
6
+ //
7
+ // ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
8
+ // so the daemon CLI can load it without going through boot.ts.
9
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
10
+ import { spawnSync } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { join, dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { globalDir } from "../config/paths.ts";
15
+ import { daemonIsRunning } from "./ipc.ts";
16
+
17
+ const LABEL = "pro.privateer.daemon"; // launchd label / reverse-dns id
18
+ const UNIT = "privateer-daemon.service"; // systemd --user unit name
19
+
20
+ // Absolute path to the node launcher that boots + runs the daemon (bin/privateer-daemon.mjs).
21
+ // Resolved from THIS module so it's correct for both a dev checkout and a global npm
22
+ // install (…/node_modules/privateer-agent/bin/privateer-daemon.mjs).
23
+ function daemonLauncherPath(): string {
24
+ const here = dirname(fileURLToPath(import.meta.url)); // …/src/daemon
25
+ return resolve(here, "../../bin/privateer-daemon.mjs");
26
+ }
27
+
28
+ // The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
29
+ // bash launcher already picked a compatible one) by absolute path, so the service
30
+ // never depends on launchd/systemd having a usable PATH.
31
+ function nodeBinaryPath(): string {
32
+ return process.execPath;
33
+ }
34
+
35
+ function daemonLogPath(): string {
36
+ return join(globalDir(), "daemon.log");
37
+ }
38
+
39
+ // Env we forward into the service so a non-default home / server URL survives. Kept
40
+ // tiny and explicit — the daemon reads the rest from ~/.privateer.
41
+ function forwardedEnv(): Record<string, string> {
42
+ const env: Record<string, string> = {};
43
+ if (process.env.PRIVATEER_HOME) env.PRIVATEER_HOME = process.env.PRIVATEER_HOME;
44
+ if (process.env.PRIVATEER_SERVER_URL) env.PRIVATEER_SERVER_URL = process.env.PRIVATEER_SERVER_URL;
45
+ return env;
46
+ }
47
+
48
+ // ── macOS (launchd) ─────────────────────────────────────────────────────────────
49
+
50
+ function launchAgentPath(): string {
51
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
52
+ }
53
+
54
+ function xmlEscape(s: string): string {
55
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
56
+ }
57
+
58
+ function launchdPlist(): string {
59
+ const args = [nodeBinaryPath(), daemonLauncherPath(), "run"];
60
+ const envVars = forwardedEnv();
61
+ const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
62
+ const envXml = Object.entries(envVars)
63
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
64
+ .join("\n");
65
+ const log = xmlEscape(daemonLogPath());
66
+ return `<?xml version="1.0" encoding="UTF-8"?>
67
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
+ <plist version="1.0">
69
+ <dict>
70
+ <key>Label</key>
71
+ <string>${LABEL}</string>
72
+ <key>ProgramArguments</key>
73
+ <array>
74
+ ${argXml}
75
+ </array>
76
+ ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
77
+ <true/>
78
+ <key>KeepAlive</key>
79
+ <true/>
80
+ <key>StandardOutPath</key>
81
+ <string>${log}</string>
82
+ <key>StandardErrorPath</key>
83
+ <string>${log}</string>
84
+ </dict>
85
+ </plist>
86
+ `;
87
+ }
88
+
89
+ function installLaunchd(): void {
90
+ const plist = launchAgentPath();
91
+ mkdirSync(dirname(plist), { recursive: true });
92
+ writeFileSync(plist, launchdPlist());
93
+ // Unload a prior copy (ignore failure — it may not be loaded), then load with -w so
94
+ // it's enabled across reboots.
95
+ spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
96
+ const r = spawnSync("launchctl", ["load", "-w", plist], { encoding: "utf8" });
97
+ if (r.status !== 0) {
98
+ throw new Error(`launchctl load failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
99
+ }
100
+ }
101
+
102
+ function uninstallLaunchd(): void {
103
+ const plist = launchAgentPath();
104
+ if (existsSync(plist)) {
105
+ spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
106
+ rmSync(plist, { force: true });
107
+ }
108
+ }
109
+
110
+ // ── Linux (systemd --user) ───────────────────────────────────────────────────────
111
+
112
+ function systemdUnitPath(): string {
113
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
114
+ return join(base, "systemd", "user", UNIT);
115
+ }
116
+
117
+ function systemdUnit(): string {
118
+ const exec = [nodeBinaryPath(), daemonLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
119
+ const envLines = Object.entries(forwardedEnv())
120
+ .map(([k, v]) => `Environment=${k}=${v}`)
121
+ .join("\n");
122
+ return `[Unit]
123
+ Description=Privateer resident agent daemon (routines + app-driven task spawns)
124
+ After=network-online.target
125
+ Wants=network-online.target
126
+
127
+ [Service]
128
+ Type=simple
129
+ ExecStart=${exec}
130
+ Restart=on-failure
131
+ RestartSec=5
132
+ ${envLines}
133
+
134
+ [Install]
135
+ WantedBy=default.target
136
+ `;
137
+ }
138
+
139
+ function installSystemd(): void {
140
+ const unit = systemdUnitPath();
141
+ mkdirSync(dirname(unit), { recursive: true });
142
+ writeFileSync(unit, systemdUnit());
143
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
144
+ // enable-linger so the user service keeps running with no active login session —
145
+ // the whole point of "reachable even when no shell is open". Best-effort: it needs
146
+ // no root on most distros, but don't fail the install if it's disallowed.
147
+ spawnSync("loginctl", ["enable-linger", process.env.USER || ""], { stdio: "ignore" });
148
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", UNIT], { encoding: "utf8" });
149
+ if (r.status !== 0) {
150
+ throw new Error(`systemctl enable failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
151
+ }
152
+ }
153
+
154
+ function uninstallSystemd(): void {
155
+ const unit = systemdUnitPath();
156
+ spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
157
+ if (existsSync(unit)) rmSync(unit, { force: true });
158
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
159
+ }
160
+
161
+ // ── Public API ───────────────────────────────────────────────────────────────────
162
+
163
+ export interface ServiceInfo {
164
+ platform: NodeJS.Platform;
165
+ supported: boolean;
166
+ installed: boolean;
167
+ unitPath: string;
168
+ logPath: string;
169
+ }
170
+
171
+ function unitPathFor(platform: NodeJS.Platform): string {
172
+ if (platform === "darwin") return launchAgentPath();
173
+ if (platform === "linux") return systemdUnitPath();
174
+ return "";
175
+ }
176
+
177
+ export function serviceInfo(): ServiceInfo {
178
+ const platform = process.platform;
179
+ const unitPath = unitPathFor(platform);
180
+ return {
181
+ platform,
182
+ supported: platform === "darwin" || platform === "linux",
183
+ installed: !!unitPath && existsSync(unitPath),
184
+ unitPath,
185
+ logPath: daemonLogPath(),
186
+ };
187
+ }
188
+
189
+ // Install the service for the current platform. Idempotent (rewrites + reloads).
190
+ export function installService(): ServiceInfo {
191
+ const platform = process.platform;
192
+ if (platform === "darwin") installLaunchd();
193
+ else if (platform === "linux") installSystemd();
194
+ else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer daemon\` yourself, or keep a terminal open.`);
195
+ return serviceInfo();
196
+ }
197
+
198
+ export function uninstallService(): ServiceInfo {
199
+ const platform = process.platform;
200
+ if (platform === "darwin") uninstallLaunchd();
201
+ else if (platform === "linux") uninstallSystemd();
202
+ else throw new Error(`No service to remove on ${platform}.`);
203
+ return serviceInfo();
204
+ }
205
+
206
+ // Human-readable status line for `privateer daemon status`: whether the service is
207
+ // installed AND whether a daemon is actually answering on the IPC socket right now.
208
+ export async function statusReport(): Promise<string> {
209
+ const info = serviceInfo();
210
+ const live = await daemonIsRunning();
211
+ const lines = [
212
+ `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer daemon` manually)"}`,
213
+ `service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
214
+ `daemon: ${live ? "running (answering IPC)" : "not reachable"}`,
215
+ `logs: ${info.logPath}`,
216
+ ];
217
+ // Surface a stale-unit hint: file present but nothing answering usually means it
218
+ // failed to boot — the log path above is where to look.
219
+ if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
220
+ return lines.join("\n");
221
+ }
222
+
223
+ // Best-effort read of the tail of the daemon log (for a `status --log` affordance or
224
+ // error surfacing). Returns "" if absent.
225
+ export function tailDaemonLog(maxBytes = 4_000): string {
226
+ try {
227
+ const buf = readFileSync(daemonLogPath(), "utf8");
228
+ return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
229
+ } catch {
230
+ return "";
231
+ }
232
+ }
@@ -44,6 +44,14 @@ export interface GateController {
44
44
  confineToCwd?: boolean;
45
45
  getRemote?(): boolean;
46
46
  getNoQuarter?(): boolean;
47
+ // Block a tool outright while the turn is remote-driven (only consulted when
48
+ // getRemote() is true). For tools whose own prompts render on the host terminal
49
+ // rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
50
+ // invisible local prompt. Returns true to block. See isRemoteUnsafeTool.
51
+ blockedWhenRemote?(toolName: string): boolean;
52
+ // Notified when blockedWhenRemote blocked a tool, so the controller can surface a
53
+ // one-line reason in the app feed (a `notice` frame) explaining why nothing ran.
54
+ onRemoteBlocked?(toolName: string): void;
47
55
  // Local interactive approval via the per-call ctx. Default provided below.
48
56
  localAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome>;
49
57
  // Remote (relay) approval. Optional until Phase 4; when absent, a remote turn
@@ -60,6 +68,23 @@ export interface GateBlock {
60
68
  reason: string;
61
69
  }
62
70
 
71
+ // Tools that CANNOT be driven from the app and so are blocked outright on a
72
+ // remote-driven turn (see chat.ts wiring). pi-subagents runs each subagent as a
73
+ // child session/subprocess whose own permission gate + UI aren't wired to the
74
+ // relay — its approvals and "TUI clarification" prompts surface on the HOST
75
+ // terminal, never in the app. A driven turn that spawned one would wedge on a
76
+ // prompt the phone can't answer. `contact_supervisor`/`intercom` are the child→
77
+ // parent tools; they only exist in child sessions but are listed for safety.
78
+ // Blocking is fail-closed and matches the "remote turns never auto-approve"
79
+ // posture: the feature is simply disabled while driving until its prompts relay.
80
+ export const REMOTE_UNSAFE_TOOLS: ReadonlySet<string> = new Set([
81
+ "subagent",
82
+ "contact_supervisor",
83
+ "intercom",
84
+ ]);
85
+
86
+ export const isRemoteUnsafeTool = (toolName: string): boolean => REMOTE_UNSAFE_TOOLS.has(toolName);
87
+
63
88
  // THE decision path. Classify → policy → decision, fail-closed on any error.
64
89
  // Returns a block directive to deny, or undefined to let the tool run.
65
90
  export async function decideToolCall(
@@ -68,6 +93,19 @@ export async function decideToolCall(
68
93
  input: unknown,
69
94
  ctx: ToolCallCtx,
70
95
  ): Promise<GateBlock | undefined> {
96
+ // Remote-driven turn: a few tools can't be driven from the app because their own
97
+ // interactive prompts render on the host terminal, not the relay (pi-subagents
98
+ // spawns child sessions outside the bridge). Block them fail-closed BEFORE any
99
+ // classification so a driven turn never wedges on a prompt the phone can't answer,
100
+ // and tell the controller so it can post a notice to the app feed.
101
+ if (ctrl.getRemote?.() && ctrl.blockedWhenRemote?.(toolName)) {
102
+ ctrl.onRemoteBlocked?.(toolName);
103
+ return {
104
+ block: true,
105
+ reason: `${toolName} is unavailable while this terminal is driven remotely — its prompts can't reach the app. Complete the task without it, or ask the operator to run it from the terminal directly.`,
106
+ };
107
+ }
108
+
71
109
  const req = classifyToolCall(toolName, input, {
72
110
  cwd: ctrl.cwd,
73
111
  confineToCwd: ctrl.confineToCwd,
@@ -1,4 +1,5 @@
1
- import { resolve, isAbsolute, relative } from "node:path";
1
+ import { resolve, isAbsolute, relative, dirname, join, basename } from "node:path";
2
+ import { realpathSync } from "node:fs";
2
3
  import { isProtectedPath } from "./protected.ts";
3
4
  import type { PermissionRequest } from "./gate.ts";
4
5
 
@@ -18,8 +19,31 @@ export interface ScopeOptions {
18
19
  allowedOutsideRoots?: string[];
19
20
  }
20
21
 
22
+ // Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
23
+ // lexical `resolve()` check (P5-1: `resolve` normalizes `..` but does NOT follow
24
+ // symlinks, so `cwd/link/secret` where `link -> /etc` looks in-cwd lexically). We
25
+ // realpath the DEEPEST EXISTING ancestor — a write target's leaf may not exist yet —
26
+ // and re-append the missing tail; the escape lives in the existing prefix, so
27
+ // canonicalizing that is what matters. Falls back to the lexical path when nothing
28
+ // resolves (e.g. a fully-nonexistent tree, as in unit tests).
29
+ function realBase(abs: string): string {
30
+ let dir = abs;
31
+ const tail: string[] = [];
32
+ for (;;) {
33
+ try {
34
+ const real = realpathSync(dir);
35
+ return tail.length ? join(real, ...tail) : real;
36
+ } catch {
37
+ const parent = dirname(dir);
38
+ if (parent === dir) return abs; // reached the FS root without resolving → lexical
39
+ tail.unshift(basename(dir));
40
+ dir = parent;
41
+ }
42
+ }
43
+ }
44
+
21
45
  function resolveInCwd(cwd: string, p: string): string {
22
- return isAbsolute(p) ? p : resolve(cwd, p);
46
+ return realBase(isAbsolute(p) ? p : resolve(cwd, p));
23
47
  }
24
48
 
25
49
  function isInsideDir(root: string, abs: string): boolean {
@@ -29,11 +53,14 @@ function isInsideDir(root: string, abs: string): boolean {
29
53
  }
30
54
 
31
55
  // Outside the agent's working-directory scope? Only when confinement is on and the
32
- // path is neither inside cwd nor inside a session-approved outside root.
56
+ // path is neither inside cwd nor inside a session-approved outside root. Both sides are
57
+ // symlink-canonicalized (realBase) so a symlinked cwd — or a symlink inside cwd — can't
58
+ // fake containment (P5-1).
33
59
  export function isOutsideScope(scope: ScopeOptions, abs: string): boolean {
34
60
  if (scope.confineToCwd === false) return false;
35
- if (isInsideDir(scope.cwd, abs)) return false;
36
- return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(root, abs));
61
+ const target = realBase(abs);
62
+ if (isInsideDir(realBase(scope.cwd), target)) return false;
63
+ return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(realBase(root), target));
37
64
  }
38
65
 
39
66
  function str(v: unknown): string {
@@ -44,6 +71,21 @@ function firstPath(input: Record<string, unknown>): string {
44
71
  return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
45
72
  }
46
73
 
74
+ // A write/edit tool call whose target path we can't statically extract — an aliased
75
+ // param name, or a patch tool whose target paths live in the DIFF BODY rather than a
76
+ // param (P5-4). Fail safe: mark it outside-scope so it prompts (in default/acceptEdits)
77
+ // instead of defaulting to a silent in-cwd auto-write. Precise patch-body path parsing
78
+ // needs Pi's apply_patch schema — TODO(verify) against the full builtin tool catalog.
79
+ function unknownTarget(toolName: string, kind: "write" | "edit"): PermissionRequest {
80
+ return {
81
+ tool: toolName,
82
+ kind,
83
+ title: kind === "write" ? "Write to an unverified path" : "Edit an unverified path",
84
+ detail: "(target path not statically known — approve to allow)",
85
+ outside: true,
86
+ };
87
+ }
88
+
47
89
  // Known-safe read-only / meta builtins that never mutate and never leave the
48
90
  // machine: no gate regardless of arguments. Tunable — the conservative default for
49
91
  // anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
@@ -96,6 +138,7 @@ export function classifyToolCall(
96
138
  // Write — create/overwrite a file.
97
139
  if (WRITE_TOOLS.has(name)) {
98
140
  const p = firstPath(obj);
141
+ if (!p) return unknownTarget(toolName, "write"); // P5-4: no extractable path → fail safe
99
142
  const abs = resolveInCwd(scope.cwd, p);
100
143
  const outside = isOutsideScope(scope, abs);
101
144
  return {
@@ -112,6 +155,7 @@ export function classifyToolCall(
112
155
  // Edit — modify an existing file.
113
156
  if (EDIT_TOOLS.has(name)) {
114
157
  const p = firstPath(obj);
158
+ if (!p) return unknownTarget(toolName, "edit"); // P5-4: no extractable path → fail safe
115
159
  const abs = resolveInCwd(scope.cwd, p);
116
160
  const outside = isOutsideScope(scope, abs);
117
161
  return {
@@ -20,6 +20,7 @@ import {
20
20
  notifySignedIn,
21
21
  } from "../auth/privateer.ts";
22
22
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
23
+ import { ACCOUNT_DEFAULT_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
23
24
 
24
25
  // Seed/fallback catalog: registered synchronously so the account provider has real
25
26
  // models the instant it loads (before the live /api/models fetch resolves) — in
@@ -28,7 +29,7 @@ import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } fro
28
29
  // confidential-compute (TEE, attestable) model — the strongest privacy tier. Also the
29
30
  // fallback list if the live listing can't be reached.
30
31
  const DEFAULT_MODELS = [
31
- "near/zai-org/GLM-5.1-FP8",
32
+ ACCOUNT_DEFAULT_MODEL_ID,
32
33
  "anthropic/claude-sonnet-4.6",
33
34
  "openai/gpt-5.5",
34
35
  "deepseek/deepseek-v4-flash",
@@ -101,6 +102,11 @@ export const privateerOAuthProvider = {
101
102
  }
102
103
  if (cb.signal?.aborted) throw new Error("Login cancelled");
103
104
  const creds = await spawnAccountCredentials();
105
+ // Seed Pi's saved model default to the account channel, so the next launch resolves
106
+ // to a billable subscription model instead of falling through to a keyless built-in
107
+ // (the "No API key found for openrouter" trap). No-op if the user already has a
108
+ // chosen default. See providers/defaultModel.ts.
109
+ ensurePiDefaultModel();
104
110
  // The fresh path already fired notifySignedIn (pollForToken); fire here for the
105
111
  // already-linked path so the header re-renders to "connected" on this terminal too.
106
112
  if (wasLinked) notifySignedIn();
@@ -0,0 +1,119 @@
1
+ // The single source of truth for "which model do we default to?" — shared by every
2
+ // entry point that has to pick a model when the user hasn't named one: the REPL
3
+ // (cli/chat.ts), the daemon (routines), the channels runner, and the login-time hook
4
+ // that seeds Pi's TUI default (ensurePiDefaultModel).
5
+ //
6
+ // The bug this fixes: each of those sites used to hardcode `openrouter/openai/gpt-4o-
7
+ // mini`, which assumes a BYO OpenRouter key. A user who is ONLY signed into their
8
+ // Privateer subscription has no such key, so the runtime resolved to OpenRouter and
9
+ // then failed at request time with "No API key found for openrouter". Being signed in
10
+ // never nominated a model. resolveDefaultModel() makes the account channel the default
11
+ // the moment credentials exist, and keeps the legacy BYO behaviour otherwise.
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { hasCredentials } from "../auth/privateer.ts";
16
+ import { agentDir } from "../config/paths.ts";
17
+
18
+ // The signed-in default: a NEAR confidential-compute (TEE, attestable) model — the
19
+ // strongest privacy tier, and the same id the app shows first. Kept here as the one
20
+ // definition; providers/account.ts imports it so its seed catalog can't drift.
21
+ export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
22
+ export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
23
+
24
+ // Last-resort BYO default, preserved from the pre-resolver code so a user who set an
25
+ // OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
26
+ // either, this still surfaces the familiar "No API key found for openrouter" — a clear
27
+ // signal to run /login or set a key, which is better than an empty/undefined model.
28
+ export const LEGACY_BYO_FALLBACK = "openrouter/openai/gpt-4o-mini";
29
+
30
+ // BYO providers we can positively detect from the environment, in preference order.
31
+ // Each model id matches Pi's own defaultModelPerProvider so it actually resolves once
32
+ // the key is present. OpenRouter stays on the legacy cheap default for continuity.
33
+ const BYO_BY_KEY: Array<{ env: string; spec: string }> = [
34
+ { env: "ANTHROPIC_API_KEY", spec: "anthropic/claude-opus-4-8" },
35
+ { env: "OPENAI_API_KEY", spec: "openai/gpt-5.5" },
36
+ { env: "OPENROUTER_API_KEY", spec: LEGACY_BYO_FALLBACK },
37
+ ];
38
+
39
+ export interface ResolveDefaultModelOptions {
40
+ // An explicit, user-chosen spec (e.g. config.defaultModel, a channel's `model`).
41
+ // Wins over everything when non-empty — it's a deliberate choice, not a fallback.
42
+ explicit?: string | null;
43
+ // Override for testing / non-process callers. Defaults to process.env.
44
+ env?: NodeJS.ProcessEnv;
45
+ // Override the signed-in check (testing). Defaults to hasCredentials().
46
+ signedIn?: boolean;
47
+ }
48
+
49
+ // Resolve the model spec ("provider/id") to use when no model is named. Pure and
50
+ // synchronous (only reads env + the credentials file), so it's safe to call from any
51
+ // entry point at startup. Precedence:
52
+ // 1. explicit user choice (config/channel) — deliberate, always wins
53
+ // 2. PRIVATEER_MODEL env — dev/global override
54
+ // 3. signed into Privateer → the account default — the fix: subscription users
55
+ // 4. a BYO provider whose key is present — anthropic, openai, openrouter
56
+ // 5. LEGACY_BYO_FALLBACK — familiar "add a key" signal
57
+ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
58
+ const env = opts.env ?? process.env;
59
+
60
+ const explicit = opts.explicit?.trim();
61
+ if (explicit) return explicit;
62
+
63
+ const fromEnv = env.PRIVATEER_MODEL?.trim();
64
+ if (fromEnv) return fromEnv;
65
+
66
+ const signedIn = opts.signedIn ?? hasCredentials();
67
+ if (signedIn) return ACCOUNT_DEFAULT_SPEC;
68
+
69
+ for (const { env: keyName, spec } of BYO_BY_KEY) {
70
+ if (env[keyName]?.trim()) return spec;
71
+ }
72
+
73
+ return LEGACY_BYO_FALLBACK;
74
+ }
75
+
76
+ // Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
77
+ // only the first delimiter separates provider from model). Returns null for a spec
78
+ // with no provider prefix.
79
+ function splitSpec(spec: string): { provider: string; modelId: string } | null {
80
+ const slash = spec.indexOf("/");
81
+ if (slash <= 0 || slash === spec.length - 1) return null;
82
+ return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
83
+ }
84
+
85
+ // The TUI consumer. Pi's own model resolution (findInitialModel) checks its saved
86
+ // settings default BEFORE it falls through to a keyless built-in, but nothing ever
87
+ // pointed that default at the account channel — Pi's provider-default table has no
88
+ // `privateer` entry, so a signed-in-only user landed on OpenRouter and errored. On a
89
+ // successful login we seed Pi's global settings.json (agentDir/settings.json — the
90
+ // same file its SettingsManager reads) with the account default, so the NEXT launch
91
+ // resolves cleanly.
92
+ //
93
+ // Guarded: we only write when the user has NOT already chosen a default (no
94
+ // `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
95
+ // any read/parse/write failure is swallowed; a missing seed just means the user picks
96
+ // a model once via /model. Returns the spec written, or null if we left it alone.
97
+ export function ensurePiDefaultModel(spec: string = ACCOUNT_DEFAULT_SPEC): string | null {
98
+ const parts = splitSpec(spec);
99
+ if (!parts) return null;
100
+ const settingsPath = join(agentDir(), "settings.json");
101
+ try {
102
+ let settings: Record<string, unknown> = {};
103
+ if (existsSync(settingsPath)) {
104
+ const raw = readFileSync(settingsPath, "utf8").trim();
105
+ if (raw) settings = JSON.parse(raw) as Record<string, unknown>;
106
+ }
107
+ // Respect an existing choice — presence of the key means the user (or Pi) already
108
+ // has a default; don't override it.
109
+ if (typeof settings.defaultModel === "string" && settings.defaultModel.trim()) {
110
+ return null;
111
+ }
112
+ settings.defaultProvider = parts.provider;
113
+ settings.defaultModel = parts.modelId;
114
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
115
+ return spec;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }