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 +21 -0
- package/README.md +3 -2
- package/bin/privateer-acp.mjs +2 -2
- package/bin/privateer-harbor.mjs +2 -2
- package/bin/privateer-launch.mjs +2 -2
- package/bin/privateer.mjs +9 -2
- package/extensions/privateer-connect.ts +22 -2
- package/extensions/privateer-spawn-skills.ts +39 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +68 -0
- package/src/acp/run.ts +1 -1
- package/src/channels/run.ts +1 -1
- package/src/cli/chat.ts +23 -8
- package/src/config/spawns.ts +187 -0
- package/src/ext/permissionGate.ts +8 -1
- package/src/harbor/ipc.ts +27 -1
- package/src/mcp/catalog.ts +67 -8
- package/src/permissions/modeGate.ts +26 -7
- package/src/providers/account.ts +5 -4
- package/src/providers/defaultModel.ts +36 -15
- package/src/providers/modelCatalog.ts +115 -0
- package/src/remote/skillsControl.ts +17 -0
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
|
-
|
|
574
|
-
|
|
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
|
package/bin/privateer-acp.mjs
CHANGED
|
@@ -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();
|
package/bin/privateer-harbor.mjs
CHANGED
|
@@ -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));
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
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
|
-
|
|
128
|
+
getAutoApprove: () => posture === "auto",
|
|
129
129
|
async localAsk() {
|
|
130
130
|
return "deny";
|
|
131
131
|
},
|
package/src/channels/run.ts
CHANGED
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
|
|
629
|
-
// /models list and the app's picker
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 /
|
|
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
|
-
|
|
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
|
}
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
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
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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:
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
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";
|
package/src/providers/account.ts
CHANGED
|
@@ -47,10 +47,11 @@ import {
|
|
|
47
47
|
const DEFAULT_MODELS = [
|
|
48
48
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
49
49
|
ACCOUNT_NEAR_MODEL_ID,
|
|
50
|
-
//
|
|
51
|
-
// user who saved
|
|
52
|
-
// rather than falling through to "first model with configured auth" — the BYO
|
|
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
|
-
//
|
|
26
|
-
//
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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 });
|