privateer-agent 0.12.6 → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hayden Bulk Tech Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -570,8 +570,9 @@ posts a warning to the transcript, and while it's on the footer carries a perman
570
570
  Toggling takes effect from the next gated action — an approval already on screen still
571
571
  needs an answer. It's a physical-terminal switch: a phone driving this terminal over
572
572
  `/remote-access` can't reach it. The app has its own no-quarter toggle for driven turns,
573
- which is `bypass` exactly never less so dangerous shell and destructive actions
574
- still surface an Allow there, precisely as they do under `/mode bypass` locally.
573
+ and it means the same thing this flag does: the moat down, dangerous shell and destructive
574
+ actions included stronger than `/mode bypass`, which keeps those two above it. A hard
575
+ plan-mode deny is the one thing it doesn't talk around.
575
576
 
576
577
  > shift+tab is Pi's default "cycle thinking level" chord; Privateer takes it for this.
577
578
  > Thinking level is still under `/settings`, or bind `app.thinking.cycle` to another key
@@ -18,7 +18,7 @@
18
18
  // process.loadEnvFile and tsx's register() are both silent; keep it that way, and
19
19
  // send any diagnostic you add to stderr.
20
20
  import { register } from "tsx/esm/api";
21
- import { fileURLToPath } from "node:url";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import { dirname, resolve } from "node:path";
23
23
 
24
24
  const here = dirname(fileURLToPath(import.meta.url));
@@ -36,5 +36,5 @@ try {
36
36
  process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
37
37
 
38
38
  register();
39
- const { runAcp } = await import(resolve(repo, "src/acp/run.ts"));
39
+ const { runAcp } = await import(pathToFileURL(resolve(repo, "src/acp/run.ts")).href);
40
40
  await runAcp();
@@ -7,7 +7,7 @@
7
7
  // Invoked two ways: interactively via the bash launcher (`privateer harbor …`), and
8
8
  // by the installed launchd/systemd service (`node privateer-harbor.mjs run`).
9
9
  import { register } from "tsx/esm/api";
10
- import { fileURLToPath } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { dirname, resolve } from "node:path";
12
12
 
13
13
  const here = dirname(fileURLToPath(import.meta.url));
@@ -26,5 +26,5 @@ try {
26
26
  process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
27
27
 
28
28
  register();
29
- const { runHarborCli } = await import(resolve(repo, "src/cli/harborCli.ts"));
29
+ const { runHarborCli } = await import(pathToFileURL(resolve(repo, "src/cli/harborCli.ts")).href);
30
30
  await runHarborCli(process.argv.slice(2));
@@ -341,11 +341,11 @@ else {
341
341
  const signedIn = fs.existsSync(CRED);
342
342
  // Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
343
343
  // file carries the measurements behind the choice.
344
- const ACCOUNT_MODEL = "privateer/tinfoil/kimi-k2-6";
344
+ const ACCOUNT_MODEL = "privateer/tinfoil/gpt-oss-120b";
345
345
  const MODEL = process.env.PRIVATEER_MODEL
346
346
  ? process.env.PRIVATEER_MODEL
347
347
  : haveTinfoilKey()
348
- ? "tinfoil/kimi-k2-6"
348
+ ? "tinfoil/gpt-oss-120b"
349
349
  : signedIn
350
350
  ? ACCOUNT_MODEL
351
351
  : haveKey("ANTHROPIC_API_KEY")
package/bin/privateer.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // code and dev keys from the repo. Prefer the `bin/pv` wrapper, which also picks a
5
5
  // Node >= 22 (the Pi stack's floor).
6
6
  import { register } from "tsx/esm/api";
7
- import { fileURLToPath } from "node:url";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { dirname, resolve } from "node:path";
9
9
 
10
10
  const here = dirname(fileURLToPath(import.meta.url));
@@ -19,4 +19,11 @@ try {
19
19
  }
20
20
 
21
21
  register(); // resolves the repo's tsx regardless of the invocation cwd
22
- await import(resolve(repo, "src/cli/chat.ts"));
22
+ // pathToFileURL, NOT the bare path: on Windows an absolute path starts with a
23
+ // drive letter, and dynamic import() reads "D:\..." as the URL scheme "d:" —
24
+ // ERR_UNSUPPORTED_ESM_URL_SCHEME, before a single line of ours runs. POSIX
25
+ // absolute paths happen to work, which is exactly why this survived so long:
26
+ // every launcher here had it, and `--version` is intercepted upstream in
27
+ // privateer-launch.mjs, so the Windows smoke test booted fine while the actual
28
+ // command was dead. Pinned by tests/launcherImports.test.ts.
29
+ await import(pathToFileURL(resolve(repo, "src/cli/chat.ts")).href);
@@ -297,6 +297,20 @@ function pendingFromCatalog(e: CatalogEntry): Pending {
297
297
  hint: `Replaces the placeholder ${e.fill}`,
298
298
  initial: e.fill,
299
299
  });
300
+ } else if (e.needs === "url") {
301
+ // A server hosted by an app already running HERE. Nothing to paste, but the
302
+ // endpoint is the one thing that can differ per machine (Unreal's port and path
303
+ // are editable in Editor Preferences), and a wrong port is a connector that
304
+ // saves cleanly and never answers — the failure this catalog exists to avoid.
305
+ // Pre-filled with the documented default, so <enter> is the common case.
306
+ steps.push({
307
+ key: "url",
308
+ prompt: "Server URL",
309
+ hint: `Default is ${e.url} — change it only if you moved it.`,
310
+ initial: e.url,
311
+ validate: (v) =>
312
+ /^https?:\/\//i.test(v.trim()) ? undefined : "That needs to be an http:// or https:// URL.",
313
+ });
300
314
  }
301
315
  return {
302
316
  title: e.label,
@@ -307,12 +321,17 @@ function pendingFromCatalog(e: CatalogEntry): Pending {
307
321
  for (const [k, v] of Object.entries(answers)) {
308
322
  if (k.startsWith("env:")) env[k.slice(4)] = v;
309
323
  }
310
- return draftFromCatalog(e, { env, fill: answers.fill });
324
+ return draftFromCatalog(e, { env, fill: answers.fill, url: answers.url });
311
325
  },
312
326
  note:
313
327
  e.needs === "oauth"
314
328
  ? `Authorize it in a browser on THIS machine: /mcp-auth ${e.name}`
315
- : undefined,
329
+ : e.localHttp
330
+ // Not a warning — a localHttp connector is correct the moment it saves. But
331
+ // it answers only while its host app is up, so a later failure means "the
332
+ // app is closed", and saying that now beats debugging it then.
333
+ ? `Works whenever ${e.label} is running on this machine. Nothing to authorize.`
334
+ : undefined,
316
335
  };
317
336
  }
318
337
 
@@ -329,6 +348,7 @@ const NEEDS_LABEL: Record<string, string> = {
329
348
  token: "needs a token",
330
349
  path: "needs a path",
331
350
  oauth: "browser sign-in",
351
+ url: "confirm the URL",
332
352
  none: "no setup",
333
353
  };
334
354
 
@@ -0,0 +1,39 @@
1
+ // Per-spawn skills — the skills a folder carries, without touching the folder.
2
+ //
3
+ // Pi discovers project skills at `<cwd>/.privateer/skills` (and `<cwd>/.pi/skills`),
4
+ // resolved lexically from cwd with no override — see projectConfigDirCandidates. That
5
+ // is the right home for skills a TEAM shares through the repo, and the wrong one for
6
+ // "the skills I use in this folder on this machine": giving a folder its own skills
7
+ // would mean writing into the user's tree, where it lands in their next `git status`.
8
+ //
9
+ // So per-spawn skills live under the global dir with that folder's other defaults
10
+ // (src/config/spawns.ts), and this extension hands the directory to Pi through the
11
+ // `resources_discover` hook — the supported way to contribute skill paths. Nothing is
12
+ // patched, and the skills are loaded by the session itself, so the model can actually
13
+ // invoke them rather than merely having them listed in the app's Skills manager.
14
+ //
15
+ // Note this contributes PATHS, not skills: Pi still owns discovery, precedence and
16
+ // the disable-model-invocation flag inside them.
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { spawnSkillsDir } from "../src/config/spawns.ts";
20
+
21
+ export default function privateerSpawnSkills(pi: any): void {
22
+ pi.on("resources_discover", (event: any) => {
23
+ // `cwd` comes from the event rather than a captured value: the desktop rebuilds
24
+ // its session on a folder switch, and a stale closure would keep feeding the old
25
+ // folder's skills to the new one.
26
+ const cwd = event?.cwd ?? process.cwd();
27
+ let dir: string;
28
+ try {
29
+ dir = spawnSkillsDir(cwd);
30
+ } catch {
31
+ return; // no global dir (PRIVATEER_HOME unset in a stripped environment)
32
+ }
33
+ // Only offer a directory that exists. Pi tolerates a missing path, but an empty
34
+ // contribution is also how `emitResourcesDiscover` decides there is nothing to
35
+ // reload — so staying silent keeps a spawn with no skills of its own free.
36
+ if (!existsSync(dir)) return;
37
+ return { skillPaths: [dir] };
38
+ });
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1055,3 +1055,71 @@ index f81b4b4..9712a37 100644
1055
1055
  -a, --approve Trust project-local files for this command
1056
1056
  -na, --no-approve Ignore project-local files for this command
1057
1057
 
1058
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1059
+ index 68d0308..f7208f1 100644
1060
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1061
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1062
+ @@ -1,5 +1,5 @@
1063
+ import { isAbsolute, relative, resolve, sep } from "node:path";
1064
+ -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1065
+ +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
1066
+ import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
1067
+ import { theme } from "../theme/theme.js";
1068
+ /**
1069
+ @@ -13,6 +13,43 @@ function sanitizeStatusText(text) {
1070
+ .replace(/ +/g, " ")
1071
+ .trim();
1072
+ }
1073
+ +/**
1074
+ + * Lay extension statuses out over as many lines as the terminal needs, keeping
1075
+ + * each status whole. Upstream joined them into ONE line and truncated it to the
1076
+ + * width, which silently hid whatever didn't fit — and what didn't fit was often
1077
+ + * the loudest indicator on screen (Privateer's "permission gate OFF" flag). A
1078
+ + * status too wide to fit on a line of its own is wrapped, never clipped.
1079
+ + */
1080
+ +function packStatusLines(statuses, width) {
1081
+ + if (width <= 0)
1082
+ + return [];
1083
+ + const lines = [];
1084
+ + let current = "";
1085
+ + let currentWidth = 0;
1086
+ + const flush = () => {
1087
+ + if (current)
1088
+ + lines.push(current);
1089
+ + current = "";
1090
+ + currentWidth = 0;
1091
+ + };
1092
+ + for (const status of statuses) {
1093
+ + if (!status)
1094
+ + continue;
1095
+ + const statusWidth = visibleWidth(status);
1096
+ + if (statusWidth > width) {
1097
+ + flush();
1098
+ + lines.push(...wrapTextWithAnsi(status, width));
1099
+ + continue;
1100
+ + }
1101
+ + const separator = current ? 1 : 0;
1102
+ + if (currentWidth + separator + statusWidth > width)
1103
+ + flush();
1104
+ + current = current ? `${current} ${status}` : status;
1105
+ + currentWidth += separator + statusWidth;
1106
+ + }
1107
+ + flush();
1108
+ + return lines;
1109
+ +}
1110
+ /**
1111
+ * Format token counts for compact footer display.
1112
+ */
1113
+ @@ -211,9 +248,9 @@ export class FooterComponent {
1114
+ const sortedStatuses = Array.from(extensionStatuses.entries())
1115
+ .sort(([a], [b]) => a.localeCompare(b))
1116
+ .map(([, text]) => sanitizeStatusText(text));
1117
+ - const statusLine = sortedStatuses.join(" ");
1118
+ - // Truncate to terminal width with dim ellipsis for consistency with footer style
1119
+ - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
1120
+ + // Wrap onto extra lines instead of truncating: a status is there because
1121
+ + // something wants to be seen, so none of them may be cut off.
1122
+ + lines.push(...packStatusLines(sortedStatuses, width));
1123
+ }
1124
+ return lines;
1125
+ }
package/src/acp/run.ts CHANGED
@@ -125,7 +125,7 @@ export async function runAcp(): Promise<void> {
125
125
  cwd: baseCwd,
126
126
  confineToCwd: true,
127
127
  getRemote: () => true,
128
- getNoQuarter: () => posture === "auto",
128
+ getAutoApprove: () => posture === "auto",
129
129
  async localAsk() {
130
130
  return "deny";
131
131
  },
@@ -191,7 +191,7 @@ async function main() {
191
191
  cwd,
192
192
  confineToCwd: true,
193
193
  getRemote: () => true,
194
- getNoQuarter: () => posture() === "auto",
194
+ getAutoApprove: () => posture() === "auto",
195
195
  async localAsk() {
196
196
  return "deny";
197
197
  },
package/src/cli/chat.ts CHANGED
@@ -47,6 +47,7 @@ async function main() {
47
47
  dropPersistedAccountCredential,
48
48
  } = await import("../providers/account.ts");
49
49
  const { agentVersion } = await import("../config/version.ts");
50
+ const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
50
51
  const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
51
52
  const { postOutbox } = await import("../outbox/cloudOutbox.ts");
52
53
  const { addPendingCloud } = await import("../routines/store.ts");
@@ -625,11 +626,14 @@ async function main() {
625
626
  console.log(`${DIM}shift+tab toggles no quarter — unattended mode, no approval prompts.${RESET}`);
626
627
  await showPosture();
627
628
 
628
- // The available model catalog as sorted "provider/id" specs. Same source the
629
- // /models list and the app's picker draw from.
630
- async function availableModelSpecs(): Promise<string[]> {
631
- const all: any[] = (services.modelRegistry as any).getAvailable ? await (services.modelRegistry as any).getAvailable() : [];
632
- return all.map((m) => `${m.provider}/${m.id}`).sort();
629
+ // The available model catalog as sorted "provider/id" specs, plus what the
630
+ // registry had to leave out. Same source the /models list and the app's picker
631
+ // draw from see providers/modelCatalog.ts for the two things that make this
632
+ // more than a map+sort (sealed models register late; the whole account catalog
633
+ // is filtered out until the credential is armed).
634
+ const SIGN_IN_HINT = "Run /login.";
635
+ async function modelCatalog() {
636
+ return pickerCatalog(services.modelRegistry as any);
633
637
  }
634
638
 
635
639
  // Switch the live session's model in place (history preserved — see
@@ -660,9 +664,17 @@ async function main() {
660
664
  // as a selection prompt and switch to whatever the driver picks. This is the
661
665
  // remote /model flow — the terminal owns the options, the app just renders them.
662
666
  async function pickModelRemote(filter: string): Promise<void> {
663
- const specs = (await availableModelSpecs()).filter((sp) => !filter || sp.toLowerCase().includes(filter));
667
+ const cat = await modelCatalog();
668
+ const specs = cat.specs.filter((sp) => !filter || sp.toLowerCase().includes(filter));
669
+ // Say why the list is short BEFORE opening it — a driver looking at a catalog
670
+ // with no confidential models has no way to tell "we don't carry them" from
671
+ // "this terminal is signed out", and only one of those has a fix.
672
+ const notice = hiddenAccountNotice(cat, SIGN_IN_HINT);
673
+ if (notice) relay?.sendNotice(notice);
664
674
  const choice = await bridge.selectRemote({
665
- title: "Choose a model",
675
+ // The suffix puts the same fact in the sheet itself, where the driver is
676
+ // actually looking. Terminal chrome, so English like the rest of the title.
677
+ title: `Choose a model${hiddenAccountTitleSuffix(cat)}`,
666
678
  options: specs.map((sp) => ({ value: sp, label: sp })),
667
679
  current: currentSpec,
668
680
  });
@@ -689,8 +701,11 @@ async function main() {
689
701
  if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
690
702
  const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
691
703
  if (remote) { await pickModelRemote(filter); return true; }
692
- const rows = (await availableModelSpecs()).filter((sp) => !filter || sp.toLowerCase().includes(filter));
704
+ const cat = await modelCatalog();
705
+ const rows = cat.specs.filter((sp) => !filter || sp.toLowerCase().includes(filter));
693
706
  console.log(rows.slice(0, 40).join("\n") + (rows.length > 40 ? `\n${DIM}… ${rows.length - 40} more (try /models <filter>)${RESET}` : ""));
707
+ const hidden = hiddenAccountNotice(cat, SIGN_IN_HINT);
708
+ if (hidden) console.log(`${DIM}${hidden}${RESET}`);
694
709
  return true;
695
710
  }
696
711
  // Extensions: remote drives the app's manager (list frame); local prints the list.
@@ -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
+ }
@@ -79,6 +79,17 @@ export function makeSkillsControl(opts: {
79
79
  cwd: string;
80
80
  agentDir: string;
81
81
  settingsManager: SettingsManager;
82
+ /**
83
+ * Extra skill directories to load beyond settings' own `skillPaths`.
84
+ *
85
+ * The desktop passes this folder's per-spawn skills dir (config/spawns.ts). Pi
86
+ * discovers project skills only under `<cwd>/.privateer/skills`, which would mean
87
+ * writing into the user's tree to give a folder its own skills — so they live
88
+ * under the global dir with the folder's other defaults and arrive here instead.
89
+ * Read-only from this control's point of view: `editable` still means "under
90
+ * <agentDir>/skills", so create/delete keep targeting the one dir they always did.
91
+ */
92
+ extraSkillPaths?: string[];
82
93
  }): SkillsControl {
83
94
  // The user's own global skills live here; only these are editable.
84
95
  const userSkillsDir = path.join(opts.agentDir, "skills");
@@ -99,6 +110,12 @@ export function makeSkillsControl(opts: {
99
110
  } catch {
100
111
  skillPaths = [];
101
112
  }
113
+ // Appended, not prepended: a path the user configured in settings outranks a
114
+ // per-spawn one on a name clash, the same way an explicit setting outranks a
115
+ // default everywhere else. De-duplicated so a dir named in both is loaded once.
116
+ for (const extra of opts.extraSkillPaths ?? []) {
117
+ if (extra && !skillPaths.includes(extra)) skillPaths.push(extra);
118
+ }
102
119
  let result;
103
120
  try {
104
121
  result = loadSkills({ cwd: opts.cwd, agentDir: opts.agentDir, skillPaths, includeDefaults: true });