privateer-agent 0.12.58 → 0.14.0
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 +77 -3
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/privateer-launch.mjs +11 -1
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +23 -1
- package/extensions/privateer-speak.ts +9 -1
- package/extensions/privateer-terminals.ts +73 -0
- package/package.json +3 -2
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +6 -1
- package/src/config/moatManifest.json +1 -0
- package/src/config/whatsNew.json +10 -0
- package/src/ext/permissionGate.ts +21 -0
- package/src/harbor/accountLease.ts +76 -0
- package/src/harbor/closeSession.ts +20 -0
- package/src/harbor/index.ts +143 -58
- package/src/permissions/classify.ts +34 -4
- package/src/permissions/danger.ts +9 -0
- package/src/permissions/modeGate.ts +8 -0
- package/src/permissions/privateMode.ts +136 -0
- package/src/permissions/protected.ts +36 -1
- package/src/remote/liveTaskSession.ts +18 -2
- package/src/terminals/backends.ts +376 -0
- package/src/terminals/index.ts +200 -0
- package/src/terminals/registry.ts +107 -0
- package/src/tools/terminal.ts +80 -0
package/README.md
CHANGED
|
@@ -54,6 +54,10 @@ Privateer runs in three places, over one account and one config: the **terminal*
|
|
|
54
54
|
- **Privacy you can verify, not just trust.** Confidential-enclave (TEE) inference is
|
|
55
55
|
cryptographically **attested** — not a policy promise — and an **on-device PII gate**
|
|
56
56
|
warns before structured personal data ever leaves your machine for an unverified model.
|
|
57
|
+
- **A private subagent for your other agents.** Claude Code, Codex, or your own orchestrator
|
|
58
|
+
can hand Privateer the files it shouldn't read itself: `privateer -p --private "…"` reads
|
|
59
|
+
them on your machine, reasons over them in an attested enclave, and returns only the
|
|
60
|
+
answer. See [Hand off sensitive work](#hand-off-sensitive-work-to-privateer).
|
|
57
61
|
- **It's Pi underneath.** Privateer is a distribution of the [Pi](https://pi.dev) coding agent —
|
|
58
62
|
every Pi extension, skill, and command works, and Privateer's own features are just extensions
|
|
59
63
|
you can read, swap, or build on. Nothing to compile. See [Built on Pi](#built-on-pi).
|
|
@@ -126,6 +130,9 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
126
130
|
and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
|
|
127
131
|
machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
|
|
128
132
|
makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
|
|
133
|
+
- **Named terminals.** The agent can open windows you watch — a dev server, a REPL, a second
|
|
134
|
+
agent — give each a name, and bring any of them to the front; `/term api` switches you there
|
|
135
|
+
yourself. macOS Terminal, iTerm2, and tmux anywhere. See [Named terminals](#named-terminals).
|
|
129
136
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
130
137
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
131
138
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
@@ -359,8 +366,9 @@ One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab`
|
|
|
359
366
|
by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
|
|
360
367
|
|
|
361
368
|
Voice is **off by default and interactive-only** — harbor, ACP and channel sessions never
|
|
362
|
-
speak. The mic opens only on something you did (`/talk`, the push-to-talk key,
|
|
363
|
-
conversation turn you switched on
|
|
369
|
+
speak. The mic opens only on something you did (`/talk`, the push-to-talk key, a
|
|
370
|
+
conversation turn you switched on, or switching to a window while dictation or conversation
|
|
371
|
+
mode is on); there is no wake word, capture is hard-capped, audio is
|
|
364
372
|
held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
|
|
365
373
|
every tool call it leads to hits the same permission gate as anything you type. Recording
|
|
366
374
|
needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
|
|
@@ -368,6 +376,37 @@ needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` o
|
|
|
368
376
|
standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
|
|
369
377
|
in any Pi agent.
|
|
370
378
|
|
|
379
|
+
## Named terminals
|
|
380
|
+
|
|
381
|
+
Ask for "a terminal running the dev server" and the agent opens a new window, names it, and
|
|
382
|
+
puts it in front of you. Every Privateer window names itself too (after its folder, or the
|
|
383
|
+
name the agent gave it), so you and the agent can hop between them by name:
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
/term pick a terminal to switch to
|
|
387
|
+
/term api switch to "api"
|
|
388
|
+
/term open api npm run dev open one yourself
|
|
389
|
+
/term name backend rename this window
|
|
390
|
+
/term close api close it, ending what runs there
|
|
391
|
+
/term list every named terminal (← marks this one)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
The agent does the same through its `terminal` tool. Opening a window with a command goes
|
|
395
|
+
through the permission gate exactly like running that command in bash; switching and listing
|
|
396
|
+
don't ask. The agent can't read what a window prints: that's what bash and background tasks
|
|
397
|
+
are for. These windows are for you.
|
|
398
|
+
|
|
399
|
+
Voice follows you between windows. Every window shares one voice setup, so with
|
|
400
|
+
dictation or conversation mode on, the window you switch to listens for your next prompt,
|
|
401
|
+
including a new agent the moment its window opens in front. The window you left pauses
|
|
402
|
+
its mic. (That's [privateer-speak](https://www.npmjs.com/package/privateer-speak)'s focus
|
|
403
|
+
awareness; it needs a terminal that reports focus.)
|
|
404
|
+
|
|
405
|
+
Works in **macOS Terminal**, **iTerm2**, and inside **tmux** on any platform; other terminals
|
|
406
|
+
get told so. macOS may ask once for permission to let the terminal control itself. Names are
|
|
407
|
+
shared across every Privateer on the machine (`~/.privateer/terminals.json`), and a window
|
|
408
|
+
that's been closed drops off the list on its own.
|
|
409
|
+
|
|
371
410
|
## The Privateer app
|
|
372
411
|
|
|
373
412
|
The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
|
|
@@ -510,6 +549,8 @@ setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
|
|
|
510
549
|
Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
|
|
511
550
|
every approval arrives as a `session/request_permission` call it answers. The wire format,
|
|
512
551
|
option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
|
|
552
|
+
Handing it data your program shouldn't see? Set `"private": true` in the `acp` block; see
|
|
553
|
+
[Hand off sensitive work](#hand-off-sensitive-work-to-privateer).
|
|
513
554
|
|
|
514
555
|
### One-shot runs (`-p`) and spending
|
|
515
556
|
|
|
@@ -531,6 +572,36 @@ call. `media_capabilities` shows the same prices. A call that can't be priced is
|
|
|
531
572
|
under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
|
|
532
573
|
open or running in the background.
|
|
533
574
|
|
|
575
|
+
## Hand off sensitive work to Privateer
|
|
576
|
+
|
|
577
|
+
Your coding agent's context goes to its provider. When a task touches files that
|
|
578
|
+
shouldn't (patient records, a customer export, payroll, a database dump), the agent can
|
|
579
|
+
hand that part to Privateer instead of reading the files itself:
|
|
580
|
+
|
|
581
|
+
```bash
|
|
582
|
+
privateer -p --private --model privateer/tinfoil/gemma4-31b \
|
|
583
|
+
"Read customers/export.csv. Reply with the number of customers per country. No row-level data."
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
The calling agent passes file *names*. Privateer reads the files on this machine, reasons
|
|
587
|
+
over them on a verified-private model, and prints only the answer. `--private` is what
|
|
588
|
+
makes that a guarantee rather than a hope:
|
|
589
|
+
|
|
590
|
+
- **No file reaches an unverified model.** Before every tool call the model must be
|
|
591
|
+
`tee-verified` (an enclave quote Privateer checked itself) or `local`. A ZDR route, a
|
|
592
|
+
standard provider, or a failed attestation refuses the tool.
|
|
593
|
+
- **Nothing leaves through a tool.** Only on-machine tools run. Web, MCP, media, uploads
|
|
594
|
+
and network shell commands (`curl`, `ssh`, `git push`, …) are refused.
|
|
595
|
+
- **Nothing lifts it.** It sits above permission modes and approvals, `--no-quarter`
|
|
596
|
+
included, and subagents inherit it.
|
|
597
|
+
- **The caller sees only the answer.** `-p` prints the reply. `--private --mode json`,
|
|
598
|
+
which would stream the file contents, is refused.
|
|
599
|
+
|
|
600
|
+
The reply itself is the one channel left, so ask for aggregates, yes/no answers, or a
|
|
601
|
+
summary, and have Privateer write detail to a file. Setup for Claude Code and other agents,
|
|
602
|
+
the ACP route for your own programs, prompt patterns, and the honest limits are in
|
|
603
|
+
[`docs/private-handoff.md`](docs/private-handoff.md).
|
|
604
|
+
|
|
534
605
|
## Connectors — MCP
|
|
535
606
|
|
|
536
607
|
Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
@@ -702,7 +773,10 @@ agent is stuck),
|
|
|
702
773
|
`privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
|
|
703
774
|
[`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
|
|
704
775
|
`privateer -p … [--allow-spend …] [--approve-in-app]` (see
|
|
705
|
-
[one-shot runs](#one-shot-runs--p-and-spending)), `privateer --
|
|
776
|
+
[one-shot runs](#one-shot-runs--p-and-spending)), `privateer --private` (tools run only on a
|
|
777
|
+
verified-private model and never reach the network; see
|
|
778
|
+
[Hand off sensitive work](#hand-off-sensitive-work-to-privateer)), `privateer --no-quarter`,
|
|
779
|
+
`privateer --version`.
|
|
706
780
|
An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
|
|
707
781
|
|
|
708
782
|
## Develop
|
package/bin/headless-flags.d.mts
CHANGED
|
@@ -6,6 +6,7 @@ export const BILLED_TOOLS: string[];
|
|
|
6
6
|
export const CLI_SPEND_ENV: string;
|
|
7
7
|
export const APPROVE_IN_APP_ENV: string;
|
|
8
8
|
export const PI_SUBCOMMANDS: string[];
|
|
9
|
+
export const PRIVATE_ENV: string;
|
|
9
10
|
|
|
10
11
|
export interface CliSpendGrant {
|
|
11
12
|
tools: string[];
|
|
@@ -22,4 +23,10 @@ export function extractHeadlessFlags(args: string[]): {
|
|
|
22
23
|
error?: string;
|
|
23
24
|
};
|
|
24
25
|
|
|
26
|
+
/** Strips `--private` out of `args` in place; true when it was there. */
|
|
27
|
+
export function extractPrivateFlag(args: string[]): boolean;
|
|
28
|
+
|
|
29
|
+
/** Why these args can't run under --private, or null. */
|
|
30
|
+
export function privateProblem(args: string[]): string | null;
|
|
31
|
+
|
|
25
32
|
export function authProblem(args: string[], cmd?: string): string | null;
|
package/bin/headless-flags.mjs
CHANGED
|
@@ -137,6 +137,46 @@ export function extractHeadlessFlags(args) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** The env var carrying `--private` (permissions/privateMode.ts) into every gate. */
|
|
141
|
+
export const PRIVATE_ENV = "PRIVATEER_PRIVATE";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Strip `--private` from `args` (in place). Returns true when it was there.
|
|
145
|
+
*
|
|
146
|
+
* Valid for any launch — the TUI, `-p`, `acp` — because it only ever makes a run
|
|
147
|
+
* stricter: tools run only while the model is verified-private, and only tools that
|
|
148
|
+
* stay on this machine. Unlike the spend env vars, an exported PRIVATEER_PRIVATE is
|
|
149
|
+
* honoured too, for the same reason.
|
|
150
|
+
*/
|
|
151
|
+
export function extractPrivateFlag(args) {
|
|
152
|
+
let found = false;
|
|
153
|
+
for (let i = 0; i < args.length; i++) {
|
|
154
|
+
if (args[i] === "--") break;
|
|
155
|
+
if (args[i] === "--private") {
|
|
156
|
+
args.splice(i, 1);
|
|
157
|
+
i--;
|
|
158
|
+
found = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Why these args can't run in private mode, or null. `--mode json` streams every event
|
|
166
|
+
* to stdout, tool results included — the contents of every file the run read, handed to
|
|
167
|
+
* the very caller private mode exists to keep them from. `-p` prints only the answer.
|
|
168
|
+
*/
|
|
169
|
+
export function privateProblem(args) {
|
|
170
|
+
for (let i = 0; i < args.length; i++) {
|
|
171
|
+
const a = args[i];
|
|
172
|
+
if (a === "--") break;
|
|
173
|
+
if ((a === "--mode" && args[i + 1] === "json") || a === "--mode=json") {
|
|
174
|
+
return "--private can't be combined with --mode json: JSON mode streams every tool result (the files this run reads) to stdout. Use -p, which prints only the answer, or `privateer acp`.";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
140
180
|
// Pi's own subcommands. Pi recognizes each only as args[0], so the launcher must hand
|
|
141
181
|
// them over with NOTHING in front — the normal launch prepends --model, -e and --skill,
|
|
142
182
|
// which is exactly how `privateer auth check` used to become a chat message.
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import path from "node:path";
|
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
|
-
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, authProblem, extractHeadlessFlags } from "./headless-flags.mjs";
|
|
32
|
+
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
|
|
33
33
|
import { runToCompletion } from "./run-to-completion.mjs";
|
|
34
34
|
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
35
35
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
@@ -248,6 +248,16 @@ delete process.env[APPROVE_IN_APP_ENV];
|
|
|
248
248
|
if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
|
|
249
249
|
if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
|
|
250
250
|
}
|
|
251
|
+
// --private: NOT cleared from the inherited env first, unlike the two above — it can only
|
|
252
|
+
// tighten a run, so an orchestrator exporting it for every child is the point.
|
|
253
|
+
if (extractPrivateFlag(args)) process.env[PRIVATE_ENV] = "1";
|
|
254
|
+
if (/^(1|true|yes)$/i.test((process.env[PRIVATE_ENV] ?? "").trim())) {
|
|
255
|
+
const problem = privateProblem(args);
|
|
256
|
+
if (problem) {
|
|
257
|
+
process.stderr.write(`privateer: ${problem}\n`);
|
|
258
|
+
process.exit(2);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
251
261
|
|
|
252
262
|
const sub = args[0];
|
|
253
263
|
|
|
@@ -281,17 +281,21 @@ function contextLine(p: Palette): string {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// ── "What's New" — a tiny in-banner changelog ────────────────────────────────
|
|
284
|
-
// A hand-curated highlights list (newest first)
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
284
|
+
// A hand-curated highlights list (newest first), kept in src/config/whatsNew.json so the
|
|
285
|
+
// release guard (scripts/check-whats-new.mjs) can read it without loading the TUI. Not the
|
|
286
|
+
// full changelog — just the two to four things a returning user should notice. `cmd`, when
|
|
287
|
+
// present, is rendered in the accent color so the actionable bit stands out from the prose.
|
|
288
|
+
const WHATS_NEW: Array<{ text: string; cmd?: string }> = (() => {
|
|
289
|
+
try {
|
|
290
|
+
const list = JSON.parse(readFileSync(new URL("../src/config/whatsNew.json", import.meta.url), "utf8"));
|
|
291
|
+
return Array.isArray(list?.items) ? list.items : [];
|
|
292
|
+
} catch {
|
|
293
|
+
return []; // a banner without the list beats no banner
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
293
296
|
|
|
294
297
|
function whatsNewRows(p: Palette): string[] {
|
|
298
|
+
if (!WHATS_NEW.length) return [];
|
|
295
299
|
const head = `${p.BOLD}${p.INK}✦ What's new${p.RESET}`;
|
|
296
300
|
const items = WHATS_NEW.map(
|
|
297
301
|
({ text, cmd }) =>
|
|
@@ -322,7 +326,8 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
|
|
|
322
326
|
const packs = packNotice(p);
|
|
323
327
|
if (packs) text.push(packs);
|
|
324
328
|
// A blank spacer, then the What's New block — set off below the identity lines.
|
|
325
|
-
|
|
329
|
+
const news = whatsNewRows(p);
|
|
330
|
+
if (news.length) text.push("", ...news);
|
|
326
331
|
|
|
327
332
|
// Zip the mark and the text column by row. Rows past the mark's height get a blank
|
|
328
333
|
// gutter of the mark's width, so the text stays in one column throughout.
|
|
@@ -40,6 +40,7 @@ import { updatePostureBadge } from "./privateer-posture.ts";
|
|
|
40
40
|
import { childSpendAllows } from "../src/permissions/childSpend.ts";
|
|
41
41
|
import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
|
|
42
42
|
import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
|
|
43
|
+
import { privateModeFromEnv } from "../src/permissions/privateMode.ts";
|
|
43
44
|
import type { PermissionRequest } from "../src/permissions/gate.ts";
|
|
44
45
|
import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
|
|
45
46
|
import { describeErrorText } from "../src/engine/errors.ts";
|
|
@@ -694,7 +695,9 @@ export default function privateerControl(pi: any): void {
|
|
|
694
695
|
// prompt, before it plans). Quiet when nothing billable is enabled, or when no quarter
|
|
695
696
|
// lifts the gate anyway.
|
|
696
697
|
function uncoveredBilledTools(): string[] {
|
|
697
|
-
|
|
698
|
+
// Private mode refuses every billed tool outright (they send text off the machine),
|
|
699
|
+
// so "add --allow-spend" would be advice that cannot work.
|
|
700
|
+
if (noQuarterActive() || privateModeFromEnv()) return [];
|
|
698
701
|
const active: string[] = (() => {
|
|
699
702
|
try {
|
|
700
703
|
return pi.getActiveTools?.() ?? [];
|
|
@@ -750,6 +753,25 @@ export default function privateerControl(pi: any): void {
|
|
|
750
753
|
});
|
|
751
754
|
pi.on("session_shutdown", () => appApprover?.close());
|
|
752
755
|
|
|
756
|
+
// Private mode (src/permissions/privateMode.ts), told to the model before it plans:
|
|
757
|
+
// otherwise it reaches for web_search, gets refused, and narrates the refusal instead
|
|
758
|
+
// of doing the local work. And the reply is the one channel the gate can't close, so
|
|
759
|
+
// the model is asked to keep raw records out of it — a request, not a control.
|
|
760
|
+
pi.on("before_agent_start", (event: any) => {
|
|
761
|
+
if (!privateModeFromEnv()) return;
|
|
762
|
+
return {
|
|
763
|
+
systemPrompt:
|
|
764
|
+
`${event.systemPrompt}\n\n# Private mode\n` +
|
|
765
|
+
"This run handles data that must not leave this machine except through a verified-private model. " +
|
|
766
|
+
"Only local tools work: read, grep, find, ls, write, edit, and shell commands that don't use the network. " +
|
|
767
|
+
"Web, MCP, media and upload tools, and network commands, are refused. Every tool is refused if the model " +
|
|
768
|
+
"can't be verified as private; if that happens, stop and say so.\n" +
|
|
769
|
+
"Your reply leaves private mode: whoever asked will see it. Answer exactly what was asked. Don't quote raw " +
|
|
770
|
+
"records, names, emails, identifiers or other personal data from the files unless the task explicitly asks " +
|
|
771
|
+
"for them. When asked for detail, write it to a file and reply with the path.",
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
|
|
753
775
|
// Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
|
|
754
776
|
// "Connection error." — and exits. Follow it with what that means and what to do,
|
|
755
777
|
// on stderr so stdout stays the answer (or the JSON stream).
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "privateer-speak";
|
|
62
62
|
import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
|
|
63
63
|
import { globalDir } from "../src/config/paths.ts";
|
|
64
|
+
import { thisTerminalIsFront } from "../src/terminals/index.ts";
|
|
64
65
|
import { VOICE_DESCRIPTIONS } from "./speak-voices.data.ts";
|
|
65
66
|
|
|
66
67
|
const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
|
|
@@ -358,7 +359,14 @@ export default function privateerSpeak(pi: any): void {
|
|
|
358
359
|
// voiceCommands: saying one of these AS THE WHOLE UTTERANCE runs /fresh (see
|
|
359
360
|
// extensions/privateer-fresh.ts) instead of sending the words to the model. Whole
|
|
360
361
|
// utterance only, so "why did the fresh start fail" is still a question.
|
|
361
|
-
|
|
362
|
+
// isFocused: a window that has just started (say, one the agent opened with the
|
|
363
|
+
// terminal tool) asks Terminal/iTerm2 whether it's in front, so a hands-free mode
|
|
364
|
+
// carries on in the new agent without a keypress — see src/terminals/.
|
|
365
|
+
makePiSpeakExtension({
|
|
366
|
+
configFile: join(globalDir(), "speak.json"),
|
|
367
|
+
voiceCommands: VOICE_COMMANDS,
|
|
368
|
+
isFocused: thisTerminalIsFront,
|
|
369
|
+
})(pi);
|
|
362
370
|
}
|
|
363
371
|
|
|
364
372
|
const VOICE_COMMANDS: Record<string, string> = {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Named terminals — the `terminal` tool and /term, so the agent can open windows the
|
|
2
|
+
// user watches (a dev server, a REPL, a second agent) and either of them can switch
|
|
3
|
+
// between those windows by name. See src/terminals/ for how windows are found and why
|
|
4
|
+
// only tmux, macOS Terminal and iTerm2 are driven.
|
|
5
|
+
//
|
|
6
|
+
// Interactive only: the window registers itself at session start, which a harbor or ACP
|
|
7
|
+
// host has no window to do, and the tool refuses without a UI.
|
|
8
|
+
|
|
9
|
+
import { closeTerminal, describeListing, focusTerminal, openTerminal, renameSelf, terminalListing, backend, claimSelf } from "../src/terminals/index.ts";
|
|
10
|
+
import { terminalToolDefinition } from "../src/tools/terminal.ts";
|
|
11
|
+
|
|
12
|
+
const USAGE =
|
|
13
|
+
"/term — pick a terminal to switch to · /term <name> — switch · /term open <name> [command] · " +
|
|
14
|
+
"/term name <name> — rename this one · /term close <name> · /term list";
|
|
15
|
+
|
|
16
|
+
export default function privateerTerminals(pi: any): void {
|
|
17
|
+
pi.registerTool?.(terminalToolDefinition);
|
|
18
|
+
|
|
19
|
+
// Name this window as soon as the session starts, so it can be switched back to from
|
|
20
|
+
// any other — not only after its first /term.
|
|
21
|
+
pi.on?.("session_start", (_e: any, ctx: any) => {
|
|
22
|
+
if (!ctx?.hasUI) return;
|
|
23
|
+
const b = backend();
|
|
24
|
+
if ("unsupported" in b) return;
|
|
25
|
+
void claimSelf(b, ctx.cwd ?? process.cwd()).catch(() => undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
pi.registerCommand?.("term", {
|
|
29
|
+
description: "Switch between named terminals, or open one",
|
|
30
|
+
handler: async (args: string, ctx: any) => {
|
|
31
|
+
const notify = (m: string, kind: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(m, kind);
|
|
32
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
33
|
+
const [sub = "", ...rest] = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
const report = (o: { ok: boolean; message: string }) => notify(o.message, o.ok ? "info" : "warning");
|
|
35
|
+
|
|
36
|
+
switch (sub.toLowerCase()) {
|
|
37
|
+
case "": {
|
|
38
|
+
const l = await terminalListing(cwd);
|
|
39
|
+
if ("unsupported" in l) return notify(l.unsupported, "warning");
|
|
40
|
+
const others = l.records.filter((r) => r.id !== l.selfId);
|
|
41
|
+
if (!others.length) return notify(`No other named terminals. ${USAGE}`);
|
|
42
|
+
if (typeof ctx?.ui?.select !== "function") return notify(describeListing(l));
|
|
43
|
+
const labels = others.map((r) => `${r.name} ${r.command ?? r.cwd}`);
|
|
44
|
+
const picked = await ctx.ui.select("Switch to terminal", labels);
|
|
45
|
+
const rec = picked ? others[labels.indexOf(picked)] : undefined;
|
|
46
|
+
if (rec) report(await focusTerminal(rec.name, cwd));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case "list": {
|
|
50
|
+
const l = await terminalListing(cwd);
|
|
51
|
+
return notify("unsupported" in l ? l.unsupported : describeListing(l), "unsupported" in l ? "warning" : "info");
|
|
52
|
+
}
|
|
53
|
+
case "open": {
|
|
54
|
+
const [name, ...cmd] = rest;
|
|
55
|
+
if (!name) return notify("Usage: /term open <name> [command]", "warning");
|
|
56
|
+
return report(await openTerminal({ name, cwd, ...(cmd.length ? { command: cmd.join(" ") } : {}) }));
|
|
57
|
+
}
|
|
58
|
+
case "name":
|
|
59
|
+
case "rename":
|
|
60
|
+
if (!rest.length) return notify("Usage: /term name <name>", "warning");
|
|
61
|
+
return report(await renameSelf(rest.join(" "), cwd));
|
|
62
|
+
case "close":
|
|
63
|
+
if (!rest.length) return notify("Usage: /term close <name>", "warning");
|
|
64
|
+
return report(await closeTerminal(rest.join(" "), cwd));
|
|
65
|
+
case "help":
|
|
66
|
+
return notify(USAGE);
|
|
67
|
+
default:
|
|
68
|
+
// Anything else is a name: /term api, /term "dev server".
|
|
69
|
+
return report(await focusTerminal([sub, ...rest].join(" "), cwd));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"dev": "node bin/privateer-launch.mjs",
|
|
65
65
|
"dev:fresh": "PRIVATEER_HOME=\"$PWD/.dev-home\" node bin/privateer-launch.mjs",
|
|
66
66
|
"typecheck": "tsc --noEmit",
|
|
67
|
+
"check:whats-new": "node scripts/check-whats-new.mjs",
|
|
67
68
|
"test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
|
|
68
69
|
},
|
|
69
70
|
"engines": {
|
|
@@ -86,7 +87,7 @@
|
|
|
86
87
|
"pi-privacy": "0.14.1",
|
|
87
88
|
"pi-subagents": "0.70.0",
|
|
88
89
|
"picomatch": "4.0.5",
|
|
89
|
-
"privateer-speak": "0.
|
|
90
|
+
"privateer-speak": "0.7.0",
|
|
90
91
|
"privateer-workflow": "0.1.0",
|
|
91
92
|
"tinfoil": "1.1.11",
|
|
92
93
|
"tsx": "4.23.0",
|
package/src/acp/run.ts
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// "model": "openrouter/openai/gpt-4o-mini", // optional
|
|
22
22
|
// "tools": ["read","grep","find","ls"], // optional ceiling
|
|
23
23
|
// "posture": "approve", // readonly | approve | auto
|
|
24
|
-
// "cwd": "/path/to/project"
|
|
24
|
+
// "cwd": "/path/to/project", // optional; else this process's cwd
|
|
25
|
+
// "private": true // optional; see permissions/privateMode.ts
|
|
25
26
|
// }
|
|
26
27
|
// }
|
|
27
28
|
//
|
|
@@ -115,6 +116,10 @@ export async function runAcp(): Promise<void> {
|
|
|
115
116
|
: (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
|
|
116
117
|
const posture: Posture = normalizePosture(block.posture) ?? "approve";
|
|
117
118
|
const baseCwd: string = block.cwd ?? process.cwd();
|
|
119
|
+
// Private mode: `acp.private`, or `privateer acp --private` / PRIVATEER_PRIVATE (read by
|
|
120
|
+
// the gate itself). The handoff surface for an agent that must not see the data.
|
|
121
|
+
const { privateModeFromEnv, modelTier, PRIVATE_TIERS } = await import("../permissions/privateMode.ts");
|
|
122
|
+
const privateMode: boolean = block.private === true || privateModeFromEnv();
|
|
118
123
|
|
|
119
124
|
// The gate. Identical posture semantics to the channels runtime: "readonly" maps to
|
|
120
125
|
// plan mode (hard-deny writes), "auto" relaxes non-dangerous actions, and every ask
|
|
@@ -129,6 +134,7 @@ export async function runAcp(): Promise<void> {
|
|
|
129
134
|
confineToCwd: true,
|
|
130
135
|
getRemote: () => true,
|
|
131
136
|
getAutoApprove: () => posture === "auto",
|
|
137
|
+
getPrivate: () => privateMode,
|
|
132
138
|
async localAsk() {
|
|
133
139
|
return "deny";
|
|
134
140
|
},
|
|
@@ -272,8 +278,22 @@ export async function runAcp(): Promise<void> {
|
|
|
272
278
|
const modelCount = listModels()?.available.length ?? 0;
|
|
273
279
|
log(
|
|
274
280
|
`ready — model ${defaultModel}, ${modelCount} selectable, ` +
|
|
275
|
-
`ceiling [${tools.join(", ")}], posture ${posture}`,
|
|
281
|
+
`ceiling [${tools.join(", ")}], posture ${posture}${privateMode ? ", PRIVATE" : ""}`,
|
|
276
282
|
);
|
|
283
|
+
// Say up front when private mode will refuse every tool, rather than letting the
|
|
284
|
+
// caller find out from its first denial. The gate re-checks per call regardless.
|
|
285
|
+
if (privateMode) {
|
|
286
|
+
try {
|
|
287
|
+
const tier = await modelTier({ provider: model.provider, id: model.id, baseUrl: model.baseUrl });
|
|
288
|
+
log(
|
|
289
|
+
PRIVATE_TIERS.has(tier)
|
|
290
|
+
? `private mode: ${defaultModel} is ${tier}`
|
|
291
|
+
: `private mode: ${defaultModel} is ${tier}, NOT verified-private — every tool will be refused. Pick a TEE or local model.`,
|
|
292
|
+
);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
log(`private mode: could not verify ${defaultModel} (${e instanceof Error ? e.message : String(e)}) — tools will be refused until it verifies`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
277
297
|
|
|
278
298
|
// One Pi session per ACP session. A single subscription routes streamed text into a
|
|
279
299
|
// mutable holder, which the running turn owns — safe because a session runs at most
|
|
@@ -353,7 +373,12 @@ export async function runAcp(): Promise<void> {
|
|
|
353
373
|
// its own `hasConfiguredAuth` precheck before it emits `before_agent_start`,
|
|
354
374
|
// where providers/account.ts installs the equivalent net.
|
|
355
375
|
await ensureAccountArmed(currentProvider);
|
|
356
|
-
|
|
376
|
+
// Prompt text is the HOST's, so it is never a command: with expansion on, Pi
|
|
377
|
+
// runs a leading `/name` as an extension command, and anyone who can post to
|
|
378
|
+
// the host (a Buzz channel, a calling agent) could send `/privacy off` or
|
|
379
|
+
// `/privacy allow *` — the second persisted to config.json. Text goes to the
|
|
380
|
+
// model as text.
|
|
381
|
+
await session.prompt(text, { expandPromptTemplates: false });
|
|
357
382
|
} catch (e) {
|
|
358
383
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
359
384
|
} finally {
|
|
@@ -371,7 +396,7 @@ export async function runAcp(): Promise<void> {
|
|
|
371
396
|
|
|
372
397
|
let agent: InstanceType<typeof PrivateerAcpAgent> | undefined;
|
|
373
398
|
new AgentSideConnection((conn) => {
|
|
374
|
-
agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log });
|
|
399
|
+
agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log, private: privateMode });
|
|
375
400
|
return agent;
|
|
376
401
|
}, stream);
|
|
377
402
|
|
package/src/acp/server.ts
CHANGED
|
Binary file
|
package/src/channels/run.ts
CHANGED
|
@@ -332,7 +332,12 @@ async function main() {
|
|
|
332
332
|
// in providers/account.ts hangs off `before_agent_start`, which pi's prompt()
|
|
333
333
|
// never reaches — it throws on its own `hasConfiguredAuth` precheck first.
|
|
334
334
|
if (model?.provider === "privateer") await ensureAccountArmed(undefined);
|
|
335
|
-
|
|
335
|
+
// A chat message is never a command: with expansion on, Pi runs a leading
|
|
336
|
+
// `/name` as an extension command, so any chat member could send `/privacy off`
|
|
337
|
+
// or `/privacy allow *` (persisted to config.json). Same rule as ACP.
|
|
338
|
+
await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () =>
|
|
339
|
+
session.prompt(text, { expandPromptTemplates: false }),
|
|
340
|
+
);
|
|
336
341
|
} catch (e) {
|
|
337
342
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
338
343
|
} finally {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
21
21
|
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
22
22
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
|
23
|
+
{ "entry": "extensions/privateer-terminals.ts", "name": "privateer-terminals", "note": "named terminal windows — the terminal tool + /term to switch between them" },
|
|
23
24
|
{ "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
|
|
24
25
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
25
26
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
+
"reviewedFor": "0.14.0",
|
|
4
|
+
"items": [
|
|
5
|
+
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
|
|
6
|
+
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
|
|
7
|
+
{ "text": "Voice follows the window you're in", "since": "0.13.0" },
|
|
8
|
+
{ "text": "Searchable model picker —", "cmd": "/models", "since": "0.12.53" }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
@@ -18,6 +18,7 @@ import { classifyToolCall } from "../permissions/classify.ts";
|
|
|
18
18
|
import { redactText } from "../util/redact.ts";
|
|
19
19
|
import { coauthorGitCommits } from "../util/coauthor.ts";
|
|
20
20
|
import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
|
|
21
|
+
import { privateModeFromEnv, privateRefusal, type ModelRef } from "../permissions/privateMode.ts";
|
|
21
22
|
|
|
22
23
|
// The per-tool_call hook context we read (structural subset of Pi's ctx — kept
|
|
23
24
|
// local so the gate stays import-order-safe and unit-testable).
|
|
@@ -25,6 +26,8 @@ export interface ToolCallCtx {
|
|
|
25
26
|
signal?: AbortSignal;
|
|
26
27
|
hasUI?: boolean;
|
|
27
28
|
mode?: "tui" | "rpc" | "json" | "print" | string;
|
|
29
|
+
// The session's current model — what private mode checks before any tool runs.
|
|
30
|
+
model?: ModelRef;
|
|
28
31
|
// Pi's ExtensionUIContext shape (verified against pi-coding-agent 0.80):
|
|
29
32
|
// select(title, options: string[]) → chosen string | undefined; confirm(title, message).
|
|
30
33
|
ui?: {
|
|
@@ -43,6 +46,10 @@ export interface GateController {
|
|
|
43
46
|
denylist?: string[];
|
|
44
47
|
cwd: string;
|
|
45
48
|
confineToCwd?: boolean;
|
|
49
|
+
// Unattended confinement: deny outside-cwd and protected access in every mode, and
|
|
50
|
+
// treat a read of a credential store as protected even inside cwd. See
|
|
51
|
+
// ModeGate.hardConfine and ScopeOptions.guardSecretReads.
|
|
52
|
+
hardConfine?: boolean;
|
|
46
53
|
getRemote?(): boolean;
|
|
47
54
|
// The controller raised the flag: total bypass for remote turns — see
|
|
48
55
|
// ModeGate.getNoQuarter.
|
|
@@ -54,6 +61,11 @@ export interface GateController {
|
|
|
54
61
|
// launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
|
|
55
62
|
// action with no prompt.
|
|
56
63
|
getSkipAllPermissions?(): boolean;
|
|
64
|
+
// Private mode (permissions/privateMode.ts): no tool runs unless the session's model is
|
|
65
|
+
// verified-private, and only tools that stay on this machine run at all. ORed with
|
|
66
|
+
// PRIVATEER_PRIVATE, which any controller honours, so a controller can switch it on but
|
|
67
|
+
// never off.
|
|
68
|
+
getPrivate?(): boolean;
|
|
57
69
|
// Billing tools the operator authorized before this run started, so an unattended
|
|
58
70
|
// session can spend what it was told it may spend instead of denying every media
|
|
59
71
|
// call for want of a human. Lifts `alwaysAsk` and nothing else — see
|
|
@@ -138,10 +150,18 @@ export async function decideToolCall(
|
|
|
138
150
|
};
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
// Private mode sits above every mode, allowlist and approval: nothing a person or a
|
|
154
|
+
// host answers can show a file to an unverified model or send it off the machine.
|
|
155
|
+
if (ctrl.getPrivate?.() || privateModeFromEnv()) {
|
|
156
|
+
const refusal = await privateRefusal(toolName, input, ctx.model);
|
|
157
|
+
if (refusal) return { block: true, reason: refusal };
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
const req = classifyToolCall(toolName, input, {
|
|
142
161
|
cwd: ctrl.cwd,
|
|
143
162
|
confineToCwd: ctrl.confineToCwd,
|
|
144
163
|
allowedOutsideRoots: ctrl.allowedOutsideRoots,
|
|
164
|
+
guardSecretReads: ctrl.hardConfine,
|
|
145
165
|
});
|
|
146
166
|
if (!req) return undefined; // no gate needed — read-only/in-scope/meta
|
|
147
167
|
|
|
@@ -181,6 +201,7 @@ export async function decideToolCall(
|
|
|
181
201
|
getNoQuarter: ctrl.getNoQuarter,
|
|
182
202
|
getAutoApprove: ctrl.getAutoApprove,
|
|
183
203
|
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
204
|
+
hardConfine: ctrl.hardConfine,
|
|
184
205
|
isSpendPreauthorized: ctrl.isSpendPreauthorized
|
|
185
206
|
? (r: PermissionRequest) => ctrl.isSpendPreauthorized!(r, input, ctx.signal)
|
|
186
207
|
: undefined,
|