privateer-agent 0.12.36 β†’ 0.12.38

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.
@@ -9,7 +9,7 @@
9
9
  import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
10
  import { accountPosture } from "../src/providers/account.ts";
11
11
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
12
- import { privacyDisabled } from "../src/config/privacyDisabled.ts";
12
+ import { onPrivacyDisabledChange, privacyDisabled } from "../src/config/privacyDisabled.ts";
13
13
 
14
14
  const DOT: Record<string, string> = { green: "🟒", yellow: "🟑", red: "πŸ”΄", neutral: "βšͺ" };
15
15
 
@@ -79,6 +79,21 @@ export default function privateerPosture(pi: any): void {
79
79
  }
80
80
  };
81
81
 
82
- pi.on("model_select", (event: any, ctx: any) => update(event?.model?.provider, event?.model?.id, ctx));
83
- pi.on("session_start", (_event: any, ctx: any) => update(ctx?.model?.provider, ctx?.model?.id, ctx));
82
+ // Keep the last ctx a real event handed us: the badge has to be repainted when the
83
+ // privacy flag moves, and the flag can now move with no event behind it at all β€”
84
+ // the app's shield toggle writes it over the relay (RemoteBridge's onPrivacy), so
85
+ // `/privacy` is no longer the only driver and the CLI's own badge would otherwise
86
+ // sit on "⛉ Trusted Execution" over a session whose filter a phone just took down.
87
+ let lastCtx: any = null;
88
+ pi.on("model_select", (event: any, ctx: any) => {
89
+ lastCtx = ctx;
90
+ return update(event?.model?.provider, event?.model?.id, ctx);
91
+ });
92
+ pi.on("session_start", (_event: any, ctx: any) => {
93
+ lastCtx = ctx;
94
+ return update(ctx?.model?.provider, ctx?.model?.id, ctx);
95
+ });
96
+ onPrivacyDisabledChange(() => {
97
+ void update(lastCtx?.model?.provider, lastCtx?.model?.id, lastCtx);
98
+ });
84
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.36",
3
+ "version": "0.12.38",
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",
@@ -24,11 +24,27 @@ export function privacyDisabled(): boolean {
24
24
  return process.env[ENV] === "1" || process.env[ALT_ENV] === "1";
25
25
  }
26
26
 
27
+ // WHO ELSE NEEDS TO KNOW. The env var is the store, but it is not observable β€” and the
28
+ // switch now has two drivers on the same session: `/privacy off` typed into a composer,
29
+ // and the app's own shield toggle (RemoteBridge's onPrivacy). Whichever one moves it, the
30
+ // app's switch has to follow, or the desktop shows a shield over a session whose filter
31
+ // its own composer just turned off. Listeners fire on a real CHANGE only, so a re-assert
32
+ // is not an event; failures are swallowed, because a subscriber that throws must not
33
+ // leave the flag half-applied.
34
+ const listeners = new Set<(disabled: boolean) => void>();
35
+
36
+ /** Watch the disabled state. Returns an unsubscribe. Fires only when the value changes. */
37
+ export function onPrivacyDisabledChange(fn: (disabled: boolean) => void): () => void {
38
+ listeners.add(fn);
39
+ return () => listeners.delete(fn);
40
+ }
41
+
27
42
  /**
28
43
  * Set the disabled state. Modifies process.env so every module copy and child process agrees.
29
44
  * Returns the new state.
30
45
  */
31
46
  export function setPrivacyDisabled(disabled: boolean): boolean {
47
+ const before = privacyDisabled();
32
48
  if (disabled) {
33
49
  process.env[ENV] = "1";
34
50
  process.env[ALT_ENV] = "1";
@@ -36,6 +52,11 @@ export function setPrivacyDisabled(disabled: boolean): boolean {
36
52
  delete process.env[ENV];
37
53
  delete process.env[ALT_ENV];
38
54
  }
55
+ if (before !== disabled) {
56
+ for (const fn of listeners) {
57
+ try { fn(disabled); } catch { /* a watcher must not break the toggle */ }
58
+ }
59
+ }
39
60
  return disabled;
40
61
  }
41
62
 
@@ -126,6 +126,13 @@ export interface RelayCallbacks {
126
126
  // bypass mode instead of relaying every action, so the agent runs to completion.
127
127
  // Dangerous/destructive actions still relay β€” they sit above bypass locally too.
128
128
  onNoQuarter?: (on: boolean) => void;
129
+ // The app toggled the privacy filter. `off` true = pi-privacy is disabled for this
130
+ // session, exactly as `/privacy off` does it: no outbound PII scan or prompt, no
131
+ // tool-result credential redaction, no exfiltration or downgrade guard. The app is
132
+ // the only surface that can ask for this without a keyboard on this machine, so the
133
+ // frame carries the SAME state the command writes (process.env, shared with
134
+ // subagent children) rather than a second flag beside it.
135
+ onPrivacy?: (off: boolean) => void;
129
136
  // A controller attached β€” push a transcript snapshot so it can catch up.
130
137
  onControllerAttached: () => void;
131
138
  // The last controller went away (app closed / socket reaped). The terminal keeps
@@ -696,6 +703,7 @@ export class RelayClient {
696
703
  seq?: number;
697
704
  data?: string;
698
705
  on?: boolean;
706
+ off?: boolean;
699
707
  value?: string;
700
708
  source?: string;
701
709
  description?: string;
@@ -763,6 +771,9 @@ export class RelayClient {
763
771
  case "no_quarter":
764
772
  this.cb.onNoQuarter?.(frame.on === true);
765
773
  break;
774
+ case "privacy":
775
+ this.cb.onPrivacy?.(frame.off === true);
776
+ break;
766
777
  case "controller_attached":
767
778
  this.cb.onControllerAttached();
768
779
  break;
@@ -1158,6 +1169,15 @@ export class RelayClient {
1158
1169
  this.rawSend({ type: "no_quarter", on });
1159
1170
  }
1160
1171
 
1172
+ // Echo the privacy-filter state up to the controller, same contract as
1173
+ // sendNoQuarter: an ack of the app's toggle, and a resync on controller attach.
1174
+ // Only ever sent while the filter is OFF β€” the app defaults its switch to "on"
1175
+ // (the safe reading), so a terminal too old to know this frame, or one running
1176
+ // with the filter intact, correctly shows protected.
1177
+ sendPrivacy(off: boolean): void {
1178
+ this.rawSend({ type: "privacy", off });
1179
+ }
1180
+
1161
1181
  // Push this terminal's live context (selected model, agent version) to a
1162
1182
  // controller so the app's session banner reflects reality instead of a stub.
1163
1183
  // Sent on controller attach β€” like the snapshot/no_quarter resync. NON-PII ONLY
@@ -12,6 +12,9 @@
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
14
  import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts";
15
+ // Pure env-var accessors (see config/privacyDisabled.ts) β€” no Pi, no node builtins,
16
+ // so this stays safe to import from the bridge, which boot-ordered code reaches.
17
+ import { onPrivacyDisabledChange, privacyDisabled, setPrivacyDisabled } from "../config/privacyDisabled.ts";
15
18
  import type { EngineEvent } from "../engine/events.ts";
16
19
  import type { PermissionRequest } from "../permissions/gate.ts";
17
20
  import type { AskOutcome } from "../permissions/modeGate.ts";
@@ -56,6 +59,10 @@ export interface RelayLike {
56
59
  // turns into a duplicate of something the app already displayed.
57
60
  hasController?(): boolean;
58
61
  sendNoQuarter(on: boolean): void;
62
+ // Echo the privacy-filter state (off = pi-privacy disabled). Optional so a
63
+ // transport built against an older shape still satisfies the interface; the
64
+ // bridge's callers all treat a missing echo as "filter on", which is the safe read.
65
+ sendPrivacy?(off: boolean): void;
59
66
  sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
60
67
  sendNotice(text: string): void;
61
68
  sendCommands(commands: { name: string; description?: string }[]): void;
@@ -190,7 +197,25 @@ export class RemoteBridge {
190
197
  private turnPrompt = "";
191
198
  private turnText = "";
192
199
 
193
- constructor(private readonly cfg: RemoteBridgeConfig) {}
200
+ // Unsubscribes the privacy watcher below. Held so a bridge that outlives its window
201
+ // (the desktop builds one per window, in one process) stops writing to a transport
202
+ // whose renderer is gone.
203
+ private readonly unwatchPrivacy: () => void;
204
+
205
+ constructor(private readonly cfg: RemoteBridgeConfig) {
206
+ // The privacy switch has a second driver: `/privacy off` typed into the composer
207
+ // (or into the CLI beside a driving app). Watch the flag itself rather than only
208
+ // the frame, so the app's shield follows the terminal however it was moved β€” and
209
+ // on the desktop, where the flag is process-wide, so does every OTHER window's,
210
+ // which is the truth: they share the env var this writes.
211
+ this.unwatchPrivacy = onPrivacyDisabledChange((off) => this.relay?.sendPrivacy?.(off));
212
+ }
213
+
214
+ /** Release what outlives a socket. Safe to call twice. */
215
+ dispose(): void {
216
+ this.unwatchPrivacy();
217
+ this.relay = undefined;
218
+ }
194
219
 
195
220
  // Wire the outbound relay once it's constructed (RelayClient needs `callbacks`
196
221
  // at construction, so the relay is attached right after).
@@ -286,10 +311,29 @@ export class RemoteBridge {
286
311
  setNoQuarter(on);
287
312
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
288
313
  },
314
+ // The app's privacy switch. It writes the SAME state `/privacy off` writes β€” the
315
+ // env var every module copy and subagent child reads β€” so a session toggled from
316
+ // the app and one toggled from the keyboard are the same session, and the CLI's
317
+ // own `/privacy status` tells the truth about what the app did. Echoed back so
318
+ // the app's switch reflects what this terminal is actually doing rather than what
319
+ // it was asked to do.
320
+ onPrivacy: (off) => {
321
+ const changed = privacyDisabled() !== off;
322
+ setPrivacyDisabled(off);
323
+ // A real change is echoed by the watcher in the constructor (which also covers
324
+ // `/privacy` typed at a composer). Ack a no-op ask here so an app whose switch
325
+ // was already out of step still gets an answer rather than silence.
326
+ if (!changed) this.relay?.sendPrivacy?.(off);
327
+ },
289
328
  onControllerAttached: () => {
290
329
  if (this.noQuarter || noQuarterActive()) {
291
330
  this.relay?.sendNoQuarter(true);
292
331
  }
332
+ // Resync the privacy switch the same way, and for a sharper reason: the filter
333
+ // may have been turned off from the keyboard, or by a launch flag, or by an
334
+ // earlier session of this app β€” an attaching controller must not paint a shield
335
+ // over a terminal that has none. Sent only when OFF, so silence means protected.
336
+ if (privacyDisabled()) this.relay?.sendPrivacy?.(true);
293
337
  this.cfg.onControllerAttached?.();
294
338
  },
295
339
  // The app left while we're still running. Same posture as a dropped socket: stop
@@ -319,6 +363,9 @@ export class RemoteBridge {
319
363
  getRemote = (): boolean => this.remote;
320
364
  getNoQuarter = (): boolean => this.noQuarter || noQuarterActive();
321
365
 
366
+ /** True while the privacy filter is disabled for this session (`/privacy off`). */
367
+ getPrivacyDisabled = (): boolean => privacyDisabled();
368
+
322
369
  // The gate's remote approver: relay the request to the app and await its
323
370
  // allow/deny. Fail closed if no controller, on abort, or on disconnect. (The gate
324
371
  // also wraps this in its own timeout, so a silent app can't wedge the turn.)
@@ -1143,6 +1143,9 @@ export const generateSpriteToolDefinition = {
1143
1143
  "right-facing ones rather than rendered, which is why eight animations cost five clips and not " +
1144
1144
  "eight. Each clip is charged at the account's video rate, so an eight-way set is genuinely " +
1145
1145
  "expensive; say the total to the user before batching characters.\n" +
1146
+ "TWO models are involved, which matters when one of them is down: an IMAGE model turns the " +
1147
+ "picture to face each direction (only for `directions` 'four' and 'eight'), then a VIDEO model " +
1148
+ "renders the motion per facing. `image_model` and `model` override them separately.\n" +
1146
1149
  "It takes several minutes (the clips render sequentially) and this tool waits. Frame count, cell " +
1147
1150
  "size and frame rate are chosen here and cost nothing extra. AVAILABILITY: this needs a video " +
1148
1151
  "decoder on the Privateer API and some deployments do not have one β€” call media_capabilities and " +
@@ -1218,13 +1221,25 @@ export const generateSpriteToolDefinition = {
1218
1221
  model: Type.Optional(
1219
1222
  Type.String({ description: "Video model id to render the motion with. Omit for the account default." }),
1220
1223
  ),
1224
+ image_model: Type.Optional(
1225
+ Type.String({
1226
+ description:
1227
+ "Image model id used to TURN the picture to face each direction, e.g. " +
1228
+ "'google/gemini-3.1-flash-image'. Omit for the account default. Only used when " +
1229
+ "`directions` is 'four' or 'eight' β€” 'one' renders no turns and never touches an image " +
1230
+ "model. Worth setting when a run fails with SPRITE_IMAGE_MODEL_UNAVAILABLE: that is the " +
1231
+ "image provider being down, not the request being wrong, and naming another model here " +
1232
+ "retries the whole run for nothing (the turns are drawn before any clip is billed). " +
1233
+ "media_capabilities lists the account's current image model.",
1234
+ }),
1235
+ ),
1221
1236
  }),
1222
1237
  async execute(
1223
1238
  _toolCallId: string,
1224
1239
  params: {
1225
1240
  image: string; prompt: string; dir: string; action?: string; directions?: string;
1226
1241
  frames?: number; frame_size?: number; fps?: number; loop?: boolean;
1227
- name?: string; res_path?: string; model?: string;
1242
+ name?: string; res_path?: string; model?: string; image_model?: string;
1228
1243
  },
1229
1244
  signal?: AbortSignal,
1230
1245
  _onUpdate?: unknown,
@@ -1264,6 +1279,7 @@ export const generateSpriteToolDefinition = {
1264
1279
  return guessed ? { res_path: guessed } : {};
1265
1280
  })()),
1266
1281
  ...(params.model ? { model: params.model } : {}),
1282
+ ...(params.image_model ? { image_model: params.image_model } : {}),
1267
1283
  },
1268
1284
  });
1269
1285
  if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);