privateer-agent 0.12.14 → 0.12.15

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/README.md CHANGED
@@ -396,6 +396,12 @@ Download for [macOS](https://privateer.pro/download/mac) (Apple silicon),
396
396
  [macOS Intel](https://privateer.pro/download/mac-intel), or
397
397
  [Windows](https://privateer.pro/download/windows).
398
398
 
399
+ Once it's installed, **`/desktop`** in the terminal brings it up — no Spotlight detour. It
400
+ opens the app, not a copy of this conversation: the desktop hosts its own session, so pick
401
+ the folder you were working in from **File ▸ Spawn Privateer at…** and it starts on the same
402
+ model and connectors this terminal uses (the per-folder defaults live in `~/.privateer`,
403
+ which both read).
404
+
399
405
  It's an early release and **not yet code-signed or notarized** — macOS will warn on first
400
406
  open. Routines and channels deliberately aren't hosted here: those belong to the always-on
401
407
  harbor, so background work still wants `privateer harbor install`.
@@ -640,6 +646,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
640
646
  | `/extensions` | list loaded Pi extensions |
641
647
  | `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
642
648
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
649
+ | `/desktop` | open the [desktop app](#desktop-app) — same login, same per-folder defaults |
643
650
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
644
651
 
645
652
  Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
@@ -0,0 +1,54 @@
1
+ // /desktop — bring up the Privateer desktop app from the terminal.
2
+ //
3
+ // The two front ends already share everything that matters: one ~/.privateer home,
4
+ // so one login, one model config, one MCP catalog, one set of per-folder spawn
5
+ // defaults. What was missing was a way across. Getting from a terminal to the app
6
+ // meant leaving the terminal — Spotlight, the Dock, a Start-menu hunt — which is
7
+ // exactly the kind of small friction that leaves a shipped app unopened.
8
+ //
9
+ // What it does NOT do is carry the conversation over: the desktop hosts its own
10
+ // in-process session and takes no folder argument (see src/config/desktopApp.ts),
11
+ // so this opens the app and says how to point a window at the folder you were just
12
+ // working in. When the app isn't installed we say so once, with the download page
13
+ // for this platform, and never again unasked — the working-line tip in
14
+ // privateer-hints.ts only fires on a machine that HAS it.
15
+
16
+ import {
17
+ desktopAppPath,
18
+ desktopDownloadAltUrl,
19
+ desktopDownloadUrl,
20
+ openDesktopApp,
21
+ } from "../src/config/desktopApp.ts";
22
+
23
+ export default function privateerDesktop(pi: any): void {
24
+ pi.registerCommand?.("desktop", {
25
+ description: "Open the Privateer desktop app — same login, same per-folder defaults",
26
+ handler: (_args: string, ctx: any) => {
27
+ const app = desktopAppPath();
28
+
29
+ if (app) {
30
+ const cwd = process.cwd();
31
+ if (openDesktopApp(app)) {
32
+ ctx?.ui?.notify?.(
33
+ `Opening the Privateer desktop app — same login and per-folder defaults as this terminal. ` +
34
+ `File ▸ Spawn Privateer at… points a window at ${cwd}.`,
35
+ "info",
36
+ );
37
+ } else {
38
+ ctx?.ui?.notify?.(`Could not launch ${app} — open it yourself and this terminal keeps working.`, "error");
39
+ }
40
+ return;
41
+ }
42
+
43
+ const url = desktopDownloadUrl();
44
+ const alt = desktopDownloadAltUrl(); // the other Mac build — see desktopApp.ts
45
+ ctx?.ui?.notify?.(
46
+ url
47
+ ? `The Privateer desktop app isn't installed here. Download: ${url}${alt ? ` (other Macs: ${alt})` : ""} — ` +
48
+ `it reads the same ~/.privateer, so it starts already signed in with your models and connectors.`
49
+ : `The desktop app ships for macOS and Windows only — on this platform the terminal agent is the app.`,
50
+ "info",
51
+ );
52
+ },
53
+ });
54
+ }
@@ -25,6 +25,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { keyText } from "@earendil-works/pi-coding-agent";
27
27
  import { configPath, globalDir } from "../src/config/paths.ts";
28
+ import { desktopAppPath } from "../src/config/desktopApp.ts";
28
29
 
29
30
  const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
30
31
  const EVERY_MS = 12_000;
@@ -76,9 +77,28 @@ const HINTS: Array<() => string> = [
76
77
  ? `${k} is push-to-talk — /speak on reads the answer back`
77
78
  : `/talk types what you say — /speak on reads the answer back`;
78
79
  },
80
+ () => (haveDesktopApp() ? `/desktop opens the Privateer desktop app — same login, same folder defaults` : ""),
79
81
  () => `these tips are /hints — /hints off silences them`,
80
82
  ];
81
83
 
84
+ // A hint returns "" when it doesn't apply to THIS machine, and the rotation skips
85
+ // it. The desktop tip is the case that needs it: naming a command for an app the
86
+ // user has is discoverability, advertising one they haven't is an ad. Memoised
87
+ // because the rotation asks every 12 s and the answer is a stat() on a path that
88
+ // doesn't change under a running terminal (installing the app mid-session is worth
89
+ // a restart, not a filesystem poll).
90
+ let desktopSeen: boolean | undefined;
91
+ function haveDesktopApp(): boolean {
92
+ if (desktopSeen === undefined) {
93
+ try {
94
+ desktopSeen = desktopAppPath() !== null;
95
+ } catch {
96
+ desktopSeen = false;
97
+ }
98
+ }
99
+ return desktopSeen;
100
+ }
101
+
82
102
  // Default ON: absent file, absent block, or unreadable JSON all mean enabled.
83
103
  // Only an explicit { hints: { enabled: false } } turns the rotation off.
84
104
  function hintsEnabled(): boolean {
@@ -119,9 +139,20 @@ export default function privateerHints(pi: any): void {
119
139
  if (restoreDefault) uiRef?.setWorkingMessage?.();
120
140
  };
121
141
 
142
+ // The next hint that applies here, advancing the cursor past any that opted out.
143
+ // Bounded by the list length, so an all-empty list ends the rotation instead of
144
+ // spinning through it forever.
145
+ const nextHint = (): string => {
146
+ for (let i = 0; i < HINTS.length; i++) {
147
+ const text = HINTS[cursor++ % HINTS.length]();
148
+ if (text) return text;
149
+ }
150
+ return "";
151
+ };
152
+
122
153
  const showNext = (): void => {
123
- const hint = HINTS[cursor % HINTS.length]();
124
- cursor++;
154
+ const hint = nextHint();
155
+ if (!hint) return; // nothing applies on this machine — leave "Working..." alone
125
156
  uiRef?.setWorkingMessage?.(`Working... · tip: ${hint}`);
126
157
  timer = setTimeout(showNext, EVERY_MS);
127
158
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.14",
3
+ "version": "0.12.15",
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",
@@ -0,0 +1,138 @@
1
+ // Where the Privateer DESKTOP app lives on this machine, and how to bring it up.
2
+ //
3
+ // The desktop app and this terminal are two front ends over one home: both read
4
+ // ~/.privateer, so they share the account login, the model config, the MCP catalog
5
+ // and the per-folder spawn defaults (config/spawns.ts). That makes "open the app"
6
+ // a genuinely useful thing for a terminal to offer — nothing is handed over, the
7
+ // state is already common — which is what /desktop (extensions/privateer-desktop.ts)
8
+ // does, and what the working-line tip in privateer-hints.ts points at.
9
+ //
10
+ // WHY DETECTION AND NOT JUST A DOWNLOAD LINK. A tip that advertises software the
11
+ // user hasn't got is an ad; one that names a command for software they HAVE is
12
+ // discoverability. So both consumers ask this module first, and the hint stays
13
+ // silent on a machine with no app installed.
14
+ //
15
+ // NO FOLDER HANDOFF. The app takes no path argument today — its second-instance
16
+ // handler just focuses the running window (desktop/src/main/main.mjs) — so /desktop
17
+ // opens the app, and the user picks this folder from File ▸ Spawn Privateer at…,
18
+ // where the spawn record this terminal already shares makes it start on the same
19
+ // model and connectors. If the app ever learns a folder argv, this is the one place
20
+ // that has to change.
21
+ //
22
+ // IMPORT-SAFETY: node builtins only, no Pi imports, no side effects — safe from an
23
+ // extension under jiti and from a pre-boot entry alike.
24
+
25
+ import { spawn } from "node:child_process";
26
+ import { existsSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { basename, join } from "node:path";
29
+
30
+ /** Per-platform download pages (README ▸ Desktop app). No Linux build exists. */
31
+ const DOWNLOAD_MAC = "https://privateer.pro/download/mac";
32
+ const DOWNLOAD_MAC_INTEL = "https://privateer.pro/download/mac-intel";
33
+ const DOWNLOAD_WINDOWS = "https://privateer.pro/download/windows";
34
+
35
+ /**
36
+ * The download page to lead with on THIS machine, or null where we ship no desktop
37
+ * build. macOS is split by CPU family — the arm64 dmg won't run on an Intel Mac —
38
+ * and process.arch is the interpreter's answer, not the machine's: an Apple-silicon
39
+ * Mac running a Rosetta node reports x64 and would be led to the Intel build. That
40
+ * is survivable rather than solved (detecting the translation means shelling out to
41
+ * `sysctl sysctl.proc_translated` for one link) because the OTHER build is offered
42
+ * alongside it — see desktopDownloadAltUrl.
43
+ */
44
+ export function desktopDownloadUrl(
45
+ platform: NodeJS.Platform = process.platform,
46
+ arch: string = process.arch,
47
+ ): string | null {
48
+ if (platform === "darwin") return arch === "x64" ? DOWNLOAD_MAC_INTEL : DOWNLOAD_MAC;
49
+ if (platform === "win32") return DOWNLOAD_WINDOWS;
50
+ return null; // electron-builder.yml targets mac + win only
51
+ }
52
+
53
+ /**
54
+ * The macOS build for the OTHER CPU family, so a Mac user is never one wrong arch
55
+ * away from a download that won't launch. Null everywhere else: Windows ships one
56
+ * x64 installer and there is no Linux build at all.
57
+ */
58
+ export function desktopDownloadAltUrl(
59
+ platform: NodeJS.Platform = process.platform,
60
+ arch: string = process.arch,
61
+ ): string | null {
62
+ if (platform !== "darwin") return null;
63
+ return arch === "x64" ? DOWNLOAD_MAC : DOWNLOAD_MAC_INTEL;
64
+ }
65
+
66
+ /**
67
+ * The installed app, or null. Ordered by confidence:
68
+ *
69
+ * 1. OUR OWN interpreter, when the terminal is running on the app's bundled Node.
70
+ * The desktop's CLI shim runs `privateer` through the app binary itself with
71
+ * ELECTRON_RUN_AS_NODE=1 (desktop/src/main/cliShim.mjs), so process.execPath IS
72
+ * the app — and it's the copy the user actually installed, wherever they put it.
73
+ * 2. The standard install locations. macOS drag-install goes to /Applications or
74
+ * ~/Applications; the Windows installer is per-user NSIS (perMachine: false) so
75
+ * %LOCALAPPDATA%\Programs\Privateer is the default, with Program Files covered
76
+ * for an install that chose it (allowToChangeInstallationDirectory: true).
77
+ *
78
+ * A user who installed somewhere else entirely reads as "not installed" — which
79
+ * costs them a download link they don't need, and never a wrong app launched.
80
+ */
81
+ export function desktopAppPath(
82
+ platform: NodeJS.Platform = process.platform,
83
+ env: Record<string, string | undefined> = process.env,
84
+ execPath: string = process.execPath,
85
+ ): string | null {
86
+ const hit = (p: string): string | null => (p && existsSync(p) ? p : null);
87
+
88
+ if (platform === "darwin") {
89
+ // …/Privateer.app/Contents/MacOS/Privateer → …/Privateer.app
90
+ const marker = "/Privateer.app/";
91
+ const at = execPath.indexOf(marker);
92
+ if (at >= 0) {
93
+ const bundle = hit(execPath.slice(0, at + marker.length - 1));
94
+ if (bundle) return bundle;
95
+ }
96
+ return hit("/Applications/Privateer.app") ?? hit(join(homedir(), "Applications", "Privateer.app"));
97
+ }
98
+
99
+ if (platform === "win32") {
100
+ if (/^privateer\.exe$/i.test(basename(execPath))) {
101
+ const self = hit(execPath);
102
+ if (self) return self;
103
+ }
104
+ const local = env.LOCALAPPDATA;
105
+ const files = env.ProgramFiles;
106
+ return (
107
+ (local ? hit(join(local, "Programs", "Privateer", "Privateer.exe")) : null) ??
108
+ (files ? hit(join(files, "Privateer", "Privateer.exe")) : null)
109
+ );
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Launch (or focus) the desktop app. Best-effort and never throws — the caller
117
+ * reports the failure as text, the way openBrowser.ts does.
118
+ *
119
+ * ELECTRON_RUN_AS_NODE IS STRIPPED, and that is load-bearing rather than tidy: when
120
+ * this terminal was itself started by the desktop's CLI shim, that variable is set
121
+ * in our environment, and a child inheriting it starts the app binary as a bare Node
122
+ * that exits without ever drawing a window. `open` goes through LaunchServices and
123
+ * wouldn't pass it on anyway; the Windows path spawns the exe directly and would.
124
+ */
125
+ export function openDesktopApp(appPath: string, platform: NodeJS.Platform = process.platform): boolean {
126
+ const { ELECTRON_RUN_AS_NODE: _drop, ...env } = process.env;
127
+ try {
128
+ const child =
129
+ platform === "darwin"
130
+ ? spawn("open", ["-a", appPath], { detached: true, stdio: "ignore", env })
131
+ : spawn(appPath, [], { detached: true, stdio: "ignore", env });
132
+ child.on("error", () => {}); // a spawn failure must not raise on the event loop
133
+ child.unref(); // never hold the CLI's exit open
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
@@ -11,6 +11,7 @@
11
11
  { "name": "privateer-privacy", "entry": "extensions/privateer-privacy.ts", "note": "pi-privacy + account tier resolver" },
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
+ { "name": "privateer-desktop", "entry": "extensions/privateer-desktop.ts", "note": "/desktop — open the Privateer desktop app" },
14
15
  { "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
15
16
  { "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
16
17
  { "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },