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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `/update` — fetch newer tool packs without leaving the terminal.
|
|
2
|
+
//
|
|
3
|
+
// A "tool pack" is what Pi calls a package: an npm or git source that contributes
|
|
4
|
+
// extensions, skills, prompts or themes (pi-hermes-memory, a private tools repo, …).
|
|
5
|
+
// Pi already checked for updates at startup and drew its own yellow box about it. We
|
|
6
|
+
// suppress that box (patches/, in interactive-mode's run()) and own the surface here,
|
|
7
|
+
// for two reasons:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE BOX NAMED A COMMAND THAT DOES NOT EXIST. It said "Run pi update --extensions".
|
|
10
|
+
// No `pi` binary is installed on a Privateer machine, and `privateer update` means
|
|
11
|
+
// the CLI's own self-update — so the one instruction on screen either failed with
|
|
12
|
+
// "command not found" or reinstalled the wrong thing. The launcher now routes
|
|
13
|
+
// `privateer update --extensions` properly (bin/privateer-launch.mjs), and in the
|
|
14
|
+
// TUI the answer is simply /update.
|
|
15
|
+
// 2. IT WAS THE WRONG SIZE. A warning-bordered box under the banner, for "a pack has a
|
|
16
|
+
// newer version". Privateer's own release notice is a single line INSIDE the banner;
|
|
17
|
+
// packs get a line beside it (extensions/privateer-brand.ts) and nothing more.
|
|
18
|
+
//
|
|
19
|
+
// WHY THIS CAN HAPPEN LIVE. Nothing here needs a restart, and that is a property of Pi's
|
|
20
|
+
// own machinery rather than a trick of ours:
|
|
21
|
+
// - DefaultPackageManager.update() installs into the same on-disk locations the
|
|
22
|
+
// resolver reads from, so the new code is simply *there* when something looks again.
|
|
23
|
+
// - ctx.reload() runs the same path as /reload: resourceLoader.reload() re-runs
|
|
24
|
+
// packageManager.resolve() AND calls clearExtensionCache(), and extensions are loaded
|
|
25
|
+
// through jiti with moduleCache:false — so every extension file is re-read from disk,
|
|
26
|
+
// not served from a module cache. Keybindings, skills, prompts and themes come back
|
|
27
|
+
// with it, and the chat is rebuilt from the session's messages, so scrollback and
|
|
28
|
+
// context survive.
|
|
29
|
+
// The honest caveat: in Node (not the bundle) jiti leaves `tryNative` on, so a dependency
|
|
30
|
+
// DEEP inside an updated npm pack that Node already loaded natively can stay the old copy
|
|
31
|
+
// in memory. The pack's own source always reloads; if something still looks stale after
|
|
32
|
+
// an update, a restart is the guaranteed fix, and we say so rather than pretending.
|
|
33
|
+
//
|
|
34
|
+
// The CLI itself is the one thing that genuinely cannot be swapped live — updating it
|
|
35
|
+
// replaces the program that is running — so /update reports it and points at the shell.
|
|
36
|
+
|
|
37
|
+
import { DefaultPackageManager, SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
38
|
+
import { agentVersion } from "../src/config/version.ts";
|
|
39
|
+
import {
|
|
40
|
+
type PackUpdate,
|
|
41
|
+
pendingCliUpdate,
|
|
42
|
+
pendingPackUpdates,
|
|
43
|
+
setPendingPackUpdates,
|
|
44
|
+
} from "../src/updates.ts";
|
|
45
|
+
|
|
46
|
+
// A check costs an npm/git round trip per configured pack. Once at startup is the point;
|
|
47
|
+
// the extra checks come from reload (including the one our own /update triggers), where
|
|
48
|
+
// re-asking the registry seconds after we already know the answer is pure waste.
|
|
49
|
+
const RECHECK_AFTER_MS = 60_000;
|
|
50
|
+
let lastCheckedAt = 0;
|
|
51
|
+
|
|
52
|
+
// Build a package manager against THIS session's cwd and trust decision. Mirrors what
|
|
53
|
+
// interactive-mode does for its own startup check: an untrusted project's packages must
|
|
54
|
+
// stay out, and trust is the session's answer (ctx.isProjectTrusted), not a fresh guess.
|
|
55
|
+
function packageManagerFor(ctx: any): any {
|
|
56
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
57
|
+
const agentDir = getAgentDir();
|
|
58
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, {
|
|
59
|
+
projectTrusted: ctx?.isProjectTrusted?.() ?? false,
|
|
60
|
+
});
|
|
61
|
+
return new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runCheck(ctx: any, opts: { force?: boolean } = {}): Promise<readonly PackUpdate[]> {
|
|
65
|
+
if (!opts.force && Date.now() - lastCheckedAt < RECHECK_AFTER_MS) return pendingPackUpdates();
|
|
66
|
+
// --offline / PI_OFFLINE means "no startup network", and a registry check is exactly
|
|
67
|
+
// that. Pi's own check bails the same way.
|
|
68
|
+
if (process.env.PI_OFFLINE) return pendingPackUpdates();
|
|
69
|
+
lastCheckedAt = Date.now();
|
|
70
|
+
const updates = await packageManagerFor(ctx).checkForAvailableUpdates();
|
|
71
|
+
const packs: PackUpdate[] = updates.map((u: any) => ({
|
|
72
|
+
source: u.source,
|
|
73
|
+
displayName: u.displayName,
|
|
74
|
+
type: u.type,
|
|
75
|
+
scope: u.scope,
|
|
76
|
+
}));
|
|
77
|
+
setPendingPackUpdates(packs);
|
|
78
|
+
return packs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// "pi-hermes-memory", "pi-hermes-memory and pi-speak", "pi-hermes-memory, pi-speak and 2 more".
|
|
82
|
+
function nameList(packs: readonly PackUpdate[]): string {
|
|
83
|
+
const names = packs.map((p) => p.displayName);
|
|
84
|
+
if (names.length <= 2) return names.join(" and ");
|
|
85
|
+
return `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The CLI half of "what's out of date", appended to /update's replies so the answer to
|
|
89
|
+
// "am I current?" is complete in one place. Null when the CLI is current or unchecked.
|
|
90
|
+
function cliNote(): string | null {
|
|
91
|
+
const latest = pendingCliUpdate(agentVersion());
|
|
92
|
+
return latest
|
|
93
|
+
? `Privateer v${latest} is out too — that one replaces the running program, so run \`privateer update\` from a shell.`
|
|
94
|
+
: null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function privateerUpdate(pi: any): void {
|
|
98
|
+
// Check in the background on startup and after a reload. Never on a headless surface
|
|
99
|
+
// (harbor, ACP, print, channels): there's no banner to flag it on and nobody to type
|
|
100
|
+
// /update, so the network call would buy nothing.
|
|
101
|
+
pi.on("session_start", (event: any, ctx: any) => {
|
|
102
|
+
if (!ctx?.hasUI) return;
|
|
103
|
+
if (event?.reason !== "startup" && event?.reason !== "reload") return;
|
|
104
|
+
void runCheck(ctx).catch(() => {
|
|
105
|
+
// Offline, a private registry that won't answer, a git remote behind a VPN — an
|
|
106
|
+
// update check is never worth an error in the user's face. The flag just stays down.
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.registerCommand?.("update", {
|
|
111
|
+
description: "Fetch tool pack updates in place: /update [<pack> | check]",
|
|
112
|
+
handler: async (args: string, ctx: any) => {
|
|
113
|
+
const arg = String(args ?? "").trim();
|
|
114
|
+
const notify = (msg: string, kind: "info" | "warning" | "error" = "info") =>
|
|
115
|
+
ctx?.ui?.notify?.(msg, kind);
|
|
116
|
+
|
|
117
|
+
if (arg === "check") {
|
|
118
|
+
try {
|
|
119
|
+
const packs = await runCheck(ctx, { force: true });
|
|
120
|
+
const cli = cliNote();
|
|
121
|
+
notify(
|
|
122
|
+
packs.length
|
|
123
|
+
? `${packs.length} tool pack update${packs.length === 1 ? "" : "s"} ready: ${nameList(packs)} — /update to fetch${cli ? `. ${cli}` : ""}`
|
|
124
|
+
: `Tool packs are current.${cli ? ` ${cli}` : ""}`,
|
|
125
|
+
);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
notify(`Update check failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A named pack is passed straight through to the package manager, which matches it
|
|
133
|
+
// against configured sources and reports a better "no such pack" than we could.
|
|
134
|
+
// With no argument we act on what the check found, re-checking first if it never
|
|
135
|
+
// ran (a terminal that started offline, or a pack installed since launch).
|
|
136
|
+
let targets = pendingPackUpdates();
|
|
137
|
+
if (!arg) {
|
|
138
|
+
try {
|
|
139
|
+
targets = await runCheck(ctx, { force: pendingPackUpdates().length === 0 });
|
|
140
|
+
} catch {
|
|
141
|
+
// fall through with whatever the last check knew — update() re-checks anyway.
|
|
142
|
+
}
|
|
143
|
+
if (targets.length === 0) {
|
|
144
|
+
const cli = cliNote();
|
|
145
|
+
notify(`Tool packs are current.${cli ? ` ${cli}` : ""}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const label = arg || nameList(targets);
|
|
151
|
+
ctx?.ui?.setStatus?.("update", `⚑ updating ${arg || `${targets.length} pack${targets.length === 1 ? "" : "s"}`}`);
|
|
152
|
+
try {
|
|
153
|
+
const pm = packageManagerFor(ctx);
|
|
154
|
+
// Progress goes to the footer, not the transcript: an install emits several
|
|
155
|
+
// events per pack and each one as a notification would bury the chat.
|
|
156
|
+
pm.setProgressCallback((e: any) => {
|
|
157
|
+
if (e?.type === "error") return; // update() throws; the catch below reports it
|
|
158
|
+
ctx?.ui?.setStatus?.("update", `⚑ ${e?.action ?? "update"} ${e?.source ?? ""}`.trimEnd());
|
|
159
|
+
});
|
|
160
|
+
await pm.update(arg || undefined);
|
|
161
|
+
pm.setProgressCallback(undefined);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
ctx?.ui?.setStatus?.("update", undefined);
|
|
164
|
+
notify(`Update failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
ctx?.ui?.setStatus?.("update", undefined);
|
|
168
|
+
// Whatever was pending is on disk now. Clear the flag before the reload so the
|
|
169
|
+
// banner that comes back is already correct, rather than flying a stale one until
|
|
170
|
+
// the post-reload check answers.
|
|
171
|
+
setPendingPackUpdates([]);
|
|
172
|
+
lastCheckedAt = 0;
|
|
173
|
+
|
|
174
|
+
// The reload is refused mid-turn (Pi guards on streaming/compaction), so don't ask
|
|
175
|
+
// for one — say plainly that the new code is on disk and waiting.
|
|
176
|
+
if (ctx?.isIdle?.() === false) {
|
|
177
|
+
notify(`Updated ${label}. Run /reload when this turn finishes to load it.`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
notify(`Updated ${label} — reloading.`);
|
|
182
|
+
// ⚠️ ctx is STALE after this line (Pi invalidates it when the runtime is replaced).
|
|
183
|
+
// Nothing may touch it, so this is the last statement in the handler.
|
|
184
|
+
await ctx.reload();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
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",
|
|
@@ -904,8 +904,76 @@ index 3355a47..8c6bba9 100644
|
|
|
904
904
|
}
|
|
905
905
|
getResourcePattern(item) {
|
|
906
906
|
const scope = item.metadata.scope;
|
|
907
|
+
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
|
|
908
|
+
index 68d0308..f7208f1 100644
|
|
909
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
910
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
911
|
+
@@ -1,5 +1,5 @@
|
|
912
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
913
|
+
-import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
914
|
+
+import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
915
|
+
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
|
|
916
|
+
import { theme } from "../theme/theme.js";
|
|
917
|
+
/**
|
|
918
|
+
@@ -13,6 +13,43 @@ function sanitizeStatusText(text) {
|
|
919
|
+
.replace(/ +/g, " ")
|
|
920
|
+
.trim();
|
|
921
|
+
}
|
|
922
|
+
+/**
|
|
923
|
+
+ * Lay extension statuses out over as many lines as the terminal needs, keeping
|
|
924
|
+
+ * each status whole. Upstream joined them into ONE line and truncated it to the
|
|
925
|
+
+ * width, which silently hid whatever didn't fit — and what didn't fit was often
|
|
926
|
+
+ * the loudest indicator on screen (Privateer's "permission gate OFF" flag). A
|
|
927
|
+
+ * status too wide to fit on a line of its own is wrapped, never clipped.
|
|
928
|
+
+ */
|
|
929
|
+
+function packStatusLines(statuses, width) {
|
|
930
|
+
+ if (width <= 0)
|
|
931
|
+
+ return [];
|
|
932
|
+
+ const lines = [];
|
|
933
|
+
+ let current = "";
|
|
934
|
+
+ let currentWidth = 0;
|
|
935
|
+
+ const flush = () => {
|
|
936
|
+
+ if (current)
|
|
937
|
+
+ lines.push(current);
|
|
938
|
+
+ current = "";
|
|
939
|
+
+ currentWidth = 0;
|
|
940
|
+
+ };
|
|
941
|
+
+ for (const status of statuses) {
|
|
942
|
+
+ if (!status)
|
|
943
|
+
+ continue;
|
|
944
|
+
+ const statusWidth = visibleWidth(status);
|
|
945
|
+
+ if (statusWidth > width) {
|
|
946
|
+
+ flush();
|
|
947
|
+
+ lines.push(...wrapTextWithAnsi(status, width));
|
|
948
|
+
+ continue;
|
|
949
|
+
+ }
|
|
950
|
+
+ const separator = current ? 1 : 0;
|
|
951
|
+
+ if (currentWidth + separator + statusWidth > width)
|
|
952
|
+
+ flush();
|
|
953
|
+
+ current = current ? `${current} ${status}` : status;
|
|
954
|
+
+ currentWidth += separator + statusWidth;
|
|
955
|
+
+ }
|
|
956
|
+
+ flush();
|
|
957
|
+
+ return lines;
|
|
958
|
+
+}
|
|
959
|
+
/**
|
|
960
|
+
* Format token counts for compact footer display.
|
|
961
|
+
*/
|
|
962
|
+
@@ -211,9 +248,9 @@ export class FooterComponent {
|
|
963
|
+
const sortedStatuses = Array.from(extensionStatuses.entries())
|
|
964
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
965
|
+
.map(([, text]) => sanitizeStatusText(text));
|
|
966
|
+
- const statusLine = sortedStatuses.join(" ");
|
|
967
|
+
- // Truncate to terminal width with dim ellipsis for consistency with footer style
|
|
968
|
+
- lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
969
|
+
+ // Wrap onto extra lines instead of truncating: a status is there because
|
|
970
|
+
+ // something wants to be seen, so none of them may be cut off.
|
|
971
|
+
+ lines.push(...packStatusLines(sortedStatuses, width));
|
|
972
|
+
}
|
|
973
|
+
return lines;
|
|
974
|
+
}
|
|
907
975
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
908
|
-
index 5d65200..
|
|
976
|
+
index 5d65200..397a7b7 100644
|
|
909
977
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
910
978
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
911
979
|
@@ -10,7 +10,7 @@ import { getProviders, } from "@earendil-works/pi-ai/compat";
|
|
@@ -948,7 +1016,28 @@ index 5d65200..f827cd7 100644
|
|
|
948
1016
|
.map((command) => ({
|
|
949
1017
|
type: "warning",
|
|
950
1018
|
message: command.invocationName === command.name
|
|
951
|
-
@@ -
|
|
1019
|
+
@@ -549,12 +560,14 @@ export class InteractiveMode {
|
|
1020
|
+
this.showNewVersionNotification(newRelease);
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
- // Start package update check asynchronously
|
|
1024
|
+
- this.checkForPackageUpdates().then((updates) => {
|
|
1025
|
+
- if (updates.length > 0) {
|
|
1026
|
+
- this.showPackageUpdateNotification(updates);
|
|
1027
|
+
- }
|
|
1028
|
+
- });
|
|
1029
|
+
+ // Privateer owns the package ("tool pack") update surface — see
|
|
1030
|
+
+ // extensions/privateer-update.ts. Upstream ran its own check here and drew a
|
|
1031
|
+
+ // warning-bordered box telling the user to run `pi update --extensions`: a
|
|
1032
|
+
+ // binary that is installed nowhere on a Privateer machine, so the only
|
|
1033
|
+
+ // instruction on screen could not be followed. We replace it with a single
|
|
1034
|
+
+ // line in the startup banner plus /update, which applies packs in place. The
|
|
1035
|
+
+ // CHECK is removed too, not just the box, so this costs no registry round
|
|
1036
|
+
+ // trip — the extension does exactly one, on the same startup.
|
|
1037
|
+
// Check tmux keyboard setup asynchronously
|
|
1038
|
+
this.checkTmuxKeyboardSetup().then((warning) => {
|
|
1039
|
+
if (warning) {
|
|
1040
|
+
@@ -2042,7 +2055,17 @@ export class InteractiveMode {
|
|
952
1041
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
953
1042
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
954
1043
|
this.editor.setText("");
|
|
@@ -967,7 +1056,7 @@ index 5d65200..f827cd7 100644
|
|
|
967
1056
|
return;
|
|
968
1057
|
}
|
|
969
1058
|
if (text === "/export" || text.startsWith("/export ")) {
|
|
970
|
-
@@ -2105,14 +
|
|
1059
|
+
@@ -2105,14 +2128,44 @@ export class InteractiveMode {
|
|
971
1060
|
this.editor.setText("");
|
|
972
1061
|
return;
|
|
973
1062
|
}
|
|
@@ -1015,7 +1104,7 @@ index 5d65200..f827cd7 100644
|
|
|
1015
1104
|
return;
|
|
1016
1105
|
}
|
|
1017
1106
|
if (text === "/new") {
|
|
1018
|
-
@@ -2650,7 +
|
|
1107
|
+
@@ -2650,7 +2703,7 @@ export class InteractiveMode {
|
|
1019
1108
|
if (this.chatContainer.children.length > 0) {
|
|
1020
1109
|
this.chatContainer.addChild(new Spacer(1));
|
|
1021
1110
|
}
|
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.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
{ "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
|
|
13
13
|
{ "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
|
|
14
14
|
{ "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
|
|
15
|
+
{ "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
|
|
15
16
|
{ "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
16
17
|
{ "name": "rpiv-web-tools", "dep": ["@juicesharp/rpiv-web-tools", "index.ts"], "note": "private web tools (user's own provider key)" },
|
|
17
18
|
{ "name": "rpiv-ask-user-question", "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "note": "ask_user_question" },
|
|
@@ -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
|
+
}
|
package/src/context.ts
CHANGED
|
@@ -153,8 +153,16 @@ export function writeTemplate(dir: string = process.cwd()): WriteResult {
|
|
|
153
153
|
// Lets /init (in the context extension) tell the banner (in the brand extension) that
|
|
154
154
|
// PRIVATEER.md state changed, so the header re-renders its loaded/hint line immediately —
|
|
155
155
|
// without either extension importing the other. Mirrors priv.onSignedIn.
|
|
156
|
+
//
|
|
157
|
+
// ⚠️ The listener set CANNOT be plain module state. Pi gives every extension its own jiti
|
|
158
|
+
// instance with moduleCache:false, so this module is instantiated once per extension that
|
|
159
|
+
// imports it: /init's copy and the banner's copy are different objects, and an emit on one
|
|
160
|
+
// never reaches a listener registered on the other — the refresh silently did nothing.
|
|
161
|
+
// globalThis is the one thing the two copies share. Same fix, same reason, as the pack
|
|
162
|
+
// state in src/updates.ts.
|
|
156
163
|
type Listener = () => void;
|
|
157
|
-
const
|
|
164
|
+
const LISTENERS = Symbol.for("privateer.context.listeners");
|
|
165
|
+
const listeners: Set<Listener> = (((globalThis as any)[LISTENERS] ??= new Set<Listener>()) as Set<Listener>);
|
|
158
166
|
|
|
159
167
|
export function onContextChanged(fn: Listener): void {
|
|
160
168
|
listeners.add(fn);
|