privateer-agent 0.12.6 → 0.12.8
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 +77 -7
- package/bin/privateer-splash.mjs +247 -0
- package/bin/privateer.mjs +9 -2
- package/bin/update-route.d.mts +6 -0
- package/bin/update-route.mjs +35 -0
- package/extensions/privateer-brand.ts +27 -22
- package/extensions/privateer-connect.ts +22 -2
- package/extensions/privateer-spawn-skills.ts +39 -0
- package/extensions/privateer-update.ts +187 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +93 -4
- package/src/acp/run.ts +1 -1
- package/src/channels/run.ts +1 -1
- package/src/cli/chat.ts +23 -8
- package/src/config/moatManifest.json +1 -0
- package/src/config/spawns.ts +187 -0
- package/src/context.ts +9 -1
- 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/src/updates.ts +106 -0
|
@@ -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 });
|
package/src/updates.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// What's out of date — the two kinds of update a Privateer terminal can have pending,
|
|
2
|
+
// owned in one place so every surface gives the same answer.
|
|
3
|
+
//
|
|
4
|
+
// 1. THE CLI ITSELF (privateer-agent). Read from the cache the launcher refreshes in
|
|
5
|
+
// the background at most ~daily (bin/privateer-launch.mjs refreshUpdateCache). We
|
|
6
|
+
// never fetch here, so the banner stays synchronous and offline-safe. Applying it
|
|
7
|
+
// REPLACES the running program, so it can only ever be `privateer update` from a
|
|
8
|
+
// shell — never live.
|
|
9
|
+
// 2. TOOL PACKS (Pi "packages": extensions/skills/prompts/themes installed from npm or
|
|
10
|
+
// git). Checked in-process by extensions/privateer-update.ts, held here in memory,
|
|
11
|
+
// and applied live — see that file for why a running terminal can swap them out.
|
|
12
|
+
//
|
|
13
|
+
// The two consumers are different extensions (the banner draws the pending state, /update
|
|
14
|
+
// acts on it), so the pack list gets the same listener idiom as src/context.ts's
|
|
15
|
+
// onContextChanged: a setter fires listeners, and neither extension imports the other.
|
|
16
|
+
// In memory rather than on disk ON PURPOSE — a cache file outlives the update that
|
|
17
|
+
// cleared it, and a banner still flying the flag after you've fetched everything is worse
|
|
18
|
+
// than one that shows up a second late.
|
|
19
|
+
//
|
|
20
|
+
// IMPORT-SAFETY: no Pi imports, no side effects — safe to load from a jiti-loaded
|
|
21
|
+
// extension and from pre-boot code alike.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { globalDir } from "./config/paths.ts";
|
|
26
|
+
|
|
27
|
+
// ── the CLI release ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
// Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
|
|
30
|
+
// enough for our npm releases; anything unparseable sorts as 0 and is treated as older.
|
|
31
|
+
function isNewer(a: string, b: string): boolean {
|
|
32
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
33
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
36
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The newer privateer-agent release the launcher's cache knows about, or null when we're
|
|
43
|
+
* current / offline / never checked. Reads only — the refresh is the launcher's job.
|
|
44
|
+
*/
|
|
45
|
+
export function pendingCliUpdate(current: string): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const { latest } = JSON.parse(readFileSync(join(globalDir(), "update-check.json"), "utf8"));
|
|
48
|
+
if (typeof latest === "string" && isNewer(latest, current)) return latest;
|
|
49
|
+
} catch {
|
|
50
|
+
// no cache yet, unreadable, or malformed — nothing pending as far as we know.
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── tool packs ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** One installed pack with a newer version available. Mirrors Pi's PackageUpdate. */
|
|
58
|
+
export interface PackUpdate {
|
|
59
|
+
/** The configured source string ("pi-hermes-memory", "github:owner/repo", …). */
|
|
60
|
+
source: string;
|
|
61
|
+
/** What to show a human — Pi's own label for the pack. */
|
|
62
|
+
displayName: string;
|
|
63
|
+
type: "npm" | "git";
|
|
64
|
+
scope: "user" | "project";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Listener = () => void;
|
|
68
|
+
|
|
69
|
+
// ⚠️ MODULE STATE IS NOT SHARED BETWEEN EXTENSIONS. Pi loads every extension through its
|
|
70
|
+
// OWN jiti instance with moduleCache:false (core/extensions/loader.js), so a module two
|
|
71
|
+
// extensions both import is instantiated TWICE — a plain module-level `let` here would
|
|
72
|
+
// leave the banner reading a different copy from the one /update writes, and the flag
|
|
73
|
+
// would never appear. Verified rather than assumed: two jiti instances importing the same
|
|
74
|
+
// relative module see completely independent state, and a globalThis-keyed store is what
|
|
75
|
+
// crosses between them. Symbol.for so both copies resolve the same key.
|
|
76
|
+
const STATE = Symbol.for("privateer.updates.packs");
|
|
77
|
+
interface PackState {
|
|
78
|
+
pending: readonly PackUpdate[];
|
|
79
|
+
listeners: Set<Listener>;
|
|
80
|
+
}
|
|
81
|
+
const state: PackState = (((globalThis as any)[STATE] ??= {
|
|
82
|
+
pending: [],
|
|
83
|
+
listeners: new Set<Listener>(),
|
|
84
|
+
}) as PackState);
|
|
85
|
+
|
|
86
|
+
/** Packs with an update waiting, as of the last check. Empty until the check lands. */
|
|
87
|
+
export function pendingPackUpdates(): readonly PackUpdate[] {
|
|
88
|
+
return state.pending;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Record the result of a check (or of an update that cleared the list) and notify. */
|
|
92
|
+
export function setPendingPackUpdates(next: readonly PackUpdate[]): void {
|
|
93
|
+
state.pending = [...next];
|
|
94
|
+
for (const fn of state.listeners) {
|
|
95
|
+
try {
|
|
96
|
+
fn();
|
|
97
|
+
} catch {
|
|
98
|
+
// a broken listener must not break the check that called us.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Called whenever the pending list changes — the banner re-renders from it. */
|
|
104
|
+
export function onPackUpdatesChanged(fn: Listener): void {
|
|
105
|
+
state.listeners.add(fn);
|
|
106
|
+
}
|