privateer-agent 0.4.0 → 0.5.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.
@@ -139,7 +139,7 @@ function shortCwd(): string {
139
139
  // - signed in → "connected as <account>"
140
140
  // - signed out AND the current model bills to a Privateer account → it can't run
141
141
  // until they sign in, so say so plainly (warning)
142
- // - signed out on their own key → a quiet tease that /signin adds more
142
+ // - signed out on their own key → a quiet tease that /login adds more
143
143
  function accountLine(modelProvider?: string): string {
144
144
  const u = priv.currentUser();
145
145
  if (u) {
@@ -147,9 +147,9 @@ function accountLine(modelProvider?: string): string {
147
147
  return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
148
148
  }
149
149
  if (modelProvider === "privateer") {
150
- return `${YELLOW}not signed in · /signin to use this model${RESET}`;
150
+ return `${YELLOW}not signed in · /login to use this model${RESET}`;
151
151
  }
152
- return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
152
+ return `${DIM}not signed in · ${OCEAN_LIGHT}/login${DIM} to connect your account${RESET}`;
153
153
  }
154
154
 
155
155
  // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
@@ -430,7 +430,7 @@ export default function privateerBrand(pi: any): void {
430
430
  ctx?.ui?.setTitle?.("Privateer");
431
431
  refresh(ctx);
432
432
  // No startup notify here: the banner's account line already surfaces the
433
- // "not signed in · /signin" prompt, so a second line would just be noise.
433
+ // "not signed in · /login" prompt, so a second line would just be noise.
434
434
  });
435
435
 
436
436
  // Keep the header's account line in sync with the picked model (the "this model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -110,6 +110,7 @@ async function main() {
110
110
  type GateController = import("../ext/permissionGate.ts").GateController;
111
111
  const { makePiPrivacyExtension } = await import("pi-privacy");
112
112
  const { makeAccountProvider } = await import("../providers/account.ts");
113
+ const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
113
114
  const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
114
115
  const { redactText, collectSecrets } = await import("../util/redact.ts");
115
116
  const { MessagingBridge } = await import("./bridge.ts");
@@ -130,7 +131,7 @@ async function main() {
130
131
  process.exit(1);
131
132
  }
132
133
  const ch = cfg.channels ?? {};
133
- const defaultModel: string = ch.model ?? cfg.defaultModel ?? "openrouter/openai/gpt-4o-mini";
134
+ const defaultModel: string = resolveDefaultModel({ explicit: ch.model ?? cfg.defaultModel });
134
135
  const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length ? ch.tools : SAFE_TOOLS;
135
136
  const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
136
137
  const cwd: string = ch.cwd ?? process.cwd();
package/src/cli/chat.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // the EngineEvent adapter, and Pi's real session.
5
5
  //
6
6
  // Run: nvm use && node --env-file=.env --import tsx src/cli/chat.ts
7
- // Model: PRIVATEER_MODEL=provider/id (default openrouter/openai/gpt-4o-mini)
7
+ // Model: PRIVATEER_MODEL=provider/id (else the account default when signed in, or a
8
+ // BYO-keyed provider — see providers/defaultModel.ts)
8
9
  // e.g. tinfoil/llama3-3-70b to watch TEE posture go green.
9
10
 
10
11
  import "../boot.ts"; // env + attestation dispatcher, before any Pi import
@@ -33,8 +34,11 @@ async function main() {
33
34
  const priv = await import("../auth/privateer.ts");
34
35
  const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
35
36
  const { agentVersion } = await import("../config/version.ts");
37
+ const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
36
38
 
37
- const spec = process.env.PRIVATEER_MODEL ?? "openrouter/openai/gpt-4o-mini";
39
+ // resolveDefaultModel() already honours PRIVATEER_MODEL first, then the account
40
+ // default when signed in, then a BYO key — one source of truth (defaultModel.ts).
41
+ const spec = resolveDefaultModel();
38
42
  const slash = spec.indexOf("/");
39
43
  const provider = spec.slice(0, slash);
40
44
  const modelId = spec.slice(slash + 1);
@@ -0,0 +1,13 @@
1
+ // Harbor hosted mode.
2
+ //
3
+ // When true, this daemon is running inside Privateer's confidential-VM fleet
4
+ // (the host orchestrator sets HARBOR_HOSTED=1), not on a user's own machine.
5
+ // Hosted daemons run on-demand: they report their next routine fire time to the
6
+ // server and idle-suspend when there's no work, so the server can wake them
7
+ // again in time. A daemon on a user's laptop leaves this off and keeps running
8
+ // its own cron continuously.
9
+ //
10
+ // Read via process.env at call time, mirroring PRIVATEER_HOME / PRIVATEER_SERVER_URL.
11
+ export function isHosted(): boolean {
12
+ return process.env.HARBOR_HOSTED === "1";
13
+ }
@@ -15,6 +15,7 @@ import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
15
15
  import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
16
16
  import { makePiPrivacyExtension } from "pi-privacy";
17
17
  import { makeAccountProvider } from "../providers/account.ts";
18
+ import { resolveDefaultModel } from "../providers/defaultModel.ts";
18
19
  import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
19
20
  import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
20
21
  import { makeRoutinesControl } from "../remote/routinesControl.ts";
@@ -49,6 +50,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
49
50
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
50
51
  import { redactText, collectSecrets } from "../util/redact.ts";
51
52
  import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
53
+ import { isHosted } from "../config/hosted.ts";
52
54
 
53
55
  // The safe, read-only toolset for unattended runs — Pi builtins with no
54
56
  // write/edit/bash, so a routine firing with nobody watching can't mutate the
@@ -58,6 +60,10 @@ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
58
60
  const SAFE_TOOLS = ["read", "grep", "find", "ls"];
59
61
 
60
62
  const TICK_MS = 60_000; // scan for due routines once a minute
63
+ // Harbor hosted mode (isHosted): suspend after this much idle time with no work,
64
+ // and stay up if a routine is due within the lead window (avoids suspend→wake churn).
65
+ const HOSTED_IDLE_MS = Number(process.env.HARBOR_IDLE_MS) || 5 * 60_000;
66
+ const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS) || 2 * 60_000;
61
67
  const MAX_CLOUD_PLAINTEXT = 45_000;
62
68
  // How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
63
69
  // answer before it fail-closes to "no response" (the runner then defers the run). Bounds
@@ -76,12 +82,14 @@ function loadDaemonConfig(): DaemonConfig {
76
82
  try {
77
83
  const raw = JSON.parse(readFileSync(configPath(), "utf8"));
78
84
  return {
79
- defaultModel: typeof raw.defaultModel === "string" ? raw.defaultModel : "openrouter/openai/gpt-4o-mini",
85
+ // config.defaultModel is the explicit choice; absent it, resolve (account default
86
+ // when signed in, else BYO) rather than assuming a BYO OpenRouter key.
87
+ defaultModel: resolveDefaultModel({ explicit: typeof raw.defaultModel === "string" ? raw.defaultModel : undefined }),
80
88
  webhooks: raw.webhooks,
81
89
  providers: raw.providers,
82
90
  };
83
91
  } catch {
84
- return { defaultModel: "openrouter/openai/gpt-4o-mini" };
92
+ return { defaultModel: resolveDefaultModel() };
85
93
  }
86
94
  }
87
95
 
@@ -155,6 +163,11 @@ export class Daemon {
155
163
  private relay?: RelayClient;
156
164
  private controllerAttached = false;
157
165
  private relayTerminated = false;
166
+ // Harbor hosted mode: last time a controller attached or a routine ran. Drives
167
+ // idle-suspend (no `controller_detached` frame exists, so we gate on inactivity
168
+ // + no live work rather than on controllerAttached, which never resets while the
169
+ // socket stays open).
170
+ private lastActivityAt = Date.now();
158
171
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
159
172
  // terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
160
173
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
@@ -403,6 +416,8 @@ export class Daemon {
403
416
 
404
417
  private onControllerAttached(): void {
405
418
  this.controllerAttached = true;
419
+ this.markActivity(); // hosted: a driver is present — reset the idle timer
420
+
406
421
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
407
422
  // Version + this terminal's identity public key (so the app can confirm this is
408
423
  // the terminal it PINNED at link time before sealing channel tokens to it). No
@@ -503,6 +518,74 @@ export class Daemon {
503
518
  await this.runRoutine(r);
504
519
  }
505
520
  }
521
+ await this.hostedTick();
522
+ }
523
+
524
+ // ── Harbor hosted mode ──────────────────────────────────────────────────────
525
+ // A hosted daemon runs on-demand: it reports its earliest upcoming fire time so
526
+ // the server can wake it while suspended, and idle-suspends when there's no work.
527
+ // No-op on a user's own machine (isHosted() === false).
528
+
529
+ private markActivity(): void { this.lastActivityAt = Date.now(); }
530
+
531
+ // Earliest fire time (ms) across enabled, valid routines — mirrors tick()'s fire
532
+ // filters so we never report/wait on a routine the scheduler won't actually fire.
533
+ private earliestFireMs(): number | null {
534
+ let earliest: number | null = null;
535
+ for (const r of loadRoutines()) {
536
+ if (!r.enabled || triggerError(r)) continue;
537
+ const nrStr = r.nextRun ?? computeNextRun(r)?.toISOString();
538
+ if (!nrStr) continue;
539
+ const t = Date.parse(nrStr);
540
+ if (Number.isNaN(t)) continue;
541
+ if (earliest === null || t < earliest) earliest = t;
542
+ }
543
+ return earliest;
544
+ }
545
+
546
+ // Report our next fire time to the server so its scheduler can wake us. When
547
+ // `suspending`, also flip the server-side status to suspended (we're about to
548
+ // exit) so the sweeper knows to wake us again. Best-effort: an offline instance
549
+ // retries next tick; a suspend report failing means we stay up this tick.
550
+ private async reportSchedule(suspending = false): Promise<boolean> {
551
+ if (!isHosted() || !hasCredentials()) return true;
552
+ const earliest = this.earliestFireMs();
553
+ try {
554
+ const res = await apiRequest("/api/harbor/agent/schedule", {
555
+ method: "POST",
556
+ headers: { "content-type": "application/json" },
557
+ body: JSON.stringify({
558
+ termId: routineRelayId(),
559
+ nextRoutineAt: earliest !== null ? new Date(earliest).toISOString() : null,
560
+ ...(suspending ? { suspended: true } : {}),
561
+ }),
562
+ });
563
+ return res.ok;
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+
569
+ private shouldSuspend(): boolean {
570
+ if (this.running.size > 0 || this.liveTasks.size > 0) return false;
571
+ if (Date.now() - this.lastActivityAt < HOSTED_IDLE_MS) return false;
572
+ const earliest = this.earliestFireMs();
573
+ if (earliest !== null && earliest - Date.now() < HOSTED_SUSPEND_MIN_LEAD_MS) return false;
574
+ return true;
575
+ }
576
+
577
+ private async hostedTick(): Promise<void> {
578
+ if (!isHosted()) return;
579
+ if (this.shouldSuspend()) {
580
+ // Tell the server we're suspending (+ our next fire) BEFORE exiting; only
581
+ // then tear down. If the report fails, stay up and try again next tick.
582
+ if (!(await this.reportSchedule(true))) return;
583
+ log("hosted: idle — suspending (server will wake for the next routine)");
584
+ this.stop();
585
+ void revokeLocalSessions().finally(() => process.exit(0));
586
+ return;
587
+ }
588
+ await this.reportSchedule();
506
589
  }
507
590
 
508
591
  // Execute a routine to completion and deliver the result. The one rewired seam:
@@ -511,6 +594,7 @@ export class Daemon {
511
594
  async runRoutine(routine: Routine): Promise<IpcResponse> {
512
595
  if (this.running.has(routine.id)) return { ok: false, message: "already running" };
513
596
  this.running.add(routine.id);
597
+ this.markActivity(); // hosted: work in progress — don't idle-suspend under it
514
598
  log(`running routine "${routine.name}"`);
515
599
 
516
600
  const config = loadDaemonConfig();
@@ -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
+ }