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.
@@ -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.6",
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..f827cd7 100644
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
- @@ -2042,7 +2053,17 @@ export class InteractiveMode {
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 +2126,44 @@ export class InteractiveMode {
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 +2701,7 @@ export class InteractiveMode {
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
- getNoQuarter: () => posture === "auto",
128
+ getAutoApprove: () => posture === "auto",
129
129
  async localAsk() {
130
130
  return "deny";
131
131
  },
@@ -191,7 +191,7 @@ async function main() {
191
191
  cwd,
192
192
  confineToCwd: true,
193
193
  getRemote: () => true,
194
- getNoQuarter: () => posture() === "auto",
194
+ getAutoApprove: () => posture() === "auto",
195
195
  async localAsk() {
196
196
  return "deny";
197
197
  },
package/src/cli/chat.ts CHANGED
@@ -47,6 +47,7 @@ async function main() {
47
47
  dropPersistedAccountCredential,
48
48
  } = await import("../providers/account.ts");
49
49
  const { agentVersion } = await import("../config/version.ts");
50
+ const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
50
51
  const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
51
52
  const { postOutbox } = await import("../outbox/cloudOutbox.ts");
52
53
  const { addPendingCloud } = await import("../routines/store.ts");
@@ -625,11 +626,14 @@ async function main() {
625
626
  console.log(`${DIM}shift+tab toggles no quarter — unattended mode, no approval prompts.${RESET}`);
626
627
  await showPosture();
627
628
 
628
- // The available model catalog as sorted "provider/id" specs. Same source the
629
- // /models list and the app's picker draw from.
630
- async function availableModelSpecs(): Promise<string[]> {
631
- const all: any[] = (services.modelRegistry as any).getAvailable ? await (services.modelRegistry as any).getAvailable() : [];
632
- return all.map((m) => `${m.provider}/${m.id}`).sort();
629
+ // The available model catalog as sorted "provider/id" specs, plus what the
630
+ // registry had to leave out. Same source the /models list and the app's picker
631
+ // draw from — see providers/modelCatalog.ts for the two things that make this
632
+ // more than a map+sort (sealed models register late; the whole account catalog
633
+ // is filtered out until the credential is armed).
634
+ const SIGN_IN_HINT = "Run /login.";
635
+ async function modelCatalog() {
636
+ return pickerCatalog(services.modelRegistry as any);
633
637
  }
634
638
 
635
639
  // Switch the live session's model in place (history preserved — see
@@ -660,9 +664,17 @@ async function main() {
660
664
  // as a selection prompt and switch to whatever the driver picks. This is the
661
665
  // remote /model flow — the terminal owns the options, the app just renders them.
662
666
  async function pickModelRemote(filter: string): Promise<void> {
663
- const specs = (await availableModelSpecs()).filter((sp) => !filter || sp.toLowerCase().includes(filter));
667
+ const cat = await modelCatalog();
668
+ const specs = cat.specs.filter((sp) => !filter || sp.toLowerCase().includes(filter));
669
+ // Say why the list is short BEFORE opening it — a driver looking at a catalog
670
+ // with no confidential models has no way to tell "we don't carry them" from
671
+ // "this terminal is signed out", and only one of those has a fix.
672
+ const notice = hiddenAccountNotice(cat, SIGN_IN_HINT);
673
+ if (notice) relay?.sendNotice(notice);
664
674
  const choice = await bridge.selectRemote({
665
- title: "Choose a model",
675
+ // The suffix puts the same fact in the sheet itself, where the driver is
676
+ // actually looking. Terminal chrome, so English like the rest of the title.
677
+ title: `Choose a model${hiddenAccountTitleSuffix(cat)}`,
666
678
  options: specs.map((sp) => ({ value: sp, label: sp })),
667
679
  current: currentSpec,
668
680
  });
@@ -689,8 +701,11 @@ async function main() {
689
701
  if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
690
702
  const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
691
703
  if (remote) { await pickModelRemote(filter); return true; }
692
- const rows = (await availableModelSpecs()).filter((sp) => !filter || sp.toLowerCase().includes(filter));
704
+ const cat = await modelCatalog();
705
+ const rows = cat.specs.filter((sp) => !filter || sp.toLowerCase().includes(filter));
693
706
  console.log(rows.slice(0, 40).join("\n") + (rows.length > 40 ? `\n${DIM}… ${rows.length - 40} more (try /models <filter>)${RESET}` : ""));
707
+ const hidden = hiddenAccountNotice(cat, SIGN_IN_HINT);
708
+ if (hidden) console.log(`${DIM}${hidden}${RESET}`);
694
709
  return true;
695
710
  }
696
711
  // Extensions: remote drives the app's manager (list frame); local prints the list.
@@ -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 listeners = new Set<Listener>();
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);