@skaile/transform-proxy 0.2.5 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - omp extension, **behaviour change**: `/anon on` and `/anon off` switch only the omp process they run in (its subagents and its `/new`, `/resume`, `/fork` sessions included) and no longer save the default. Before, every `/anon on|off` wrote `~/.omp/agent/skaile-transform-proxy.json`, so turning anon off in one window turned it off for every omp started later. `/anon on --global` and `/anon off --global` also save the default. A saved `on` is applied at session start only until the first successful `/anon on|off` in that process, so a plain `/anon off` stays off across `/new`. An existing saved `enabled: true` still turns anon on in new omp processes; run `/anon off --global` to clear it.
6
+
7
+ ## 0.2.6
8
+
9
+ - omp extension: while anon is on, in error or required, images are not sent. The extension sets omp's `images.blockImages` as a runtime override (never persisted), so omp replaces every image in the request, history included, with `Image reading is disabled.`; `/anon off` drops the override. Before, a screenshot from a browser tool made the proxy refuse every later request of the session with 400 `unsupported_content` ("Anthropic image content cannot be transformed"). `/anon status` lists `images: not sent`. An omp without the settings registry gets one warning, and the proxy keeps refusing images.
10
+
3
11
  ## 0.2.5
4
12
 
5
13
  - omp extension: the status is `🟢A` (on), `🔴A` (off), `🟠A` (error) instead of `A`, `Ⱥ`, `A!`. The crossed A was not legible in common terminal fonts, and omp strips ANSI colour from extension statuses, so the colour comes from an emoji.
package/README.md CHANGED
@@ -93,7 +93,7 @@ The package ships an [omp](https://omp.sh) extension (subpath `@skaile/transform
93
93
  omp plugin install @skaile/transform-proxy
94
94
  ```
95
95
 
96
- It adds the command `/anon on|off|status` and a compact status: `🟢A` (on), `🔴A` (off) or `🟠A` (error). The colour is in the emoji because omp strips ANSI colour from extension statuses and draws them in its accent colour. Unless `SKAILE_TRANSFORM_PROXY_URL` names a running proxy, it needs Node ≥ 22 on `PATH` (or its path in `SKAILE_TRANSFORM_PROXY_NODE`).
96
+ It adds the command `/anon on|off [--global] | status` and a compact status: `🟢A` (on), `🔴A` (off) or `🟠A` (error). The colour is in the emoji because omp strips ANSI colour from extension statuses and draws them in its accent colour. Unless `SKAILE_TRANSFORM_PROXY_URL` names a running proxy, it needs Node ≥ 22 on `PATH` (or its path in `SKAILE_TRANSFORM_PROXY_NODE`).
97
97
 
98
98
  omp shows extension statuses on an extra line below the status line. To show the status in the status line itself, add the `status` segment and turn the extra line off in `~/.omp/agent/config.yml` (`showHookStatus: false` moves every extension status, not just this one, into the segment):
99
99
 
@@ -112,10 +112,12 @@ statusLine:
112
112
  - Providers on a wire the proxy does not rewrite (Bedrock, Vertex, Gemini, agent bridges, providers without a base URL) are routed too, so the proxy refuses their requests: the setup fails closed. `/anon status` lists them as refused.
113
113
  - The session's active model is rebound to the re-routed one, so the next request already uses the new base URL. A rebind that fails is an activation failure: `/anon on` rolls the routes back, saves nothing and stays off. When anon is required, or a later turn's rebind fails while anon is on, the status shows `🟠A`.
114
114
  - While anon is on or in error, a `before_provider_request` handler checks every model request: a request whose model does not target the proxy gets its payload replaced with an empty object, so no conversation text leaves, and an error notice names the model. omp has no hook that cancels a provider request, and a throwing handler is only logged, so this replacement is the block.
115
+ - While anon is on or in error, images are not sent: the extension sets omp's `images.blockImages` in omp's in-memory runtime settings layer, and omp replaces every image in the request, history included, with the note `Image reading is disabled.`. The proxy cannot anonymize images and refuses every request that carries one (400 `unsupported_content`), and omp resends the whole history each turn, so a single screenshot would otherwise block the session. `/anon off` drops the override, so your own `images.blockImages` setting applies again; the override is never written to `config.yml`. An omp without this settings API gets a warning instead, and the proxy keeps refusing images.
115
116
  - The proxy session follows the root omp session: on `/new`, `/resume`, `/fork` (`session_switch`) and on a root `session_start` with a new session id, every provider is re-routed with the new `x-skaile-transform-session`. The proxy keeps the old session's state in memory until it exits; no request carries that id again. A `session_start` of a headless `print` session (how omp runs subagents) keeps the parent's id, so subagents share its pseudonyms.
116
117
  - `/anon off` restores the original base URLs and headers.
117
- - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, and the routed and refused providers.
118
- - The enabled flag is saved in `skaile-transform-proxy.json` in omp's agent dir (`~/.omp/agent`; `SKAILE_TRANSFORM_PROXY_OMP_STATE` overrides the path) and re-applied when a session starts.
118
+ - `/anon status` shows the state, the proxy URL and its source, the pipeline, the session id, whether images are blocked, and the routed and refused providers.
119
+ - `/anon on` and `/anon off` switch only the omp process they run in: that window, its subagents, and the sessions it opens with `/new`, `/resume` and `/fork`. Other omp windows are not touched, and the saved default does not change.
120
+ - `/anon on --global` and `/anon off --global` also save the default for omp processes started later, in `skaile-transform-proxy.json` in omp's agent dir (`~/.omp/agent`; `SKAILE_TRANSFORM_PROXY_OMP_STATE` overrides the path). A session start applies a saved `on` until the first successful `/anon on|off` in that process; from then on the process follows its own choice. A failed `/anon on --global` saves nothing.
119
121
  - `SKAILE_TRANSFORM_PROXY_MODE=required` (set by a skaile session in required mode) turns routing on at session start and refuses `/anon off`.
120
122
 
121
123
  The overrides are registered under the model-registry source `skaile-transform-proxy`, so `/anon off` clears them in one catalog reload. A provider that another extension registered at runtime is taken over while anon is on; `/anon off` restores its base URL and headers, and a provider that existed only at runtime is reported as lost (restart omp to restore it).
@@ -59,6 +59,10 @@ interface OmpSessionActions {
59
59
  setModel(model: OmpModel): Promise<boolean>;
60
60
  }
61
61
  interface OmpExtensionApi extends OmpSessionActions {
62
+ /** omp's package exports; `settings` is the scope its setting handles read and override. */
63
+ readonly pi?: {
64
+ readonly settings?: unknown;
65
+ };
62
66
  registerCommand(name: string, options: {
63
67
  description?: string;
64
68
  getArgumentCompletions?: (argumentPrefix: string) => OmpAutocompleteItem[] | null;
@@ -84,6 +88,14 @@ interface ProxyChildProcess {
84
88
  on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
85
89
  on(event: 'error', listener: (err: Error) => void): unknown;
86
90
  }
91
+ /**
92
+ * Image sending of the host. The proxy cannot anonymize images and refuses every request that
93
+ * carries one; omp resends the whole history each turn, so one screenshot would block the session.
94
+ */
95
+ interface ImageSending {
96
+ /** `true` stops images from reaching the provider, `false` restores the user's setting. Resolves `false` when the host offers no way to do it; that answer is final. */
97
+ setBlocked(blocked: boolean): Promise<boolean>;
98
+ }
87
99
  interface AnonControllerDeps {
88
100
  readonly env: Readonly<Record<string, string | undefined>>;
89
101
  readonly home: string;
@@ -95,6 +107,8 @@ interface AnonControllerDeps {
95
107
  readonly cliPath?: string;
96
108
  /** How long the child may take to print `listening <url>`. Default: 60 s (transform modules may load ML models). */
97
109
  readonly listenTimeoutMs?: number;
110
+ /** Blocked while anon is wanted (on, error or required), released by `/anon off`. Default: none. */
111
+ readonly images?: ImageSending;
98
112
  }
99
113
  interface AnonController {
100
114
  readonly required: boolean;
@@ -103,7 +117,8 @@ interface AnonController {
103
117
  command(args: string, ctx: OmpContext, session: OmpSessionActions): Promise<void>;
104
118
  completions(prefix: string): OmpAutocompleteItem[] | null;
105
119
  /**
106
- * Applies the persisted flag (or required mode) and refreshes the status bar. A root session
120
+ * Applies the saved default (unless `/anon on|off` already decided in this process) or required
121
+ * mode, and refreshes the status bar. A root session
107
122
  * whose omp session id changed rotates the proxy session; subagents keep the parent's id.
108
123
  */
109
124
  sessionStart(ctx: OmpContext, session: OmpSessionActions): Promise<void>;
@@ -133,4 +148,6 @@ export declare function defaultStatePath(env: Readonly<Record<string, string | u
133
148
  /** Registers the `/anon` command and the event handlers on one session's extension API. */
134
149
  export declare function registerAnonExtension(pi: OmpExtensionApi, controller: AnonController): void;
135
150
  export default function anonExtension(pi: OmpExtensionApi): void;
136
- export type { AnonController, AnonControllerDeps, OmpAutocompleteItem, OmpContext, OmpEventName, OmpExtensionApi, OmpModel, OmpModelRegistry, OmpNotifyType, OmpProviderOverride, OmpSessionActions, OmpUi, ProxyChildProcess, ProxySpawnOptions };
151
+ /** Blocks images through a runtime override of omp's `images.blockImages`, which strips them from every request, history included. */
152
+ export declare function ompImageSending(pi: OmpExtensionApi, load?: () => Promise<unknown>): ImageSending;
153
+ export type { AnonController, AnonControllerDeps, ImageSending, OmpAutocompleteItem, OmpContext, OmpEventName, OmpExtensionApi, OmpModel, OmpModelRegistry, OmpNotifyType, OmpProviderOverride, OmpSessionActions, OmpUi, ProxyChildProcess, ProxySpawnOptions };
@@ -14,7 +14,7 @@ const TOKEN_ENV = "SKAILE_TRANSFORM_PROXY_TOKEN";
14
14
  const NODE_ENV = "SKAILE_TRANSFORM_PROXY_NODE";
15
15
  /** `required` (set by a skaile session in required mode): routing is forced on and `/anon off` is refused. */
16
16
  const MODE_ENV = "SKAILE_TRANSFORM_PROXY_MODE";
17
- /** Overrides the path of the persisted enabled flag. */
17
+ /** Overrides the path of the saved default (`/anon on|off --global`). */
18
18
  const STATE_ENV = "SKAILE_TRANSFORM_PROXY_OMP_STATE";
19
19
  /** The footer status key. */
20
20
  const STATUS_KEY = "anon";
@@ -26,17 +26,29 @@ const SERVED_APIS = {
26
26
  "openai-completions": true,
27
27
  "openai-responses": true
28
28
  };
29
- const USAGE = "usage: /anon on|off|status";
29
+ /** `/anon on --global` / `/anon off --global` also save the default that new omp sessions start with. */
30
+ const GLOBAL_FLAG = "--global";
31
+ const USAGE = "usage: /anon on|off [--global] | status";
30
32
  const VERBS = [
31
33
  {
32
34
  value: "on",
33
35
  label: "on",
34
- description: "route every provider through the transform proxy"
36
+ description: "route this session through the transform proxy"
37
+ },
38
+ {
39
+ value: "on --global",
40
+ label: "on --global",
41
+ description: "on, and the default for new sessions"
35
42
  },
36
43
  {
37
44
  value: "off",
38
45
  label: "off",
39
- description: "restore the original provider base URLs"
46
+ description: "restore this session's original provider base URLs"
47
+ },
48
+ {
49
+ value: "off --global",
50
+ label: "off --global",
51
+ description: "off, and the default for new sessions"
40
52
  },
41
53
  {
42
54
  value: "status",
@@ -44,6 +56,23 @@ const VERBS = [
44
56
  description: "show the proxy, pipeline and routed providers"
45
57
  }
46
58
  ];
59
+ /** A parsed `/anon` argument; `undefined` for anything else. */
60
+ function parseCommand(args) {
61
+ const [verb = "", ...flags] = args.trim().toLowerCase().split(/\s+/);
62
+ if (verb === "" || verb === "status") return flags.length === 0 ? {
63
+ verb: "status",
64
+ global: false
65
+ } : void 0;
66
+ if (verb !== "on" && verb !== "off") return void 0;
67
+ if (flags.length === 0) return {
68
+ verb,
69
+ global: false
70
+ };
71
+ return flags.length === 1 && flags[0] === GLOBAL_FLAG ? {
72
+ verb,
73
+ global: true
74
+ } : void 0;
75
+ }
47
76
  /**
48
77
  * The process-wide `/anon` state machine. omp builds one extension instance per session
49
78
  * (subagents included) while they share one model registry, so one controller serves them all.
@@ -62,6 +91,12 @@ function createAnonController(deps) {
62
91
  const registries = /* @__PURE__ */ new WeakMap();
63
92
  let wanted = required;
64
93
  let enabled = false;
94
+ /** The last successful `/anon on|off` in this omp process; once set, the saved default no longer applies here. */
95
+ let local;
96
+ /** Whether this controller holds the image block; only then does it release it. */
97
+ let imagesBlocked = false;
98
+ /** The host answered that it cannot block images; asked once, warned once. */
99
+ let imagesUnsupported = false;
65
100
  let endpoint;
66
101
  let starting;
67
102
  let sessionId;
@@ -265,6 +300,24 @@ function createAnonController(deps) {
265
300
  function stateLabel() {
266
301
  return enabled ? "on" : wanted ? "error" : "off";
267
302
  }
303
+ /** Follows `wanted`: images are blocked while model requests must go through the proxy. */
304
+ async function syncImages(ctx) {
305
+ const images = deps.images;
306
+ if (images === void 0 || imagesUnsupported || imagesBlocked === wanted) return;
307
+ let done;
308
+ try {
309
+ done = await images.setBlocked(wanted);
310
+ } catch (err) {
311
+ ctx.ui.notify(`anon: could not ${wanted ? "stop" : "restore"} image sending: ${errorMessage(err)}`, "warning");
312
+ return;
313
+ }
314
+ if (done) {
315
+ imagesBlocked = wanted;
316
+ return;
317
+ }
318
+ imagesUnsupported = true;
319
+ if (wanted) ctx.ui.notify("anon: this omp cannot stop image sending; the proxy refuses every request that carries an image", "warning");
320
+ }
268
321
  /**
269
322
  * Compact status for omp's main status line. omp strips ANSI colour from extension statuses and
270
323
  * paints them in its accent colour, so the colour comes from an emoji: green on, red off, orange error.
@@ -282,6 +335,7 @@ function createAnonController(deps) {
282
335
  lines.push(`pipeline: ${endpoint.pipeline ?? "the proxy default"}`);
283
336
  }
284
337
  if (sessionId !== void 0) lines.push(`session: ${sessionId}`);
338
+ if (imagesBlocked) lines.push("images: not sent (omp images.blockImages)");
285
339
  const routed = [...state.routed.keys()].sort();
286
340
  const refused = routed.flatMap((provider) => {
287
341
  const reason = refusalReason(apis.get(provider), state.originals.get(provider));
@@ -300,7 +354,7 @@ function createAnonController(deps) {
300
354
  */
301
355
  async function start(ctx, session, root) {
302
356
  enterSession(ctx, root);
303
- if (!wanted && readEnabled(statePath)) wanted = true;
357
+ if (!wanted && local === void 0 && readEnabled(statePath)) wanted = true;
304
358
  if (wanted) {
305
359
  const failure = await activate(ctx, session);
306
360
  if (failure !== void 0) {
@@ -308,21 +362,25 @@ function createAnonController(deps) {
308
362
  ctx.ui.notify(refusalHint(), "error");
309
363
  }
310
364
  }
365
+ await syncImages(ctx);
311
366
  showStatus(ctx);
312
367
  }
313
368
  return {
314
369
  required,
315
370
  statePath,
316
371
  async command(args, ctx, session) {
317
- switch (args.trim().toLowerCase()) {
372
+ const parsed = parseCommand(args);
373
+ const scope = parsed?.global === true ? `this session; default for new sessions: ${parsed.verb}` : "this session";
374
+ switch (parsed?.verb) {
318
375
  case "on": {
319
376
  const before = wanted;
320
377
  wanted = true;
321
378
  const failure = await activate(ctx, session);
322
379
  if (failure === void 0) {
323
- persist(ctx, true);
380
+ local = true;
381
+ if (parsed.global) persist(ctx, true);
324
382
  const count = stateOf(ctx.modelRegistry).routed.size;
325
- ctx.ui.notify(`anon on: ${count} provider(s) routed through ${endpoint?.url}`, "info");
383
+ ctx.ui.notify(`anon on (${scope}): ${count} provider(s) routed through ${endpoint?.url}`, "info");
326
384
  break;
327
385
  }
328
386
  wanted = before || required;
@@ -338,18 +396,19 @@ function createAnonController(deps) {
338
396
  const lost = unroute(ctx);
339
397
  wanted = false;
340
398
  enabled = false;
399
+ local = false;
341
400
  if (!await rebind(ctx, session)) ctx.ui.notify(`anon: could not rebind the active model ${ctx.model?.provider}/${ctx.model?.id}; select it again`, "warning");
342
- persist(ctx, false);
343
- ctx.ui.notify("anon off: original provider base URLs restored", "info");
401
+ if (parsed.global) persist(ctx, false);
402
+ ctx.ui.notify(`anon off (${scope}): original provider base URLs restored`, "info");
344
403
  if (lost.length > 0) ctx.ui.notify(`anon: ${lost.join(", ")} lost their runtime registration (registered by another extension); restart omp to restore them`, "warning");
345
404
  break;
346
405
  }
347
- case "":
348
406
  case "status":
349
407
  ctx.ui.notify(statusText(ctx), "info");
350
408
  break;
351
409
  default: ctx.ui.notify(USAGE, "error");
352
410
  }
411
+ await syncImages(ctx);
353
412
  showStatus(ctx);
354
413
  },
355
414
  completions(prefix) {
@@ -545,6 +604,7 @@ function originOf(url) {
545
604
  // src/omp/index.ts
546
605
  /** omp re-imports the extension per session; the controller (proxy child, recorded originals) is process-wide. */
547
606
  const CONTROLLER_KEY = Symbol.for("@skaile/transform-proxy/omp");
607
+ const BLOCK_IMAGES = "images.blockImages";
548
608
  /** Registers the `/anon` command and the event handlers on one session's extension API. */
549
609
  function registerAnonExtension(pi, controller) {
550
610
  pi.registerCommand("anon", {
@@ -562,9 +622,35 @@ function anonExtension(pi) {
562
622
  const scope = globalThis;
563
623
  registerAnonExtension(pi, scope[CONTROLLER_KEY] ??= createAnonController({
564
624
  env: process.env,
565
- home: homedir()
625
+ home: homedir(),
626
+ images: ompImageSending(pi)
566
627
  }));
567
628
  }
629
+ /** Blocks images through a runtime override of omp's `images.blockImages`, which strips them from every request, history included. */
630
+ function ompImageSending(pi, load = loadOmpSettingsRegistry) {
631
+ let handle;
632
+ return { async setBlocked(blocked) {
633
+ const settings = pi.pi?.settings;
634
+ if (settings === void 0) return false;
635
+ handle ??= load().then(blockImagesHandle, () => void 0);
636
+ const resolved = await handle;
637
+ if (resolved === void 0) return false;
638
+ if (blocked) resolved.override(settings, true);
639
+ else resolved.clearOverride(settings);
640
+ return true;
641
+ } };
642
+ }
643
+ function loadOmpSettingsRegistry() {
644
+ return import("@oh-my-pi/pi-coding-agent/config/registry");
645
+ }
646
+ function blockImagesHandle(registry) {
647
+ if (typeof registry !== "object" || registry === null || !("lookup" in registry) || typeof registry.lookup !== "function") return void 0;
648
+ const handle = registry.lookup(BLOCK_IMAGES);
649
+ if (typeof handle !== "object" || handle === null) return void 0;
650
+ if (!("override" in handle) || typeof handle.override !== "function") return void 0;
651
+ if (!("clearOverride" in handle) || typeof handle.clearOverride !== "function") return void 0;
652
+ return handle;
653
+ }
568
654
  function on(pi, event, run) {
569
655
  pi.on(event, (_event, ctx) => guarded(ctx, () => run(ctx)));
570
656
  }
@@ -575,4 +661,4 @@ async function guarded(ctx, run) {
575
661
  ctx.ui.notify(`anon: ${errorMessage(err)}`, "error");
576
662
  }
577
663
  }
578
- export { createAnonController, anonExtension as default, defaultStatePath, registerAnonExtension };
664
+ export { createAnonController, anonExtension as default, defaultStatePath, ompImageSending, registerAnonExtension };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/transform-proxy",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Same-protocol passthrough proxy that runs model requests and replies through a chain of text transforms (Anthropic Messages, OpenAI chat, OpenAI responses)",
6
6
  "license": "MIT",