privateer-agent 0.12.2 → 0.12.5

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
@@ -390,6 +390,37 @@ the app are write-only — the app can name them but never read them back. Note
390
390
  live in plaintext in `config.json` on your machine, and every channel action is appended to
391
391
  `~/.privateer/channels-audit.log`.
392
392
 
393
+ ## Drive it from Buzz or Zed — ACP
394
+
395
+ Privateer speaks the [Agent Client Protocol](https://agentclientprotocol.com) (ACP v1 over
396
+ stdio). Any ACP host — [Buzz](https://buzz.xyz), Block's team messenger where agents are
397
+ teammates, or the [Zed](https://zed.dev) editor — can spawn `privateer acp` and drive it:
398
+ prompts stream back, tool activity shows live, and the host's model picker lists Privateer's
399
+ catalog with TEE-backed models labelled **confidential (TEE)**.
400
+
401
+ The part we care about: **the host renders the UI, but authority never leaves your machine.**
402
+
403
+ - Every action is classified by Privateer's own permission gate — ACP's
404
+ `session/request_permission` only delivers the prompt.
405
+ - The **tool ceiling** comes from your local config, not the host, and ships **read-only**
406
+ (`read`, `grep`, `find`, `ls`). The host cannot widen it.
407
+ - Filesystem access is **confined to one directory root**; out-of-tree access is refused,
408
+ not prompted.
409
+ - Every ambiguous outcome — unreachable host, cancelled dialog, unknown answer, aborted
410
+ turn — resolves to **deny**.
411
+ - "Allow for this session" lives in memory and dies with the session; dangerous shell
412
+ (`curl … | sh` and friends) can never become standing permission.
413
+
414
+ Zed setup (`settings.json`):
415
+
416
+ ```json
417
+ { "agent_servers": { "Privateer": { "command": "privateer", "args": ["acp"] } } }
418
+ ```
419
+
420
+ Honest caveat for Buzz: **Buzz currently auto-approves permission prompts**, so under Buzz
421
+ the tool ceiling *is* the control — which is exactly why the default is read-only. Full
422
+ setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
423
+
393
424
  ## Connectors — MCP
394
425
 
395
426
  Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
@@ -543,7 +574,8 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
543
574
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
544
575
 
545
576
  Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
546
- `privateer --no-quarter`, `privateer --version`.
577
+ `privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
578
+ [`docs/acp.md`](docs/acp.md)), `privateer --no-quarter`, `privateer --version`.
547
579
 
548
580
  ## Develop
549
581
 
@@ -0,0 +1,132 @@
1
+ // Rotating tips in the working line. While the agent streams, the "Working..."
2
+ // loader is dead air for seconds at a time — the one spot a discoverability hint
3
+ // costs no vertical space and interrupts nothing. So while a turn is active we
4
+ // rotate short tips through it for the keys and commands nobody finds on their
5
+ // own: ctrl+t to hide thinking text (the ask that prompted this), shift+tab for
6
+ // the thinking level, @file mentions, /models, and so on.
7
+ //
8
+ // Mechanics: ctx.ui.setWorkingMessage(msg) both stores the message for the next
9
+ // working indicator AND live-updates the one currently on screen (and ONLY the
10
+ // "working" kind — Pi's guard means retry/compaction spinners are never touched),
11
+ // so a timer can rotate the text mid-stream. Calling it with no argument restores
12
+ // Pi's default "Working...", which is what agent_end does. Timers run strictly
13
+ // inside agent_start → agent_end, so an idle terminal owns no message at all.
14
+ //
15
+ // Keys render through pi-coding-agent's keyText, which reads the LIVE keybindings
16
+ // singleton the running app configured — a user remap shows the remapped key, and
17
+ // each hint carries a fallback for an unbound action. The first tip waits a beat
18
+ // (FIRST_MS) so short turns never see one; the cursor is module-lifetime so
19
+ // back-to-back turns continue the rotation instead of repeating tip #1.
20
+ //
21
+ // /hints on|off persists { hints: { enabled } } in ~/.privateer/config.json (ours;
22
+ // Pi never reads that file) — read-modify-write, preserving unrelated keys.
23
+
24
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
25
+ import { keyText } from "@earendil-works/pi-coding-agent";
26
+ import { configPath, globalDir } from "../src/config/paths.ts";
27
+
28
+ const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
29
+ const EVERY_MS = 12_000;
30
+
31
+ // keyText returns "" for an unbound action and could throw if the keybindings
32
+ // singleton isn't initialized yet (headless surfaces) — fall back either way.
33
+ function key(binding: Parameters<typeof keyText>[0], fallback: string): string {
34
+ try {
35
+ return keyText(binding) || fallback;
36
+ } catch {
37
+ return fallback;
38
+ }
39
+ }
40
+
41
+ // Lazy thunks, not strings: keys resolve at display time, after the app has
42
+ // loaded (and possibly remapped) its keybindings.
43
+ const HINTS: Array<() => string> = [
44
+ () => `${key("app.thinking.toggle", "ctrl+t")} hides thinking text — make it stick in /settings`,
45
+ () => `${key("app.thinking.cycle", "shift+tab")} cycles the thinking level`,
46
+ () => `${key("app.message.followUp", "alt+enter")} queues a follow-up without interrupting`,
47
+ () => `${key("app.tools.expand", "ctrl+o")} expands collapsed tool output`,
48
+ () => `type @ to reference a file in your prompt — Tab completes the path`,
49
+ () => `/models picks a model, with TEE/ZDR privacy shields`,
50
+ () => `/init writes a PRIVATEER.md so the agent knows this project`,
51
+ () => `/connect adds MCP connectors to this terminal`,
52
+ () => `${key("app.editor.external", "ctrl+g")} drafts long prompts in your $EDITOR`,
53
+ () => `these tips are /hints — /hints off silences them`,
54
+ ];
55
+
56
+ // Default ON: absent file, absent block, or unreadable JSON all mean enabled.
57
+ // Only an explicit { hints: { enabled: false } } turns the rotation off.
58
+ function hintsEnabled(): boolean {
59
+ try {
60
+ return JSON.parse(readFileSync(configPath(), "utf8"))?.hints?.enabled !== false;
61
+ } catch {
62
+ return true;
63
+ }
64
+ }
65
+
66
+ function persistEnabled(enabled: boolean): void {
67
+ let cfg: any = {};
68
+ try {
69
+ cfg = JSON.parse(readFileSync(configPath(), "utf8"));
70
+ } catch {
71
+ /* no config yet */
72
+ }
73
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg)) cfg = {};
74
+ cfg.hints = { ...(cfg.hints ?? {}), enabled };
75
+ try {
76
+ mkdirSync(globalDir(), { recursive: true });
77
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
78
+ } catch {
79
+ /* best effort — the toggle still holds for this session */
80
+ }
81
+ }
82
+
83
+ export default function privateerHints(pi: any): void {
84
+ let timer: ReturnType<typeof setTimeout> | undefined;
85
+ let uiRef: any;
86
+ let cursor = 0;
87
+
88
+ const stop = (restoreDefault: boolean): void => {
89
+ if (timer !== undefined) {
90
+ clearTimeout(timer);
91
+ timer = undefined;
92
+ }
93
+ if (restoreDefault) uiRef?.setWorkingMessage?.();
94
+ };
95
+
96
+ const showNext = (): void => {
97
+ const hint = HINTS[cursor % HINTS.length]();
98
+ cursor++;
99
+ uiRef?.setWorkingMessage?.(`Working... · tip: ${hint}`);
100
+ timer = setTimeout(showNext, EVERY_MS);
101
+ };
102
+
103
+ pi.on("agent_start", (_e: any, ctx: any) => {
104
+ if (!ctx?.hasUI) return; // headless (print/rpc/harbor): no loader to write to
105
+ uiRef = ctx.ui;
106
+ stop(false); // an interrupted turn can restart without an agent_end between
107
+ if (!hintsEnabled()) return;
108
+ timer = setTimeout(showNext, FIRST_MS);
109
+ });
110
+
111
+ pi.on("agent_end", () => stop(true));
112
+ pi.on("session_shutdown", () => stop(false));
113
+
114
+ pi.registerCommand?.("hints", {
115
+ description: "Rotating tips in the working line: /hints [on | off]",
116
+ handler: (args: string, ctx: any) => {
117
+ uiRef = ctx?.ui ?? uiRef;
118
+ const sub = String(args ?? "").trim().toLowerCase().split(/\s+/)[0];
119
+ if (sub === "on" || sub === "off") {
120
+ const enabled = sub === "on";
121
+ persistEnabled(enabled);
122
+ if (!enabled) stop(true); // takes effect mid-turn; "on" starts next turn
123
+ ctx?.ui?.notify?.(`Working-line tips ${enabled ? "on" : "off"}.`, "info");
124
+ return;
125
+ }
126
+ ctx?.ui?.notify?.(
127
+ `Working-line tips are ${hintsEnabled() ? "on" : "off"} — /hints on|off to change.`,
128
+ "info",
129
+ );
130
+ },
131
+ });
132
+ }
@@ -0,0 +1,109 @@
1
+ // Spoken responses AND voice input for the TUI: the generic privateer-speak (pi-speak)
2
+ // extension, plus Privateer's own providers hooked into both its registries — the
3
+ // account's confidential-compute TTS (/api/audio/speech, same path as the app's Audio
4
+ // studio and generate_speech; today Tinfoil qwen3-tts in an attested enclave) and its
5
+ // confidential STT (/api/audio/transcribe, same tinfoil/near routing the app's voice
6
+ // features use). Both inherit the account's entitlement, caps and billing.
7
+ //
8
+ // PROVIDER ATTUNEMENT, NOT STOMPING. The provider registers with preferWhen: signed-in,
9
+ // which the registry ranks ABOVE the built-in local voice but BELOW a deliberate /speak
10
+ // provider pick — sign in and speech quietly upgrades from the OS voice to confidential
11
+ // TTS, exactly the resolveSignedInModel pattern, with the model-persistence lesson
12
+ // applied (a user who picked "local" stays on "local").
13
+ //
14
+ // HONEST FRAMING. "Confidential", never "private end-to-end": the utterance text leaves
15
+ // the machine for the attested TTS enclave. That is the claim the label makes and the
16
+ // only one it may make. And this only ever speaks in interactive UI sessions — pi-speak
17
+ // gates on hasUI, so harbor/daemon/ACP surfaces stay silent by design (this extension is
18
+ // manifest-only, like privateer-hints: buildMoat never includes it).
19
+ //
20
+ // VOICES. qwen3-tts speakers, mirrored from the server's TINFOIL_VOICES (the list is
21
+ // static there too; first entry is the server-side default). Tinfoil requires the voice
22
+ // param server-side, so the server applies "serena" when none is sent — leaving voice
23
+ // unset here is always safe. If the account's default TTS model ever changes, update
24
+ // this list with it.
25
+ import { join } from "node:path";
26
+ import { makePiSpeakExtension, registerSpeechProvider, registerTranscriptionProvider } from "privateer-speak";
27
+ import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
28
+ import { globalDir } from "../src/config/paths.ts";
29
+
30
+ const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
31
+
32
+ // Call an account audio endpoint and hand back the parsed JSON, throwing the server's
33
+ // own (person-readable, localized) message on failure — same policy as media.ts's
34
+ // callAccount: surface, never swallow.
35
+ async function accountJson<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
36
+ let res: Response;
37
+ try {
38
+ res = await apiRequest(path, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify(body),
42
+ ...(signal ? { signal } : {}),
43
+ });
44
+ } catch (e) {
45
+ throw new Error(`could not reach Privateer: ${e instanceof Error ? e.message : String(e)}`);
46
+ }
47
+ if (!res.ok) {
48
+ let message = `Privateer returned ${res.status}`;
49
+ try {
50
+ const err = (await res.json()) as { message?: string; error?: { message?: string } };
51
+ message = err?.message ?? err?.error?.message ?? message;
52
+ } catch {
53
+ /* non-JSON body — the status message stands */
54
+ }
55
+ throw new Error(message);
56
+ }
57
+ return (await res.json()) as T;
58
+ }
59
+
60
+ registerSpeechProvider(
61
+ {
62
+ id: "privateer",
63
+ label: "Privateer account TTS (attested enclave)",
64
+ privacy: "confidential",
65
+ defaultVoice: QWEN3_TTS_VOICES[0],
66
+ voices: () => QWEN3_TTS_VOICES,
67
+ available: () => hasCredentials(),
68
+ fetchSpeech: async (text, { voice, signal }) => {
69
+ const { audioBase64 } = await accountJson<{ audioBase64?: string }>(
70
+ "/api/audio/speech",
71
+ { text, ...(voice ? { voice } : {}) },
72
+ signal,
73
+ );
74
+ if (!audioBase64) throw new Error("Privateer returned no audio");
75
+ return { data: Buffer.from(audioBase64, "base64"), format: "mp3" };
76
+ },
77
+ },
78
+ { preferWhen: () => hasCredentials() },
79
+ );
80
+
81
+ registerTranscriptionProvider(
82
+ {
83
+ id: "privateer",
84
+ label: "Privateer account STT (attested enclave)",
85
+ privacy: "confidential",
86
+ available: () => hasCredentials(),
87
+ transcribe: async (audio, { language, signal }) => {
88
+ const { text } = await accountJson<{ text?: string }>(
89
+ "/api/audio/transcribe",
90
+ {
91
+ audioBase64: Buffer.from(audio.data).toString("base64"),
92
+ format: audio.format,
93
+ ...(language ? { language } : {}),
94
+ },
95
+ signal,
96
+ );
97
+ return typeof text === "string" ? text : "";
98
+ },
99
+ },
100
+ { preferWhen: () => hasCredentials() },
101
+ );
102
+
103
+ export default function privateerSpeak(pi: any): void {
104
+ // Ours, beside config.json — NOT ~/.pi/speak.json, so a user who also runs plain Pi
105
+ // with the generic package keeps two independent setups instead of a fought-over
106
+ // file. globalDir() is read here, not at module load, so PRIVATEER_HOME set around
107
+ // session creation (tests, the daemon) is honoured.
108
+ makePiSpeakExtension({ configFile: join(globalDir(), "speak.json") })(pi);
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.2",
3
+ "version": "0.12.5",
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",
@@ -60,7 +60,8 @@
60
60
  "start": "tsx src/main.ts",
61
61
  "chat": "node --env-file=.env --import tsx src/cli/chat.ts",
62
62
  "channels": "node --env-file=.env --import tsx src/channels/run.ts",
63
- "dev": "tsx watch src/main.ts",
63
+ "dev": "node bin/privateer-launch.mjs",
64
+ "dev:fresh": "PRIVATEER_HOME=\"$PWD/.dev-home\" node bin/privateer-launch.mjs",
64
65
  "typecheck": "tsc --noEmit",
65
66
  "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
66
67
  },
@@ -71,7 +72,6 @@
71
72
  "@earendil-works/pi-ai": "^0.80.3",
72
73
  "@earendil-works/pi-coding-agent": "0.80.3",
73
74
  "@earendil-works/pi-tui": "^0.80.3",
74
- "@hypabolic/pi-hypa": "^0.1.6",
75
75
  "@juicesharp/rpiv-ask-user-question": "^2.2.0",
76
76
  "@juicesharp/rpiv-web-tools": "^1.20.0",
77
77
  "@noble/ciphers": "^2.1.1",
@@ -84,6 +84,7 @@
84
84
  "pi-privacy": "^0.11.0",
85
85
  "pi-subagents": "^0.34.0",
86
86
  "picomatch": "^4.0.4",
87
+ "privateer-speak": "0.1.0",
87
88
  "privateer-workflow": "^0.1.0",
88
89
  "tinfoil": "^1.1.11",
89
90
  "tsx": "^4.16.0",
@@ -451,8 +451,14 @@ export async function runDeviceLogin(opts: {
451
451
  // Pass the server's own message through so the user learns what to actually do.
452
452
  async function spawnFailure(res: Response): Promise<Error> {
453
453
  if (res.status === 401) {
454
- clearCredentials();
455
- notifySessionExpired();
454
+ // Announce only the signed-in → signed-out transition: concurrent launch-time
455
+ // spawns (warmSession + the account-channel seed) can both 401 on the same dead
456
+ // login, and whichever lands second must stay silent. Same guard as
457
+ // handleServerRevoke.
458
+ if (hasCredentials()) {
459
+ clearCredentials();
460
+ notifySessionExpired();
461
+ }
456
462
  return new Error("Your Privateer session expired. Run /login to sign in again.");
457
463
  }
458
464
  let message: string | undefined;
@@ -11,16 +11,16 @@
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-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
15
+ { "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
14
16
  { "name": "rpiv-web-tools", "dep": ["@juicesharp/rpiv-web-tools", "index.ts"], "note": "private web tools (user's own provider key)" },
15
17
  { "name": "rpiv-ask-user-question", "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "note": "ask_user_question" },
16
18
  { "name": "pi-mcp-adapter", "dep": ["pi-mcp-adapter", "index.ts"], "note": "MCP servers as first-class tools" },
17
- { "name": "pi-hypa", "dep": ["@hypabolic/pi-hypa", "extensions", "index.ts"], "note": "hypa pack" },
18
19
  { "name": "pi-subagents", "dep": ["pi-subagents", "src", "extension", "index.ts"], "note": "bounded parallel sub-agents" }
19
20
  ],
20
- "retired": ["pi-privacy", "pi-web-access"],
21
+ "retired": ["pi-privacy", "pi-web-access", "pi-hypa"],
21
22
  "reservedAliases": [
22
23
  "@juicesharp/rpiv-web-tools",
23
- "@juicesharp/rpiv-ask-user-question",
24
- "@hypabolic/pi-hypa"
24
+ "@juicesharp/rpiv-ask-user-question"
25
25
  ]
26
26
  }
@@ -56,6 +56,10 @@ const DEFAULT_MODELS = [
56
56
  "anthropic/claude-sonnet-5",
57
57
  "openai/gpt-5.6-sol",
58
58
  "deepseek/deepseek-v4-flash",
59
+ // 2026-08 releases, confirmed live on OpenRouter (ZDR-covered ids reach the
60
+ // account catalog automatically; these seeds just make them resolve at launch).
61
+ "moonshotai/kimi-k3",
62
+ "z-ai/glm-5.2",
59
63
  ];
60
64
 
61
65
  function seedModel(id: string) {
@@ -816,7 +820,10 @@ export async function armAccountCredential(
816
820
  // so staying silent leaves the user to discover it as a bare "No API key found for
817
821
  // privateer" on their first prompt.
818
822
  const c = ctx as SeedContext;
819
- if (opts.notify !== false && c?.hasUI) {
823
+ // If the credentials vanished during the call, the spawn hit a 401 and
824
+ // onSessionExpired already announced the sign-out — a second line here would
825
+ // just repeat it (we returned early above if we STARTED signed out).
826
+ if (opts.notify !== false && c?.hasUI && hasCredentials()) {
820
827
  c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
821
828
  }
822
829
  return false;
@@ -59,7 +59,7 @@ export const PROVIDERS: ProviderEntry[] = [
59
59
  api: "openai-completions",
60
60
  keyEnv: "${DASHSCOPE_API_KEY}",
61
61
  compat: { thinkingFormat: "qwen" },
62
- seedModels: ["qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
62
+ seedModels: ["qwen3.8-max-preview", "qwen3-max", "qwen3-coder-plus", "qwen-max-latest"],
63
63
  },
64
64
  { id: "ollama", source: "pi-privacy" },
65
65
  { id: "nearai", source: "pi-privacy" },
@@ -36,6 +36,13 @@ export interface ModelsJson {
36
36
  providers: Record<string, ModelsJsonProvider>;
37
37
  }
38
38
 
39
+ // Per-id context-window overrides for seeds whose real window is far from the
40
+ // Appendix A.4 default (kept until the live listing refines them). Qwen3.8-Max
41
+ // serves ~1M tokens (983,616 advertised at launch, 2026-08-03).
42
+ const SEED_CONTEXT: Record<string, number> = {
43
+ "qwen3.8-max-preview": 1_000_000,
44
+ };
45
+
39
46
  function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonModel {
40
47
  return {
41
48
  id,
@@ -43,7 +50,7 @@ function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonMode
43
50
  reasoning: false,
44
51
  input: ["text"],
45
52
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
46
- contextWindow: 128000,
53
+ contextWindow: SEED_CONTEXT[id] ?? 128000,
47
54
  maxTokens: 16384,
48
55
  ...(compat ? { compat } : {}),
49
56
  };