privateer-agent 0.12.57 → 0.13.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 CHANGED
@@ -126,6 +126,9 @@ silently. The moat is swappable; the floor under it holds.
126
126
  and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
127
127
  machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
128
128
  makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
129
+ - **Named terminals.** The agent can open windows you watch — a dev server, a REPL, a second
130
+ agent — give each a name, and bring any of them to the front; `/term api` switches you there
131
+ yourself. macOS Terminal, iTerm2, and tmux anywhere. See [Named terminals](#named-terminals).
129
132
  - **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
130
133
  or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
131
134
  parallel sub-agents, and drop in skills — all gated like everything else.
@@ -359,8 +362,9 @@ One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab`
359
362
  by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
360
363
 
361
364
  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, or a
363
- conversation turn you switched on); there is no wake word, capture is hard-capped, audio is
365
+ speak. The mic opens only on something you did (`/talk`, the push-to-talk key, a
366
+ conversation turn you switched on, or switching to a window while dictation or conversation
367
+ mode is on); there is no wake word, capture is hard-capped, audio is
364
368
  held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
365
369
  every tool call it leads to hits the same permission gate as anything you type. Recording
366
370
  needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
@@ -368,6 +372,37 @@ needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` o
368
372
  standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
369
373
  in any Pi agent.
370
374
 
375
+ ## Named terminals
376
+
377
+ Ask for "a terminal running the dev server" and the agent opens a new window, names it, and
378
+ puts it in front of you. Every Privateer window names itself too (after its folder, or the
379
+ name the agent gave it), so you and the agent can hop between them by name:
380
+
381
+ ```
382
+ /term pick a terminal to switch to
383
+ /term api switch to "api"
384
+ /term open api npm run dev open one yourself
385
+ /term name backend rename this window
386
+ /term close api close it, ending what runs there
387
+ /term list every named terminal (← marks this one)
388
+ ```
389
+
390
+ The agent does the same through its `terminal` tool. Opening a window with a command goes
391
+ through the permission gate exactly like running that command in bash; switching and listing
392
+ don't ask. The agent can't read what a window prints: that's what bash and background tasks
393
+ are for. These windows are for you.
394
+
395
+ Voice follows you between windows. Every window shares one voice setup, so with
396
+ dictation or conversation mode on, the window you switch to listens for your next prompt,
397
+ including a new agent the moment its window opens in front. The window you left pauses
398
+ its mic. (That's [privateer-speak](https://www.npmjs.com/package/privateer-speak)'s focus
399
+ awareness; it needs a terminal that reports focus.)
400
+
401
+ Works in **macOS Terminal**, **iTerm2**, and inside **tmux** on any platform; other terminals
402
+ get told so. macOS may ask once for permission to let the terminal control itself. Names are
403
+ shared across every Privateer on the machine (`~/.privateer/terminals.json`), and a window
404
+ that's been closed drops off the list on its own.
405
+
371
406
  ## The Privateer app
372
407
 
373
408
  The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
@@ -285,10 +285,10 @@ function contextLine(p: Palette): string {
285
285
  // or three things a returning user should notice. `cmd`, when present, is rendered in the
286
286
  // accent color so the actionable bit stands out from the prose. Trim this as it ages.
287
287
  const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
288
+ { text: "Open and switch named terminals —", cmd: "/term" },
289
+ { text: "Voice follows the window you're in" },
288
290
  { text: "Searchable model picker —", cmd: "/models" },
289
291
  { text: "Start over with a new agent —", cmd: "/fresh" },
290
- { text: "Text-only models can read images now" },
291
- { text: "Live crypto prices and Solana wallets" },
292
292
  ];
293
293
 
294
294
  function whatsNewRows(p: Palette): string[] {
@@ -491,11 +491,25 @@ const approveInAppMs = isSubagentChild() ? 0 : Number(process.env.PRIVATEER_APPR
491
491
  // anything, and media.ts has no business in the TUI's startup graph.
492
492
  async function quoteMediaCall(tool: string, input: unknown, signal?: AbortSignal): Promise<number | null> {
493
493
  const { readMediaCapabilities, quoteMediaCallUsd } = await import("../src/tools/media.ts");
494
+ const { effectiveMediaModel } = await import("../src/config/mediaModels.ts");
494
495
  const a = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
495
- const model = typeof a.model === "string" && a.model ? a.model : undefined;
496
- const query = tool === "generate_video" || tool === "generate_sprite" ? { videoModel: model } : tool === "generate_model" ? { model } : {};
496
+ // Price the model the tool will actually SEND — its own `model`, else this machine's
497
+ // /image-model or /video-model choice — not the account default it would fall past.
498
+ let priced = a;
499
+ let query: { imageModel?: string; videoModel?: string; model?: string } = {};
500
+ if (tool === "generate_image") {
501
+ const imageModel = effectiveMediaModel("image", a.model);
502
+ priced = { ...a, model: imageModel };
503
+ query = { imageModel };
504
+ } else if (tool === "generate_video" || tool === "generate_sprite") {
505
+ const imageModel = tool === "generate_sprite" ? effectiveMediaModel("image", a.image_model) : undefined;
506
+ priced = tool === "generate_sprite" ? { ...a, image_model: imageModel } : a;
507
+ query = { videoModel: effectiveMediaModel("video", a.model), imageModel };
508
+ } else if (tool === "generate_model") {
509
+ query = { model: typeof a.model === "string" && a.model ? a.model : undefined };
510
+ }
497
511
  const caps = await readMediaCapabilities(query, signal);
498
- return caps.ok ? quoteMediaCallUsd(tool, input, caps.data) : null;
512
+ return caps.ok ? quoteMediaCallUsd(tool, priced, caps.data) : null;
499
513
  }
500
514
 
501
515
  const cliLedger = cliGrant ? new CliSpendLedger(cliGrant, quoteMediaCall) : null;
@@ -20,14 +20,20 @@
20
20
  // already on disk — no account, no network, no spend — so it stays useful to a
21
21
  // signed-out terminal, and to a child whose job is to cut together what its parent
22
22
  // generated.
23
+ //
24
+ // /image-model and /video-model are registered unconditionally too: they only write a
25
+ // local preference, and a signed-out terminal is told to /login when it opens the
26
+ // picker rather than finding the command missing.
23
27
  import { makeMediaTools } from "../src/tools/media.ts";
24
28
  import { makeComposeTools } from "../src/tools/videoCompose.ts";
25
29
  import { mediaEnabled } from "../src/config/hosted.ts";
26
30
  import { childHoldsSpendGrant } from "../src/permissions/childSpend.ts";
27
31
  import { isSubagentChild } from "../src/remote/subagentRelay.ts";
32
+ import { registerMediaModelCommands } from "../src/tools/mediaModelCommands.ts";
28
33
 
29
34
  export default function privateerMedia(pi: any): void {
30
35
  const canSpend = !isSubagentChild() || childHoldsSpendGrant();
31
36
  if (mediaEnabled() && canSpend) makeMediaTools()(pi);
32
37
  makeComposeTools()(pi);
38
+ registerMediaModelCommands(pi);
33
39
  }
@@ -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
- makePiSpeakExtension({ configFile: join(globalDir(), "speak.json"), voiceCommands: VOICE_COMMANDS })(pi);
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.12.57",
3
+ "version": "0.13.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",
@@ -86,7 +86,7 @@
86
86
  "pi-privacy": "0.14.1",
87
87
  "pi-subagents": "0.70.0",
88
88
  "picomatch": "4.0.5",
89
- "privateer-speak": "0.6.0",
89
+ "privateer-speak": "0.7.0",
90
90
  "privateer-workflow": "0.1.0",
91
91
  "tinfoil": "1.1.11",
92
92
  "tsx": "4.23.0",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which image and video models this machine's agent generates with, when the agent
3
+ * doesn't name one itself. Set by `/image-model` and `/video-model`
4
+ * (extensions/privateer-media.ts), stored as `media.imageModel` / `media.videoModel`
5
+ * in ~/.privateer/config.json.
6
+ *
7
+ * PER MACHINE, NOT PER ACCOUNT. The account already has a default for each (the app's
8
+ * own picker writes it, server-side), and that is what an unset value here falls through
9
+ * to. A terminal choice deliberately does not write back to it: picking a cheap video
10
+ * model for a batch of terminal drafts should not change what the phone renders with.
11
+ *
12
+ * PRECEDENCE, highest first: the tool call's own `model` → this file → the account
13
+ * default. The agent naming a model still wins, because it only does that when a job
14
+ * calls for a specific one (a 4K clip, a model that takes a last frame).
15
+ *
16
+ * Read at CALL time, like piiAllow.ts: a pick made in one terminal reaches a harbor or
17
+ * a second terminal on the same machine on its next generation, without a restart.
18
+ * Missing file, unreadable file and malformed JSON all mean "no choice" — the account
19
+ * default — never a crash in the middle of a tool call.
20
+ */
21
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
22
+ import { dirname } from "node:path";
23
+ import { configPath } from "./paths.ts";
24
+
25
+ export type MediaKind = "image" | "video";
26
+
27
+ const KEY: Record<MediaKind, "imageModel" | "videoModel"> = { image: "imageModel", video: "videoModel" };
28
+
29
+ function readConfig(): Record<string, unknown> {
30
+ try {
31
+ const cfg = JSON.parse(readFileSync(configPath(), "utf8"));
32
+ return cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /** The chosen model id for `kind`, or undefined to use the account default. */
39
+ export function mediaModelPref(kind: MediaKind): string | undefined {
40
+ const media = readConfig().media as Record<string, unknown> | undefined;
41
+ const v = media?.[KEY[kind]];
42
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
43
+ }
44
+
45
+ /**
46
+ * Set (or, with null, clear) the choice for `kind`. Every other key in config.json is
47
+ * preserved — it also holds webhooks, privacy and coauthor settings this must not drop.
48
+ */
49
+ export function setMediaModelPref(kind: MediaKind, id: string | null): void {
50
+ const cfg = readConfig();
51
+ const media = { ...((cfg.media as Record<string, unknown>) ?? {}) };
52
+ const clean = id?.trim();
53
+ if (clean) media[KEY[kind]] = clean;
54
+ else delete media[KEY[kind]];
55
+ const next: Record<string, unknown> = { ...cfg, media };
56
+ if (Object.keys(media).length === 0) delete next.media;
57
+ mkdirSync(dirname(configPath()), { recursive: true });
58
+ writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
59
+ }
60
+
61
+ /** The model a call should send: its own `model`, else this machine's choice, else none. */
62
+ export function effectiveMediaModel(kind: MediaKind, requested: unknown): string | undefined {
63
+ return typeof requested === "string" && requested.trim() ? requested.trim() : mediaModelPref(kind);
64
+ }
@@ -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" },
@@ -648,6 +648,28 @@ export function classifyToolCall(
648
648
  };
649
649
  }
650
650
 
651
+ // terminal (src/tools/terminal.ts) — named windows the user watches. Opening one
652
+ // with a command RUNS that command, so it is bash-kind with the command as the
653
+ // detail: the same danger list, allowlist and plan-mode refusal as typing it into
654
+ // bash. A bare shell is bash-kind too (it puts a new window on the user's screen),
655
+ // and so is close, which ends whatever runs in the window. Focus and list only move
656
+ // a window to the front or read names: no gate, in any mode.
657
+ if (name === "terminal") {
658
+ const action = str(obj.action) || "list";
659
+ if (action === "focus" || action === "list") return null;
660
+ const label = str(obj.name);
661
+ if (action === "close") {
662
+ return { tool: toolName, kind: "bash", title: `Close terminal "${label}"`, detail: `close terminal ${label}` };
663
+ }
664
+ const command = str(obj.command).trim();
665
+ return {
666
+ tool: toolName,
667
+ kind: "bash",
668
+ title: command ? `Open terminal "${label}" running a command` : `Open terminal "${label}"`,
669
+ detail: command || `open a shell${obj.cwd ? ` in ${str(obj.cwd)}` : ""}`,
670
+ };
671
+ }
672
+
651
673
  // Network reads (web fetch / search / http).
652
674
  if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
653
675
  return {