privateer-agent 0.12.5 → 0.12.7

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.
@@ -0,0 +1,187 @@
1
+ // Spawn records — the per-folder defaults an agent starts with on this machine.
2
+ //
3
+ // A "spawn" is one agent pointed at one folder: the desktop's window sessions are
4
+ // the visible case, but the record is deliberately machine-level rather than
5
+ // desktop-level, because ~/.privateer is shared with the CLI and a `privateer` run
6
+ // in the same folder should be able to honour the same defaults later.
7
+ //
8
+ // WHY NOT THE PROJECT FOLDER. Pi already has a project scope — `.privateer/` and
9
+ // `.pi/` under cwd (see PROJECT_CONFIG_DIR_NAMES) — and this is NOT that. Project
10
+ // config lives in the tree, travels with a clone, and lands in the user's commits.
11
+ // These records are the opposite by choice: which model YOU run in a folder on THIS
12
+ // computer is a local preference, not a property of the project, and writing it into
13
+ // someone's repo would be a surprise the first time they `git status`. So they live
14
+ // under the global dir, keyed by the folder's real path, and the folder itself is
15
+ // left untouched. (PRIVATEER.md is the deliberate exception — that one IS about the
16
+ // project and belongs in the tree.)
17
+ //
18
+ // Keying is by REAL path: a symlinked checkout (/var → /private/var on macOS) must
19
+ // not read as a second folder, or a spawn would silently lose its defaults depending
20
+ // on which route the user opened it by.
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+ import { globalDir } from "./paths.ts";
26
+
27
+ export interface SpawnRecord {
28
+ /** The folder, as resolved when the record was written. */
29
+ path: string;
30
+ /** Preferred model as "provider/id", or null to take the account default. */
31
+ model: string | null;
32
+ /** MCP connector names this folder's agent starts with. */
33
+ connectors: string[];
34
+ /** Epoch ms. */
35
+ createdAt: number;
36
+ /** Epoch ms of the last spawn opened on this folder, or null if never. */
37
+ lastOpenedAt: number | null;
38
+ }
39
+
40
+ /** All spawn records: <globalDir>/spawns/<key>/spawn.json (+ a per-spawn skills/ dir). */
41
+ export function spawnsDir(): string {
42
+ return join(globalDir(), "spawns");
43
+ }
44
+
45
+ // The identity of a folder for record purposes. realpathSync resolves symlinks so
46
+ // two routes to one checkout share a record; a path that doesn't exist yet (a folder
47
+ // the user is about to create) falls back to the lexical resolution rather than
48
+ // throwing. Case-folded on Windows, where the same folder is reachable as C:\Foo and
49
+ // c:\foo and neither spelling is more correct than the other.
50
+ function identity(path: string): string {
51
+ let real: string;
52
+ try {
53
+ real = realpathSync(resolve(path));
54
+ } catch {
55
+ real = resolve(path);
56
+ }
57
+ return process.platform === "win32" ? real.toLowerCase() : real;
58
+ }
59
+
60
+ /** Stable per-folder key. 64 bits of sha256 — collisions are checked, not assumed. */
61
+ export function spawnKey(path: string): string {
62
+ return createHash("sha256").update(identity(path)).digest("hex").slice(0, 16);
63
+ }
64
+
65
+ /** This spawn's own directory (records + per-folder skills). */
66
+ export function spawnDir(path: string): string {
67
+ return join(spawnsDir(), spawnKey(path));
68
+ }
69
+
70
+ /**
71
+ * Per-spawn skills, injected into the session as an extra skill path so a folder can
72
+ * carry its own without a `.privateer/skills` appearing in the user's tree.
73
+ */
74
+ export function spawnSkillsDir(path: string): string {
75
+ return join(spawnDir(path), "skills");
76
+ }
77
+
78
+ function recordPath(path: string): string {
79
+ return join(spawnDir(path), "spawn.json");
80
+ }
81
+
82
+ // Tolerant on read: these files sit in a directory users are invited to inspect, and
83
+ // a hand-edited or half-written one must degrade to "no record" rather than take the
84
+ // app down on launch. Unknown keys are dropped, not preserved — the shape is ours.
85
+ function parse(raw: string): SpawnRecord | null {
86
+ let obj: any;
87
+ try {
88
+ obj = JSON.parse(raw);
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (!obj || typeof obj !== "object" || typeof obj.path !== "string" || !obj.path) return null;
93
+ return {
94
+ path: obj.path,
95
+ model: typeof obj.model === "string" && obj.model.includes("/") ? obj.model : null,
96
+ connectors: Array.isArray(obj.connectors) ? obj.connectors.filter((c: unknown) => typeof c === "string") : [],
97
+ createdAt: Number.isFinite(obj.createdAt) ? obj.createdAt : 0,
98
+ lastOpenedAt: Number.isFinite(obj.lastOpenedAt) ? obj.lastOpenedAt : null,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The record for `path`, or null if there is none.
104
+ *
105
+ * A record whose stored path disagrees with the folder we asked about is treated as a
106
+ * miss: that is the 64-bit collision case, and answering with another folder's model
107
+ * and connectors would be worse than answering with nothing.
108
+ */
109
+ export function readSpawn(path: string): SpawnRecord | null {
110
+ const file = recordPath(path);
111
+ if (!existsSync(file)) return null;
112
+ let rec: SpawnRecord | null;
113
+ try {
114
+ rec = parse(readFileSync(file, "utf8"));
115
+ } catch {
116
+ return null;
117
+ }
118
+ if (!rec) return null;
119
+ return identity(rec.path) === identity(path) ? rec : null;
120
+ }
121
+
122
+ /**
123
+ * Create or update the record for `path` and return the result. Absent fields keep
124
+ * their stored value, so a caller that only knows the model doesn't have to read
125
+ * first and risk clobbering connectors written by another window.
126
+ */
127
+ export function writeSpawn(path: string, patch: Partial<Omit<SpawnRecord, "path" | "createdAt">>, now = Date.now()): SpawnRecord {
128
+ const existing = readSpawn(path);
129
+ const next: SpawnRecord = {
130
+ path: resolve(path),
131
+ model: patch.model !== undefined ? patch.model : existing?.model ?? null,
132
+ connectors: patch.connectors !== undefined ? patch.connectors : existing?.connectors ?? [],
133
+ createdAt: existing?.createdAt || now,
134
+ lastOpenedAt: patch.lastOpenedAt !== undefined ? patch.lastOpenedAt : existing?.lastOpenedAt ?? null,
135
+ };
136
+ const dir = spawnDir(path);
137
+ mkdirSync(dir, { recursive: true });
138
+ // 0600 like the rest of the global dir: a record names a folder on this machine
139
+ // and the connectors it runs, which is nobody else's business on a shared box.
140
+ writeFileSync(recordPath(path), JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
141
+ return next;
142
+ }
143
+
144
+ /** Stamp a spawn as opened now — the roster's "most recent first" ordering. */
145
+ export function touchSpawn(path: string, now = Date.now()): SpawnRecord {
146
+ return writeSpawn(path, { lastOpenedAt: now }, now);
147
+ }
148
+
149
+ /**
150
+ * Every record, most recently opened first (never-opened ones last, then by path so
151
+ * the order is stable). Unreadable entries are skipped rather than surfaced as blanks.
152
+ */
153
+ export function listSpawns(): SpawnRecord[] {
154
+ const dir = spawnsDir();
155
+ if (!existsSync(dir)) return [];
156
+ let keys: string[];
157
+ try {
158
+ keys = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
159
+ } catch {
160
+ return [];
161
+ }
162
+ const out: SpawnRecord[] = [];
163
+ for (const key of keys) {
164
+ try {
165
+ const rec = parse(readFileSync(join(dir, key, "spawn.json"), "utf8"));
166
+ if (rec) out.push(rec);
167
+ } catch {
168
+ // No record file, or an unreadable one — not a spawn we can offer.
169
+ }
170
+ }
171
+ return out.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0) || a.path.localeCompare(b.path));
172
+ }
173
+
174
+ /** Drop a folder's record (and its per-spawn skills). True if there was one. */
175
+ export function forgetSpawn(path: string): boolean {
176
+ const dir = spawnDir(path);
177
+ if (!existsSync(dir)) return false;
178
+ // Only remove a directory that actually holds THIS folder's record, so a collision
179
+ // (or a stale key) can't delete another spawn's skills.
180
+ if (!readSpawn(path)) return false;
181
+ try {
182
+ rmSync(dir, { recursive: true, force: true });
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
@@ -43,7 +43,12 @@ export interface GateController {
43
43
  cwd: string;
44
44
  confineToCwd?: boolean;
45
45
  getRemote?(): boolean;
46
+ // The controller raised the flag: total bypass for remote turns — see
47
+ // ModeGate.getNoQuarter.
46
48
  getNoQuarter?(): boolean;
49
+ // The weaker "auto" posture used by the non-interactive runtimes (ACP, channels):
50
+ // bypass-equivalent, dangerous/destructive still relayed. See ModeGate.getAutoApprove.
51
+ getAutoApprove?(): boolean;
47
52
  // Total bypass — see ModeGate.getSkipAllPermissions. Set by the `--no-quarter`
48
53
  // launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
49
54
  // action with no prompt.
@@ -129,13 +134,15 @@ export async function decideToolCall(
129
134
  setMode: ctrl.setMode,
130
135
  allowlist: ctrl.allowlist,
131
136
  allowedOutsideRoots: ctrl.allowedOutsideRoots,
132
- // Default to the built-in dangerous-command patterns so bypass / no-quarter /
137
+ // Default to the built-in dangerous-command patterns so bypass / "auto"-posture /
133
138
  // headless-subagent runs still force dangerous shell + secret-exfil to "ask"
134
139
  // (→ headless deny). A controller can extend, but never silently disable, this.
140
+ // The two no-quarter switches sit above the denylist by design and clear it.
135
141
  denylist: ctrl.denylist ?? DEFAULT_DENYLIST,
136
142
  ask,
137
143
  getRemote: ctrl.getRemote,
138
144
  getNoQuarter: ctrl.getNoQuarter,
145
+ getAutoApprove: ctrl.getAutoApprove,
139
146
  getSkipAllPermissions: ctrl.getSkipAllPermissions,
140
147
  });
141
148
 
package/src/harbor/ipc.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createServer, createConnection, type Socket, type Server } from "node:net";
2
2
  import { existsSync, unlinkSync, chmodSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import { join } from "node:path";
4
5
  import { globalDir } from "../config/paths.ts";
5
6
  import type { Routine } from "../routines/schema.ts";
@@ -8,7 +9,26 @@ import type { Routine } from "../routines/schema.ts";
8
9
  // is one JSON request per connection, answered with one JSON response, both
9
10
  // newline-terminated. Kept tiny and local — nothing crosses the machine boundary.
10
11
 
12
+ const isWindows = process.platform === "win32";
13
+
14
+ /**
15
+ * Where the harbor listens.
16
+ *
17
+ * POSIX: a unix socket inside PRIVATEER_HOME, so it inherits that directory's
18
+ * ownership and lives beside the log it writes.
19
+ *
20
+ * Windows has no unix sockets: `listen()` there accepts ONLY a name under
21
+ * \\.\pipe\, and handing it a file path fails — which is why `privateer harbor`
22
+ * could never start on Windows at all. The pipe name is derived from
23
+ * globalDir() so a non-default PRIVATEER_HOME still gets its own harbor (the
24
+ * pipe namespace is machine-global and has no directories to separate them),
25
+ * and hashed because that namespace takes no backslashes.
26
+ */
11
27
  export function harborSocketPath(): string {
28
+ if (isWindows) {
29
+ const id = createHash("sha256").update(globalDir().toLowerCase()).digest("hex").slice(0, 16);
30
+ return `\\\\.\\pipe\\privateer-harbor-${id}`;
31
+ }
12
32
  return join(globalDir(), "harbor.sock");
13
33
  }
14
34
 
@@ -115,6 +135,9 @@ export function startIpcServer(handler: IpcHandler): Promise<Server> {
115
135
  const server = build();
116
136
  server.once("error", (err: NodeJS.ErrnoException) => {
117
137
  if (err.code !== "EADDRINUSE") { reject(err); return; }
138
+ // Windows has nothing to reclaim: a named pipe exists only while a
139
+ // process holds it, so EADDRINUSE there always means a live harbor.
140
+ if (isWindows) { reject(new HarborAlreadyRunningError()); return; }
118
141
  void probeExistingListener(path).then((live) => {
119
142
  if (live) { reject(new HarborAlreadyRunningError()); return; }
120
143
  if (reclaimed) { reject(err); return; } // already reclaimed once — give up
@@ -140,7 +163,10 @@ export function startIpcServer(handler: IpcHandler): Promise<Server> {
140
163
  export function sendToHarbor(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
141
164
  const path = harborSocketPath();
142
165
  return new Promise<IpcResponse>((resolve, reject) => {
143
- if (!existsSync(path)) {
166
+ // The fast "nothing is there" path. Skipped on Windows: a named pipe isn't a
167
+ // filesystem entry, so existsSync() is false even for a live harbor and this
168
+ // check would report every one of them as not running.
169
+ if (!isWindows && !existsSync(path)) {
144
170
  reject(new HarborNotRunningError());
145
171
  return;
146
172
  }
@@ -8,6 +8,7 @@
8
8
  * token — one prompt per env key (masked); `credUrl` is shown as "get one at …"
9
9
  * path — one prompt replacing the `fill` placeholder ARG (a folder, a DSN)
10
10
  * oauth — nothing to type here; you authorize in a browser on THIS machine
11
+ * url — one prompt to confirm the endpoint of a server already running HERE
11
12
  * none — runs locally with no credentials, save it as-is
12
13
  *
13
14
  * Keep this list conservative and correct: a broken command in the catalog is worse
@@ -16,7 +17,7 @@
16
17
  */
17
18
  import type { McpDraft, McpTransport } from "../remote/mcpControl.ts";
18
19
 
19
- export type CatalogNeeds = "token" | "path" | "oauth" | "none";
20
+ export type CatalogNeeds = "token" | "path" | "oauth" | "url" | "none";
20
21
 
21
22
  export interface CatalogEntry {
22
23
  // Stable key for the picker; also the default server name written to config.
@@ -36,6 +37,25 @@ export interface CatalogEntry {
36
37
  fill?: string;
37
38
  // Where to get the credential, shown as a hint in the form.
38
39
  credUrl?: string;
40
+ /**
41
+ * An HTTP server running on THIS MACHINE that needs no credential at all.
42
+ *
43
+ * A third shape alongside `oauth` and a stored bearer token, and it needs its own
44
+ * flag rather than falling out of the URL: every other http entry here is a remote
45
+ * service the user authorizes, so "must authenticate" is derived from
46
+ * `transport === "http"` alone. That is false here — there is nothing to authorize
47
+ * — so the flag is what makes draftFromCatalog emit `auth: "none"`. Without it the
48
+ * adapter goes hunting for an authorization server that does not exist.
49
+ *
50
+ * Always pair with `hosted: false`: a hosted enclave has no route to the user's
51
+ * loopback, and hostedCapable()'s derived rule keys on `oauth`, not on this.
52
+ */
53
+ localHttp?: boolean;
54
+ /**
55
+ * Where to learn how to TURN THE SERVER ON — deliberately not `credUrl`, which
56
+ * means "get a credential here" and would be a lie for an entry that has none.
57
+ */
58
+ docsUrl?: string;
39
59
  // Can this connector run on a HOSTED (Harbor) agent? Leave unset to take the derived
40
60
  // answer from hostedCapable() below; set it explicitly only to say "no" to something
41
61
  // that would otherwise qualify.
@@ -110,7 +130,10 @@ export const MCP_CATALOG: CatalogEntry[] = [
110
130
  label: "Linear",
111
131
  blurb: "Issues and projects. Sign in via browser.",
112
132
  transport: "http",
113
- url: "https://mcp.linear.app/sse",
133
+ // /sse is GONE — it 404s on both GET and POST (checked 2026-07-31). Linear moved
134
+ // to the Streamable HTTP endpoint; the old URL silently failed for anyone who
135
+ // added Linear from this picker. Mirrored from the client copy on 2026-08-06.
136
+ url: "https://mcp.linear.app/mcp",
114
137
  oauth: true,
115
138
  needs: "oauth",
116
139
  },
@@ -320,6 +343,38 @@ export const MCP_CATALOG: CatalogEntry[] = [
320
343
  args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
321
344
  needs: "none",
322
345
  },
346
+
347
+ // ── Local apps that host their own MCP server ───────────────────────────────
348
+ // http, but on 127.0.0.1: nothing to install, nothing to authorize, nothing
349
+ // leaving the machine. See `localHttp` on CatalogEntry for why that needs a flag.
350
+ {
351
+ // Unreal Engine 5.8 embeds an MCP server in the EDITOR PROCESS (plugin
352
+ // `ModelContextProtocol`, surfaced as "Unreal MCP"; the tools come from the
353
+ // "All Toolsets" plugin, which has to be enabled too). Three facts shape this:
354
+ //
355
+ // 1. NO AUTHENTICATION, of any kind. Hence localHttp + auth:"none".
356
+ // 2. LOOPBACK ONLY. It binds per [HTTPServer.Listeners] DefaultBindAddress
357
+ // (default `localhost`) AND rejects non-loopback `Origin` headers — so the
358
+ // agent has to be on the same machine as the editor. True for this CLI and
359
+ // for the desktop app; not true for a hosted agent, hence hosted: false.
360
+ // 3. IT IS ONLY UP WHILE THE EDITOR IS. A connector that fails here usually
361
+ // means "Unreal isn't running", not "this is misconfigured".
362
+ //
363
+ // The port and path are editable in Editor Preferences → Model Context
364
+ // Protocol, so `needs: "url"`: the one setup step is confirming the endpoint
365
+ // rather than pasting a secret. `ModelContextProtocol.GenerateClientConfig` in
366
+ // the UE console prints the URL the editor is actually serving.
367
+ id: "unreal",
368
+ name: "unreal",
369
+ label: "Unreal Engine",
370
+ blurb: "Drive the Unreal Editor — actors, lighting, materials, tests.",
371
+ transport: "http",
372
+ url: "http://127.0.0.1:8000/mcp",
373
+ localHttp: true,
374
+ needs: "url",
375
+ hosted: false,
376
+ docsUrl: "https://dev.epicgames.com/documentation/unreal-engine/unreal-mcp-in-unreal-editor",
377
+ },
323
378
  ];
324
379
 
325
380
  export function catalogEntry(id: string): CatalogEntry | undefined {
@@ -341,9 +396,11 @@ export function promptOrder(e: CatalogEntry): string[] {
341
396
  // and mcpControl treats that as "clear this key" — so a skipped
342
397
  // optional credential is simply absent, never a bogus empty one.
343
398
  // input.fill — the real path/DSN replacing the placeholder ARG (needs:"path").
399
+ // input.url — the endpoint the user confirmed (needs:"url"); blank keeps the
400
+ // catalog default, so a straight <enter> is the documented port.
344
401
  export function draftFromCatalog(
345
402
  e: CatalogEntry,
346
- input: { env?: Record<string, string>; fill?: string } = {},
403
+ input: { env?: Record<string, string>; fill?: string; url?: string } = {},
347
404
  ): McpDraft {
348
405
  const draft: McpDraft = { name: e.name, transport: e.transport };
349
406
 
@@ -354,11 +411,13 @@ export function draftFromCatalog(
354
411
  const filled = input.fill?.trim();
355
412
  draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
356
413
  } else {
357
- draft.url = e.url;
358
- // Every http entry in this catalog is an OAuth connector. Emit the adapter's own
359
- // vocabulary (`auth`) rather than the legacy boolean, so the projection carries
360
- // `auth: "oauth"` and not a bogus boolean in the adapter's OAuthConfig slot.
361
- draft.auth = (e.oauth ?? true) ? "oauth" : "none";
414
+ draft.url = input.url?.trim() || e.url;
415
+ // Emit the adapter's own vocabulary (`auth`) rather than the legacy boolean, so
416
+ // the projection carries a string and not a bogus boolean in the adapter's
417
+ // OAuthConfig slot. A localHttp entry authenticates to nothing — saying "oauth"
418
+ // there would send the adapter looking for an authorization server that does not
419
+ // exist, which fails at connect time rather than at save time.
420
+ draft.auth = e.localHttp ? "none" : (e.oauth ?? true) ? "oauth" : "none";
362
421
  }
363
422
 
364
423
  const keys = Object.keys(e.env ?? {});
@@ -30,10 +30,23 @@ export interface ModeGateDeps {
30
30
  // Hard denies (e.g. plan mode) are still honored without bothering the phone.
31
31
  getRemote?: () => boolean;
32
32
  // True while the controller has toggled no-quarter (unattended) mode: remote
33
- // turns auto-approve like bypass mode so the agent runs to completion without
34
- // pinging the phone. Dangerous shell and alwaysAsk-destructive actions rank
35
- // above bypass in decideAuto, so those still relay for an explicit Allow/Deny.
33
+ // turns auto-approve so the agent runs to completion without pinging the phone.
34
+ // This is the SAME total bypass as the `--no-quarter` launch flag below, only
35
+ // scoped to remote turns — dangerous shell, secret-exfil shapes and alwaysAsk-
36
+ // destructive tools included. It has to be: "no quarter" is a step-away-from-
37
+ // the-keyboard switch, and a mode that still stops on the one command the user
38
+ // walked away from isn't unattended, it's a turn that wedges until it times out
39
+ // (a relayed prompt with nobody to answer it fails closed). A hard "deny" — plan
40
+ // mode — is still honored, so a read-only stance can't be talked around remotely.
36
41
  getNoQuarter?: () => boolean;
42
+ // True while a non-interactive runtime is running under its "auto" posture (the
43
+ // ACP host's `posture: "auto"`, a channel whose role resolves to it). Weaker than
44
+ // no-quarter on purpose: re-decide as if in bypass mode, so ordinary writes and
45
+ // bash run unattended but dangerous shell / alwaysAsk-destructive actions still
46
+ // relay for an explicit Allow/Deny. The party choosing it there is a host config
47
+ // or a chat-app role, not someone who tapped through a confirm on their own
48
+ // terminal, so it does not get to clear the denylist.
49
+ getAutoApprove?: () => boolean;
37
50
  // True when the operator launched with `--no-quarter` (env PRIVATEER_NO_QUARTER):
38
51
  // a session-wide TOTAL bypass of the gate. Every request auto-approves — including
39
52
  // dangerous shell, destructive tools, out-of-cwd and protected-file access — with
@@ -66,10 +79,16 @@ export class ModeGate implements PermissionGate {
66
79
  // remembered — we don't let a remote operator mutate local allowlist/mode.
67
80
  if (this.deps.getRemote?.()) {
68
81
  if (auto === "deny") return "deny";
69
- // No-quarter: re-evaluate as if in bypass mode. Dangerous/destructive
70
- // actions still come back "ask" (they sit above bypass) and fall through
71
- // to the relayed prompt; everything else runs unattended.
72
- if (this.deps.getNoQuarter?.() && decideAuto(req, "bypass", this.deps.allowlist, denylist) === "allow") {
82
+ // No-quarter: the controller has lowered the moat for this session, so
83
+ // auto-allow everything the plan-mode deny above didn't already stop —
84
+ // dangerous shell and alwaysAsk-destructive tools included. Stronger than
85
+ // `/mode bypass` (which keeps those two above it) and deliberately so: it
86
+ // is the remote-scoped twin of the `--no-quarter` flag, and the app's
87
+ // confirm says as much before the flag goes up.
88
+ if (this.deps.getNoQuarter?.()) return "allow";
89
+ // "auto" posture: the weaker cousin — bypass-equivalent, with dangerous and
90
+ // alwaysAsk-destructive actions still falling through to the relayed prompt.
91
+ if (this.deps.getAutoApprove?.() && decideAuto(req, "bypass", this.deps.allowlist, denylist) === "allow") {
73
92
  return "allow";
74
93
  }
75
94
  return (await this.deps.ask(req)) === "deny" ? "deny" : "allow";
@@ -47,10 +47,11 @@ import {
47
47
  const DEFAULT_MODELS = [
48
48
  ACCOUNT_DEFAULT_MODEL_ID,
49
49
  ACCOUNT_NEAR_MODEL_ID,
50
- // The default until 2026-08-01 (see TINFOIL_MODEL_ID). It stays in the floor so a
51
- // user who saved it as their own default still resolves it synchronously at launch,
52
- // rather than falling through to "first model with configured auth" — the BYO dead
53
- // end this seed list exists to prevent.
50
+ // Both former defaults (see TINFOIL_MODEL_ID for the dates). They stay in the floor
51
+ // so a user who saved either as their own default still resolves it synchronously at
52
+ // launch, rather than falling through to "first model with configured auth" — the BYO
53
+ // dead end this seed list exists to prevent.
54
+ "tinfoil/kimi-k2-6",
54
55
  "tinfoil/glm-5-2",
55
56
  "anthropic/claude-opus-5",
56
57
  "anthropic/claude-sonnet-5",
@@ -22,22 +22,43 @@ import { agentDir } from "../config/paths.ts";
22
22
  // One definition, three consumers: this resolver, providers/account.ts's seed catalog,
23
23
  // and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
24
24
  //
25
- // It was `tinfoil/glm-5-2` until 2026-08-01, and the swap is a LATENCY decision, not a
26
- // capability one. Measured over 22 requests spaced 20s apart on the account channel,
27
- // glm-5-2 stalled before its first token on 9 of them — 33s to 98s each, with the
28
- // model demonstrably warm 20 seconds earlier, so it is contention in that deployment
29
- // rather than a cold start anything here can warm up. kimi-k2-6 and gpt-oss-120b, same
30
- // enclave provider, same tier, same transport, stalled 0 times in 20 (medians 1.2s and
31
- // 1.0s). The same run reproduced glm-5-2's stalls on BOTH the sealed and the cleartext
32
- // path, which is what rules out the shim, the relay and the proxy as the cause.
25
+ // This has moved twice. The history matters, because both moves were about the same
26
+ // two axes — first-token latency and reasoning control — pulling in opposite directions:
33
27
  //
34
- // Two consequences worth knowing when revisiting this: glm-5-2 remains in the live
35
- // catalog and is one pick away for anyone who wants it, and kimi-k2-6 reasons on every
36
- // turn with no working off switch (see thinkingProfile in providers/account.ts — its
37
- // levers were probed and none of them moved the reasoning volume). Its reasoning is
38
- // short and it always reaches an answer, which is why that is acceptable here and was
39
- // not for glm-5-2.
40
- export const TINFOIL_MODEL_ID = "tinfoil/kimi-k2-6";
28
+ // • until 2026-08-01 — glm-5-2.
29
+ // • 2026-08-01 → 2026-08-06 — kimi-k2-6, a LATENCY swap, not a capability one. Over
30
+ // 22 requests spaced 20s apart on the account channel, glm-5-2 stalled before its
31
+ // first token on 9 of them — 33s to 98s each, with the model demonstrably warm 20
32
+ // seconds earlier, so it was contention in that deployment rather than a cold start
33
+ // anything here can warm up. kimi-k2-6 and gpt-oss-120b, same enclave provider,
34
+ // same tier, same transport, stalled 0 times in 20 (medians 1.2s and 1.0s). That
35
+ // run reproduced the stalls on BOTH the sealed and the cleartext path, which is
36
+ // what ruled out the shim, the relay and the proxy as the cause.
37
+ // • 2026-08-06 — gpt-oss-120b, on REASONING CONTROL, having ruled out a return to
38
+ // glm-5-2 by re-measuring. kimi-k2-6 reasons on every turn with no working off
39
+ // switch: thinkingProfile (providers/account.ts) omits it deliberately because both
40
+ // levers were probed and neither moved the reasoning volume. On an agent that makes
41
+ // many small tool calls, a toggle that works is worth real latency — but not glm's
42
+ // latency. Re-run of the probe above (14 rounds, 20s apart, TTFT to the first token
43
+ // of any kind, all three models per round):
44
+ //
45
+ // glm-5-2 median 4.6s max 55.0s stalls(>10s) 5/14
46
+ // kimi-k2-6 median 0.9s max 1.1s stalls 0/14
47
+ // gpt-oss-120b median 0.4s max 0.4s stalls 0/14
48
+ //
49
+ // glm-5-2's stalls (55.0 / 46.3 / 46.3 / 55.0 / 29.9s) interleave with 1.0s
50
+ // responses on the same key in the same minute while the other two never waver —
51
+ // the 2026-08-01 signature, unchanged. At ~36% per request a ten-tool-call task
52
+ // stalls with ~99% probability, so it is not defaultable however good the model is.
53
+ // gpt-oss-120b takes the latency crown outright AND honours reasoning_effort
54
+ // (verified: low → 9 reasoning deltas, high → 61), so it is the only one of the
55
+ // three that gives the user a working dial. It is a smaller model than GLM 5.2 and
56
+ // Kimi K2.6; that capability trade was made knowingly. It also serves from NEAR as
57
+ // well as Tinfoil — the only capable model here with two attested homes.
58
+ //
59
+ // Re-measure before moving this again. The stall behaviour is a property of a
60
+ // provider's deployment, not of a model, and it has already changed under us twice.
61
+ export const TINFOIL_MODEL_ID = "tinfoil/gpt-oss-120b";
41
62
 
42
63
  // Same model, reached two ways:
43
64
  // - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
@@ -0,0 +1,115 @@
1
+ // What the `/model` picker is allowed to offer, and why it might be short.
2
+ //
3
+ // Three surfaces build that list — the REPL (`cli/chat.ts`), the desktop session
4
+ // (`desktop/src/main/agentSession.ts`) and, through the relay, the app's picker
5
+ // sheet — and all three used the same two lines: `modelRegistry.getAvailable()`,
6
+ // mapped to `provider/id` and sorted. Two things about that list are surprising
7
+ // enough that they belong here rather than being rediscovered at each call site:
8
+ //
9
+ // 1. **Sealed-only models are registered late.** `phala/*` is registered only once
10
+ // the sealed loopback shim is bound (isServableAccountModel — the cleartext
11
+ // `/api/agent/v1` has no Phala route and rejects those ids outright), and the
12
+ // shim binds a beat AFTER the session's first synchronous registration. The
13
+ // account provider re-registers when it comes up, so the catalog heals itself
14
+ // within ~half a second — but a picker opened inside that window listed a
15
+ // catalog quietly missing the whole Phala tier. Waiting for the shim first
16
+ // costs milliseconds (`startShim` is a loopback `listen(0)` — no network and no
17
+ // attestation) and removes the race.
18
+ //
19
+ // 2. **The account catalog can be registered and yet entirely unavailable.** Pi's
20
+ // `getAvailable()` is `models.filter(hasConfiguredAuth)`, and hasConfiguredAuth
21
+ // is "the provider has an auth entry, or its config's apiKey resolves". The
22
+ // account provider has no apiKey — it authenticates with an OAuth child token —
23
+ // so every `privateer/*` model (the NEAR and Tinfoil confidential tiers, and the
24
+ // bulk of the 240-odd catalog) is filtered out until the credential is armed
25
+ // into Pi's auth store, which needs a machine login. On a signed-out box the
26
+ // picker therefore drops the entire account catalog and says nothing: it looks
27
+ // like we don't carry those models, rather than like you're signed out.
28
+ //
29
+ // So: one place computes the list, and the same place reports what it had to hide.
30
+
31
+ import { hasCredentials } from "../auth/privateer.ts";
32
+ import { ensureSealedShim, sealedEnabled, sealedShimBase } from "./sealedShim.ts";
33
+
34
+ /** The routing provider for the account channel — `privateer/<catalog id>`. */
35
+ export const ACCOUNT_PROVIDER = "privateer";
36
+
37
+ /** The shape we need from Pi's model registry (kept structural — it's untyped here). */
38
+ export interface CatalogRegistry {
39
+ getAll?: () => unknown[];
40
+ getAvailable?: () => unknown[] | Promise<unknown[]>;
41
+ }
42
+
43
+ interface RegistryModel {
44
+ provider?: string;
45
+ id?: string;
46
+ }
47
+
48
+ const specOf = (m: RegistryModel): string => `${m.provider}/${m.id}`;
49
+
50
+ /**
51
+ * Wait for the sealed shim if it's coming, so a catalog read can't miss the
52
+ * sealed-only models purely because it happened early (see note 1 above).
53
+ *
54
+ * The account provider attached its own post-shim re-registration at extension
55
+ * init, i.e. BEFORE this one — promise callbacks run in attachment order, so by the
56
+ * time this resolves, `phala/*` is already in the registry. Failure is not fatal:
57
+ * the catalog is then honestly the one we can serve.
58
+ */
59
+ export async function readySealedCatalog(): Promise<void> {
60
+ if (!sealedEnabled() || sealedShimBase()) return;
61
+ try {
62
+ await ensureSealedShim();
63
+ } catch {
64
+ /* no shim → no sealed models, which is exactly what the list should show */
65
+ }
66
+ }
67
+
68
+ export interface PickerCatalog {
69
+ /** Sorted `provider/id` specs the session can actually reach — what to offer. */
70
+ specs: string[];
71
+ /** Account models registered but unreachable right now (0 when all is well). */
72
+ hiddenAccountModels: number;
73
+ /** Whether this machine holds a Privateer login at all. */
74
+ signedIn: boolean;
75
+ }
76
+
77
+ /** The picker's list, plus what it had to leave out. */
78
+ export async function pickerCatalog(registry: CatalogRegistry | null | undefined): Promise<PickerCatalog> {
79
+ await readySealedCatalog();
80
+ const available = ((await registry?.getAvailable?.()) ?? []) as RegistryModel[];
81
+ const all = (registry?.getAll?.() ?? []) as RegistryModel[];
82
+ const offeredAccount = available.filter((m) => m.provider === ACCOUNT_PROVIDER).length;
83
+ const registeredAccount = all.filter((m) => m.provider === ACCOUNT_PROVIDER).length;
84
+ return {
85
+ specs: available.map(specOf).sort(),
86
+ hiddenAccountModels: Math.max(0, registeredAccount - offeredAccount),
87
+ signedIn: hasCredentials(),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * One line explaining an account catalog we registered but can't offer, or null
93
+ * when there's nothing to explain.
94
+ *
95
+ * `signInHint` is the caller's own instruction for getting signed in — the REPL
96
+ * says `/login`, the desktop points at its Account menu — because "sign in" without
97
+ * saying where is the half of this message that was already implicit.
98
+ */
99
+ export function hiddenAccountNotice(cat: PickerCatalog, signInHint: string): string | null {
100
+ if (cat.hiddenAccountModels === 0) return null;
101
+ const n = cat.hiddenAccountModels;
102
+ const models = `${n} Privateer account model${n === 1 ? "" : "s"} (including the confidential TEE ones)`;
103
+ return cat.signedIn
104
+ // Signed in but still unavailable: the credential never reached Pi's auth store
105
+ // — a revoked machine login, the terminal cap, or a network blip while arming.
106
+ ? `${models} are hidden — the account channel isn't armed. ${signInHint}`
107
+ : `Not signed in — ${models} are hidden. ${signInHint}`;
108
+ }
109
+
110
+ /** A short suffix for the picker's own title, so the shortfall shows where you're looking. */
111
+ export function hiddenAccountTitleSuffix(cat: PickerCatalog): string {
112
+ return cat.hiddenAccountModels === 0
113
+ ? ""
114
+ : ` · sign in for ${cat.hiddenAccountModels} more`;
115
+ }