privateer-agent 0.4.1 → 0.5.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.
package/bin/privateer-tui CHANGED
@@ -58,7 +58,7 @@ mkdir -p "$EXT_DIR"
58
58
  # ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
59
59
  # symlink would resolve them relative to the shim's location and break). We remove
60
60
  # any shim we previously managed first, so a dropped package can't linger and reload.
61
- MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
61
+ MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-models privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
62
62
  for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
63
63
  shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
64
64
  # Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
@@ -67,6 +67,10 @@ shim privateer-brand "$REPO/extensions/privateer-brand.ts"
67
67
  shim privateer-context "$REPO/extensions/privateer-context.ts"
68
68
  shim privateer-gate "$REPO/extensions/privateer-gate.ts"
69
69
  shim privateer-account "$REPO/extensions/privateer-account.ts"
70
+ # The /models picker — searchable model selector with per-row privacy shields
71
+ # (TEE / ZDR / standard). Pi's built-in /model is redirected here by the
72
+ # patch-package patch (patches/@earendil-works+pi-coding-agent+*.patch).
73
+ shim privateer-models "$REPO/extensions/privateer-models.ts"
70
74
  shim privateer-posture "$REPO/extensions/privateer-posture.ts"
71
75
  shim privateer-tools "$REPO/extensions/privateer-tools.ts"
72
76
  # pi-privacy wrapped with the account-channel tier resolver (privateer/near… = TEE),
@@ -0,0 +1,429 @@
1
+ // The Privateer `/models` picker — a searchable model selector that puts the
2
+ // PRIVACY POSTURE of every model on screen. Pi's built-in `/model` picker (which
3
+ // this shadows via the redirect patch in patches/@earendil-works+pi-coding-agent)
4
+ // shows only `id [provider]`; it has no hook for a privacy shield and can't be
5
+ // overridden from an extension (its command name is a reserved builtin, dispatched
6
+ // before extension commands). So we ship our OWN picker through `ctx.ui.custom`,
7
+ // which renders a full pi-tui component: fuzzy search + a per-row shield (⛉ TEE /
8
+ // ◈ ZDR / · standard) + tier grouping + a legend, ranked strongest-privacy-first.
9
+ //
10
+ // Honest-labeling contract (pi-privacy posture/tiers.ts): a TEE row is only a
11
+ // *claim* (tee-unverified, yellow) until a live attestation confirms it
12
+ // (tee-verified, green). The picker seeds each row with the server's baseline tier
13
+ // (GET /api/models `privacy.tier`, via account.ts) and then attests the TEE-capable
14
+ // rows in the background, upgrading the shield in place. ZDR-enforced rows are never
15
+ // downgraded by attestation — we only attest rows that claim a TEE.
16
+
17
+ import {
18
+ Container,
19
+ fuzzyFilter,
20
+ getKeybindings,
21
+ Input,
22
+ Spacer,
23
+ Text,
24
+ } from "@earendil-works/pi-tui";
25
+ import { TIERS, tierRank, type PrivacyTier } from "pi-privacy";
26
+ import { verifyModelPosture } from "pi-privacy";
27
+ import {
28
+ accountBaselineTier,
29
+ accountCatalogLoaded,
30
+ accountPosture,
31
+ fetchAccountCatalog,
32
+ } from "../src/providers/account.ts";
33
+
34
+ // A minimal view of Pi's theme (passed to the ui.custom factory) — enough to color
35
+ // text without pulling Pi's internal theme types into an extension.
36
+ interface ThemeLike {
37
+ fg(color: string, text: string): string;
38
+ bold(text: string): string;
39
+ }
40
+
41
+ // A pi-tui TUI handle (the first arg the ui.custom factory receives). We only use
42
+ // requestRender; keep the surface tiny so this doesn't couple to Pi internals.
43
+ interface TuiLike {
44
+ requestRender(): void;
45
+ }
46
+
47
+ interface ModelLike {
48
+ provider: string;
49
+ id: string;
50
+ name?: string;
51
+ }
52
+
53
+ interface Row {
54
+ provider: string;
55
+ id: string;
56
+ name: string;
57
+ model: ModelLike;
58
+ tier: PrivacyTier;
59
+ // true once a live attestation has resolved this row (so we don't re-attest).
60
+ attested?: boolean;
61
+ }
62
+
63
+ // posture bucket → theme color name (Pi's palette) for the shield glyph.
64
+ const POSTURE_COLOR: Record<string, string> = {
65
+ green: "success",
66
+ yellow: "warning",
67
+ red: "error",
68
+ neutral: "muted",
69
+ };
70
+
71
+ // The shield/marker glyph per tier family. TEE → shield; ZDR → diamond; local →
72
+ // house; standard → dot. Color comes from the tier's posture bucket, so verified
73
+ // (green) and merely-claimed (yellow) read differently at a glance — the same
74
+ // distinction the status-bar badge (privateer-posture.ts) draws.
75
+ function glyphFor(tier: PrivacyTier): string {
76
+ if (tier === "tee-verified" || tier === "tee-unverified") return "⛉";
77
+ if (tier === "local") return "⌂";
78
+ if (tier === "zdr-enforced" || tier === "zdr-policy") return "◈";
79
+ return "·";
80
+ }
81
+
82
+ function shield(theme: ThemeLike, tier: PrivacyTier): string {
83
+ const info = TIERS[tier];
84
+ const color = POSTURE_COLOR[info.posture] ?? "muted";
85
+ return theme.fg(color, glyphFor(tier));
86
+ }
87
+
88
+ // Server/prefix baseline tier for a provider+model, before any live attestation.
89
+ function baselineTier(provider: string, id: string): PrivacyTier {
90
+ if (provider === "privateer") {
91
+ return (
92
+ accountBaselineTier(id) ??
93
+ (id.startsWith("near/") || id.startsWith("tinfoil/") ? "tee-unverified" : "standard")
94
+ );
95
+ }
96
+ if (provider === "tinfoil" || provider === "nearai") return "tee-unverified";
97
+ if (provider === "ollama") return "local";
98
+ return "standard";
99
+ }
100
+
101
+ // A row claims a TEE and is therefore worth attesting live (to go green — or to
102
+ // honestly drop to standard if the attestation fails). ZDR/standard rows are left
103
+ // on their server baseline; attesting them would only ever weaken an honest label.
104
+ function isTeeCandidate(row: Row): boolean {
105
+ if (row.tier !== "tee-verified" && row.tier !== "tee-unverified") return false;
106
+ if (row.provider === "privateer") return row.id.startsWith("near/") || row.id.startsWith("tinfoil/");
107
+ return row.provider === "tinfoil" || row.provider === "nearai";
108
+ }
109
+
110
+ // Run the live attestation for one TEE-candidate row. privateer/* goes through the
111
+ // account server-proxy path (accountPosture); other providers use pi-privacy's
112
+ // direct client attestation. Returns undefined on any failure (keep the baseline).
113
+ async function attestRow(row: Row): Promise<PrivacyTier | undefined> {
114
+ try {
115
+ if (row.provider === "privateer") {
116
+ const res = await accountPosture(row.id);
117
+ return res.tier;
118
+ }
119
+ const apiKey =
120
+ row.provider === "nearai"
121
+ ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY
122
+ : undefined;
123
+ const res = await verifyModelPosture(row.provider, row.id, { apiKey });
124
+ return res.tier;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ // The search text a row is matched against — id, provider, name, and the tier label
131
+ // so a user can type "tee" or "zdr" to filter by posture.
132
+ function searchText(row: Row): string {
133
+ return `${row.provider} ${row.provider}/${row.id} ${row.id} ${row.name} ${TIERS[row.tier].label}`;
134
+ }
135
+
136
+ // Strongest-privacy-first, then provider, then id — so the safest options surface at
137
+ // the top of an unfiltered list.
138
+ function sortRows(rows: Row[]): Row[] {
139
+ return [...rows].sort((a, b) => {
140
+ const r = tierRank(a.tier) - tierRank(b.tier);
141
+ if (r !== 0) return r;
142
+ if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
143
+ return a.id.localeCompare(b.id);
144
+ });
145
+ }
146
+
147
+ const MAX_VISIBLE = 12;
148
+
149
+ class ModelsPicker extends Container {
150
+ private tui: TuiLike;
151
+ private theme: ThemeLike;
152
+ private rows: Row[];
153
+ private filtered: Row[];
154
+ private selectedIndex = 0;
155
+ private searchInput: Input;
156
+ private listContainer: Container;
157
+ private legendText: Text;
158
+ private blurbText: Text;
159
+ private currentId: string | undefined;
160
+ private onSelect: (row: Row) => void;
161
+ private onCancel: () => void;
162
+ private _focused = false;
163
+
164
+ constructor(opts: {
165
+ tui: TuiLike;
166
+ theme: ThemeLike;
167
+ rows: Row[];
168
+ current: ModelLike | undefined;
169
+ onSelect: (row: Row) => void;
170
+ onCancel: () => void;
171
+ }) {
172
+ super();
173
+ this.tui = opts.tui;
174
+ this.theme = opts.theme;
175
+ this.rows = sortRows(opts.rows);
176
+ this.filtered = this.rows;
177
+ this.currentId = opts.current ? `${opts.current.provider}/${opts.current.id}` : undefined;
178
+ this.onSelect = opts.onSelect;
179
+ this.onCancel = opts.onCancel;
180
+
181
+ const t = this.theme;
182
+ this.addChild(new Text(t.fg("accent", t.bold("Select a model")), 1, 0));
183
+ this.legendText = new Text(this.legend(), 1, 0);
184
+ this.addChild(this.legendText);
185
+ this.addChild(new Spacer(1));
186
+
187
+ this.searchInput = new Input();
188
+ this.searchInput.onSubmit = () => {
189
+ const row = this.filtered[this.selectedIndex];
190
+ if (row) this.onSelect(row);
191
+ };
192
+ this.addChild(this.searchInput);
193
+ this.addChild(new Spacer(1));
194
+
195
+ this.listContainer = new Container();
196
+ this.addChild(this.listContainer);
197
+ this.addChild(new Spacer(1));
198
+
199
+ this.blurbText = new Text("", 1, 0);
200
+ this.addChild(this.blurbText);
201
+ this.addChild(
202
+ new Text(
203
+ t.fg("muted", "↑↓ navigate ⏎ select type to search esc cancel"),
204
+ 1,
205
+ 0,
206
+ ),
207
+ );
208
+
209
+ // Keep selection on the current model if it's in the list.
210
+ const cur = this.rows.findIndex((r) => `${r.provider}/${r.id}` === this.currentId);
211
+ if (cur >= 0) this.selectedIndex = cur;
212
+
213
+ this.updateList();
214
+ void this.attestTeeRows();
215
+ }
216
+
217
+ get focused(): boolean {
218
+ return this._focused;
219
+ }
220
+ set focused(v: boolean) {
221
+ this._focused = v;
222
+ this.searchInput.focused = v;
223
+ }
224
+
225
+ private legend(): string {
226
+ const t = this.theme;
227
+ return (
228
+ `${shield(t, "tee-verified")} ${t.fg("muted", "TEE")} ` +
229
+ `${shield(t, "zdr-enforced")} ${t.fg("muted", "ZDR")} ` +
230
+ `${shield(t, "standard")} ${t.fg("muted", "Standard")} ` +
231
+ t.fg("muted", "— green = verified/enforced, yellow = claimed")
232
+ );
233
+ }
234
+
235
+ // Fire attestation for every TEE-candidate row in parallel; upgrade each shield in
236
+ // place as its result lands. Cheap in practice (the enabled catalog is mostly
237
+ // ZDR/standard), and never blocks the picker — the baseline renders immediately.
238
+ private async attestTeeRows(): Promise<void> {
239
+ const candidates = this.rows.filter((r) => isTeeCandidate(r) && !r.attested);
240
+ if (!candidates.length) return;
241
+ await Promise.allSettled(
242
+ candidates.map(async (row) => {
243
+ const tier = await attestRow(row);
244
+ row.attested = true;
245
+ if (tier && tier !== row.tier) {
246
+ row.tier = tier;
247
+ }
248
+ }),
249
+ );
250
+ // Re-sort (a newly-verified TEE may move up) and re-render once, preserving the
251
+ // highlighted model across the reshuffle.
252
+ const selected = this.filtered[this.selectedIndex];
253
+ this.rows = sortRows(this.rows);
254
+ this.applyFilter(this.searchInput.getValue(), selected);
255
+ this.legendText.setText(this.legend());
256
+ this.tui.requestRender();
257
+ }
258
+
259
+ private applyFilter(query: string, keep?: Row): void {
260
+ this.filtered = query
261
+ ? fuzzyFilter(this.rows, query, (r: Row) => searchText(r))
262
+ : this.rows;
263
+ if (keep) {
264
+ const i = this.filtered.findIndex((r) => r.provider === keep.provider && r.id === keep.id);
265
+ this.selectedIndex = i >= 0 ? i : 0;
266
+ } else {
267
+ this.selectedIndex = 0;
268
+ }
269
+ this.updateList();
270
+ }
271
+
272
+ private updateList(): void {
273
+ const t = this.theme;
274
+ this.listContainer.clear();
275
+ if (this.filtered.length === 0) {
276
+ this.listContainer.addChild(new Text(t.fg("muted", " No matching models"), 1, 0));
277
+ this.blurbText.setText("");
278
+ return;
279
+ }
280
+ if (this.selectedIndex >= this.filtered.length) this.selectedIndex = this.filtered.length - 1;
281
+
282
+ const start = Math.max(
283
+ 0,
284
+ Math.min(this.selectedIndex - Math.floor(MAX_VISIBLE / 2), this.filtered.length - MAX_VISIBLE),
285
+ );
286
+ const end = Math.min(start + MAX_VISIBLE, this.filtered.length);
287
+ for (let i = start; i < end; i++) {
288
+ const row = this.filtered[i];
289
+ const isSel = i === this.selectedIndex;
290
+ const isCur = `${row.provider}/${row.id}` === this.currentId;
291
+ const mark = shield(t, row.tier);
292
+ const label = TIERS[row.tier].label;
293
+ const idText = isSel ? t.fg("accent", row.id) : t.fg("text", row.id);
294
+ const prov = t.fg("muted", `[${row.provider}]`);
295
+ const tierText = t.fg(POSTURE_COLOR[TIERS[row.tier].posture] ?? "muted", label);
296
+ const check = isCur ? t.fg("success", " ✓") : "";
297
+ const prefix = isSel ? t.fg("accent", "→ ") : " ";
298
+ this.listContainer.addChild(
299
+ new Text(`${prefix}${mark} ${idText} ${prov} ${tierText}${check}`, 1, 0),
300
+ );
301
+ }
302
+ if (start > 0 || end < this.filtered.length) {
303
+ this.listContainer.addChild(
304
+ new Text(t.fg("muted", ` (${this.selectedIndex + 1}/${this.filtered.length})`), 1, 0),
305
+ );
306
+ }
307
+ // Honest one-liner for the highlighted model — states the LIMIT of the claim.
308
+ const sel = this.filtered[this.selectedIndex];
309
+ if (sel) this.blurbText.setText(t.fg("muted", ` ${TIERS[sel.tier].blurb}`));
310
+ }
311
+
312
+ // Seed the search box (from `/models <query>`) and filter to match.
313
+ setQuery(q: string): void {
314
+ this.searchInput.setValue(q);
315
+ this.applyFilter(q);
316
+ }
317
+
318
+ handleInput(keyData: string): void {
319
+ const kb = getKeybindings();
320
+ if (kb.matches(keyData, "tui.select.up")) {
321
+ if (!this.filtered.length) return;
322
+ this.selectedIndex =
323
+ this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1;
324
+ this.updateList();
325
+ } else if (kb.matches(keyData, "tui.select.down")) {
326
+ if (!this.filtered.length) return;
327
+ this.selectedIndex =
328
+ this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1;
329
+ this.updateList();
330
+ } else if (kb.matches(keyData, "tui.select.confirm")) {
331
+ const row = this.filtered[this.selectedIndex];
332
+ if (row) this.onSelect(row);
333
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
334
+ this.onCancel();
335
+ } else {
336
+ // Everything else edits the search box, then re-filters.
337
+ this.searchInput.handleInput(keyData);
338
+ this.applyFilter(this.searchInput.getValue());
339
+ }
340
+ }
341
+ }
342
+
343
+ // The extension entrypoint. Registers `/models`; the redirect patch also routes
344
+ // `/model` here so the shielded picker is the single model surface.
345
+ export default function privateerModels(pi: {
346
+ registerCommand?: (name: string, opts: unknown) => void;
347
+ setModel?: (model: ModelLike) => Promise<boolean> | boolean;
348
+ }): void {
349
+ if (typeof pi.registerCommand !== "function") return;
350
+
351
+ pi.registerCommand("models", {
352
+ description: "Pick a model with its privacy posture (TEE / ZDR / standard) — searchable",
353
+ argumentHint: "[search]",
354
+ handler: async (args: string, ctx: any): Promise<void> => {
355
+ const ui = ctx?.ui;
356
+ if (!ui?.custom) {
357
+ ctx?.ui?.notify?.("The model picker needs an interactive terminal.", "warning");
358
+ return;
359
+ }
360
+ const registry = ctx.modelRegistry;
361
+ // Refresh so a just-fetched account catalog / models.json edit is reflected.
362
+ try {
363
+ registry?.refresh?.();
364
+ } catch {
365
+ /* non-fatal */
366
+ }
367
+ // Make sure the account baseline tiers are loaded (first-open may race the
368
+ // provider's background fetch). Best-effort; falls back to prefix heuristics.
369
+ if (!accountCatalogLoaded()) {
370
+ try {
371
+ await fetchAccountCatalog();
372
+ } catch {
373
+ /* keep heuristics */
374
+ }
375
+ }
376
+
377
+ let available: ModelLike[] = [];
378
+ try {
379
+ available = (await registry.getAvailable()) as ModelLike[];
380
+ } catch (e) {
381
+ ui.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
382
+ return;
383
+ }
384
+ if (!available.length) {
385
+ ui.notify?.("No models available. Use /login to add a provider.", "warning");
386
+ return;
387
+ }
388
+
389
+ const rows: Row[] = available.map((m) => ({
390
+ provider: m.provider,
391
+ id: m.id,
392
+ name: m.name ?? m.id,
393
+ model: m,
394
+ tier: baselineTier(m.provider, m.id),
395
+ }));
396
+
397
+ const initialQuery = String(args ?? "").trim();
398
+ const chosen: Row | undefined = await ui.custom(
399
+ (tui: TuiLike, theme: ThemeLike, _kb: unknown, close: (result?: Row) => void) => {
400
+ const picker = new ModelsPicker({
401
+ tui,
402
+ theme,
403
+ rows,
404
+ current: ctx.model,
405
+ onSelect: (row) => close(row),
406
+ onCancel: () => close(undefined),
407
+ });
408
+ if (initialQuery) picker.setQuery(initialQuery);
409
+ return picker;
410
+ },
411
+ );
412
+
413
+ if (!chosen) return;
414
+ try {
415
+ const ok = await pi.setModel!(chosen.model);
416
+ if (ok === false) {
417
+ ui.notify?.(`No API key for ${chosen.provider}/${chosen.id}.`, "warning");
418
+ return;
419
+ }
420
+ ui.notify?.(
421
+ `Model: ${chosen.provider}/${chosen.id} · ${TIERS[chosen.tier].label}`,
422
+ "info",
423
+ );
424
+ } catch (e) {
425
+ ui.notify?.(`Couldn't switch model: ${(e as Error).message}`, "error");
426
+ }
427
+ },
428
+ });
429
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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",
@@ -37,6 +37,7 @@
37
37
  "bin",
38
38
  "src",
39
39
  "extensions",
40
+ "patches",
40
41
  "README.md",
41
42
  "LICENSE"
42
43
  ],
@@ -46,7 +47,8 @@
46
47
  "channels": "node --env-file=.env --import tsx src/channels/run.ts",
47
48
  "dev": "tsx watch src/main.ts",
48
49
  "typecheck": "tsc --noEmit",
49
- "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
50
+ "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done",
51
+ "postinstall": "patch-package"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=22.19.0"
@@ -60,11 +62,12 @@
60
62
  "@noble/ciphers": "^2.1.1",
61
63
  "@noble/curves": "^1.9.7",
62
64
  "@noble/hashes": "^1.7.1",
65
+ "patch-package": "^8.0.1",
63
66
  "pi-mcp-adapter": "^2.11.0",
64
67
  "pi-privacy": "^0.3.0",
65
68
  "pi-subagents": "^0.34.0",
66
- "privateer-workflow": "^0.1.0",
67
69
  "picomatch": "^4.0.4",
70
+ "privateer-workflow": "^0.1.0",
68
71
  "tsx": "^4.16.0",
69
72
  "typebox": "^1.3.4",
70
73
  "undici": "^7.28.0",
@@ -0,0 +1,23 @@
1
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
2
+ index 5d65200..a997ad7 100644
3
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
4
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
5
+ @@ -2042,7 +2042,17 @@ export class InteractiveMode {
6
+ if (text === "/model" || text.startsWith("/model ")) {
7
+ const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
8
+ this.editor.setText("");
9
+ - await this.handleModelCommand(searchTerm);
10
+ + // Privateer redirect: when the shielded picker extension is loaded
11
+ + // (registers `/models`), route `/model` to it so users get the
12
+ + // privacy-posture picker via muscle memory. Falls back to Pi's
13
+ + // built-in picker when the extension isn't present.
14
+ + const privateerModels = searchTerm ? `/models ${searchTerm}` : "/models";
15
+ + if (this.isExtensionCommand(privateerModels)) {
16
+ + await this.session.prompt(privateerModels);
17
+ + }
18
+ + else {
19
+ + await this.handleModelCommand(searchTerm);
20
+ + }
21
+ return;
22
+ }
23
+ if (text === "/export" || text.startsWith("/export ")) {
@@ -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
+ }
@@ -50,6 +50,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
50
50
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
51
51
  import { redactText, collectSecrets } from "../util/redact.ts";
52
52
  import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
53
+ import { isHosted } from "../config/hosted.ts";
53
54
 
54
55
  // The safe, read-only toolset for unattended runs — Pi builtins with no
55
56
  // write/edit/bash, so a routine firing with nobody watching can't mutate the
@@ -59,6 +60,10 @@ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
59
60
  const SAFE_TOOLS = ["read", "grep", "find", "ls"];
60
61
 
61
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;
62
67
  const MAX_CLOUD_PLAINTEXT = 45_000;
63
68
  // How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
64
69
  // answer before it fail-closes to "no response" (the runner then defers the run). Bounds
@@ -158,6 +163,11 @@ export class Daemon {
158
163
  private relay?: RelayClient;
159
164
  private controllerAttached = false;
160
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();
161
171
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
162
172
  // terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
163
173
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
@@ -406,6 +416,8 @@ export class Daemon {
406
416
 
407
417
  private onControllerAttached(): void {
408
418
  this.controllerAttached = true;
419
+ this.markActivity(); // hosted: a driver is present — reset the idle timer
420
+
409
421
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
410
422
  // Version + this terminal's identity public key (so the app can confirm this is
411
423
  // the terminal it PINNED at link time before sealing channel tokens to it). No
@@ -506,6 +518,74 @@ export class Daemon {
506
518
  await this.runRoutine(r);
507
519
  }
508
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();
509
589
  }
510
590
 
511
591
  // Execute a routine to completion and deliver the result. The one rewired seam:
@@ -514,6 +594,7 @@ export class Daemon {
514
594
  async runRoutine(routine: Routine): Promise<IpcResponse> {
515
595
  if (this.running.has(routine.id)) return { ok: false, message: "already running" };
516
596
  this.running.add(routine.id);
597
+ this.markActivity(); // hosted: work in progress — don't idle-suspend under it
517
598
  log(`running routine "${routine.name}"`);
518
599
 
519
600
  const config = loadDaemonConfig();
@@ -47,20 +47,88 @@ function seedModel(id: string) {
47
47
  };
48
48
  }
49
49
 
50
- // Fetch the account channel's enabled model catalog. `GET /api/models` is the server's
51
- // public list of billable models (`{ models: [{ modelId }] }`) — the same set the app
52
- // shows. (The `/api/agent/v1` base only implements chat/completions, no /models route.)
53
- // Falls back to DEFAULT_MODELS on any failure.
54
- export async function fetchAccountModels(): Promise<string[]> {
50
+ // One catalog entry with its server-asserted baseline privacy tier.
51
+ export interface AccountModelInfo {
52
+ id: string;
53
+ tier: PrivacyTier;
54
+ }
55
+
56
+ // The set of tier strings pi-privacy defines (posture/tiers.ts). We only trust a
57
+ // server-supplied tier if it's one of these — anything else falls back to a prefix
58
+ // heuristic, so a server typo or older/newer server can never inject a bogus tier.
59
+ const VALID_TIERS = new Set<PrivacyTier>([
60
+ "tee-verified",
61
+ "tee-unverified",
62
+ "local",
63
+ "zdr-enforced",
64
+ "zdr-policy",
65
+ "standard",
66
+ ]);
67
+
68
+ // Baseline tier when the server doesn't (yet) send one. Honest-labeling rule: a
69
+ // confidential-compute model is only *claimed* here (tee-unverified) — the picker
70
+ // upgrades it to tee-verified live via attestation (accountPosture). Everything
71
+ // else with no server signal is "standard": we don't assert ZDR we can't back.
72
+ function tierFromPrefix(modelId: string): PrivacyTier {
73
+ return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") ? "tee-unverified" : "standard";
74
+ }
75
+
76
+ function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
77
+ return tier && VALID_TIERS.has(tier as PrivacyTier) ? (tier as PrivacyTier) : tierFromPrefix(modelId);
78
+ }
79
+
80
+ // Baseline tiers for the account catalog, keyed by modelId. Populated by
81
+ // fetchAccountCatalog() so the /models picker can shield each row without re-fetching.
82
+ // A live NEAR attestation (accountPosture) can still upgrade a row to tee-verified.
83
+ const accountTierMap = new Map<string, PrivacyTier>();
84
+
85
+ // The server-asserted baseline tier for an account model, or undefined if we haven't
86
+ // seen it in a catalog fetch. Used by the /models picker (privateer-models.ts).
87
+ export function accountBaselineTier(modelId: string): PrivacyTier | undefined {
88
+ return accountTierMap.get(modelId);
89
+ }
90
+
91
+ // Whether a catalog fetch has populated the tier map at least once. The /models
92
+ // picker uses this to decide if it must fetch before opening (first-open race with
93
+ // the provider's background fetch) vs. render immediately from the cached tiers.
94
+ export function accountCatalogLoaded(): boolean {
95
+ return accountTierMap.size > 0;
96
+ }
97
+
98
+ // Fetch the account channel's enabled model catalog WITH per-model privacy tiers.
99
+ // `GET /api/models` is the server's public list of billable models
100
+ // (`{ models: [{ modelId, privacy: { tier } }] }`) — the same set the app shows.
101
+ // (The `/api/agent/v1` base only implements chat/completions, no /models route.)
102
+ // Falls back to DEFAULT_MODELS (prefix-derived tiers) on any failure. Side effect:
103
+ // refreshes accountTierMap.
104
+ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
105
+ const fallback = (): AccountModelInfo[] =>
106
+ DEFAULT_MODELS.map((id) => ({ id, tier: tierFromPrefix(id) }));
107
+ let infos: AccountModelInfo[];
55
108
  try {
56
109
  const res = await fetch(`${serverBaseUrl()}/api/models`);
57
- if (!res.ok) return DEFAULT_MODELS;
58
- const data = (await res.json()) as { models?: { modelId?: string }[] };
59
- const ids = (data.models ?? []).map((m) => m.modelId).filter((x): x is string => !!x);
60
- return ids.length ? ids : DEFAULT_MODELS;
110
+ if (!res.ok) {
111
+ infos = fallback();
112
+ } else {
113
+ const data = (await res.json()) as {
114
+ models?: { modelId?: string; privacy?: { tier?: string } }[];
115
+ };
116
+ const parsed = (data.models ?? [])
117
+ .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
118
+ .filter((x): x is AccountModelInfo => !!x);
119
+ infos = parsed.length ? parsed : fallback();
120
+ }
61
121
  } catch {
62
- return DEFAULT_MODELS;
122
+ infos = fallback();
63
123
  }
124
+ accountTierMap.clear();
125
+ for (const info of infos) accountTierMap.set(info.id, info.tier);
126
+ return infos;
127
+ }
128
+
129
+ // Back-compat id-only view over fetchAccountCatalog (registerProvider only needs ids).
130
+ export async function fetchAccountModels(): Promise<string[]> {
131
+ return (await fetchAccountCatalog()).map((m) => m.id);
64
132
  }
65
133
 
66
134
  // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
@@ -199,8 +267,10 @@ export function makeAccountProvider() {
199
267
  models: ids.map(seedModel),
200
268
  });
201
269
  register(DEFAULT_MODELS); // immediate: provider exists this tick
202
- void fetchAccountModels()
203
- .then((ids) => ids.length && register(ids)) // refine to the live catalog
270
+ // Refine to the live catalog. fetchAccountCatalog also populates accountTierMap
271
+ // as a side effect, so the /models picker can shield each row without re-fetching.
272
+ void fetchAccountCatalog()
273
+ .then((infos) => infos.length && register(infos.map((m) => m.id)))
204
274
  .catch(() => {
205
275
  /* keep the fallback model */
206
276
  });