pi-warden 0.25.0 → 0.26.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
@@ -6,6 +6,23 @@ How to keep this current: add the entry in the same pull request as the change,
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ <!-- Empty. Next release starts here. -->
10
+
11
+ ## 0.26.0
12
+
13
+ ### Added
14
+
15
+ - OpenRouter as a configurable judgment backend (`typesafeBackend` in user config). Set to `"openrouter"` to route Jev decisions through OpenRouter's decisions API instead of api.typesafe.ai. New `src/backend.ts` module; `OPENROUTER_API_KEY` env var; project overrides cannot redirect judgments.
16
+
17
+ ### Changed
18
+
19
+ - Bumped pi-typesafe to ^0.6.0 (native `backend` option on `TypeSafeOptions`).
20
+ - Deduplicated `resolveBackend` call in `shape.ts`.
21
+
22
+ ### Fixed
23
+
24
+ - Host-TUI capability guard: the extension no longer crashes when the host's bundled `pi-tui` lacks the `MouseRegion` component (e.g. omp 18.2.5). A missing named import was a link-time error that silently killed every guard, the `/warden` command, and the shortcut. The import is now a namespace property read (`tuiModule.MouseRegion`), which degrades to `undefined` instead of failing. The status widget renders the same guard text without the clickable wrapper.
25
+
9
26
  ## 0.25.0
10
27
 
11
28
  ### Added
@@ -0,0 +1,23 @@
1
+ import type { TypeSafeOptions } from "pi-typesafe";
2
+ /** The judgment backend that receives pi-warden's Jev requests. */
3
+ export type JudgmentBackend = "typesafe" | "openrouter";
4
+ /** The host the consent disclosure names as the destination. */
5
+ export declare function backendHost(backend: JudgmentBackend): string;
6
+ /** Adapt the consent text to the active backend by substituting the destination host. */
7
+ export declare function disclosureFor(backend: JudgmentBackend, disclosure: string): string;
8
+ /**
9
+ * Whether the right credential exists for the chosen backend.
10
+ * TypeSafe uses the pi-typesafe keystore; other backends use an environment variable.
11
+ */
12
+ export declare function keyAvailable(backend: JudgmentBackend, env: Record<string, string | undefined>, keystoreUsable: () => boolean): boolean;
13
+ /**
14
+ * Build the options forwarded to `createTypeSafe`.
15
+ * The caller must gate on `keyAvailable` before calling `createTypeSafe`.
16
+ */
17
+ export declare function judgeOptions(config: {
18
+ maxRequests: number;
19
+ timeoutMs: number;
20
+ typesafeBackend: JudgmentBackend;
21
+ }): TypeSafeOptions;
22
+ /** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
23
+ export declare function resolveBackend(raw: unknown): JudgmentBackend;
@@ -0,0 +1,36 @@
1
+ import { authState } from "pi-typesafe";
2
+ const BACKENDS = {
3
+ typesafe: { host: "api.typesafe.ai" },
4
+ openrouter: { host: "openrouter.ai", keyEnv: "OPENROUTER_API_KEY" },
5
+ };
6
+ /** The host the consent disclosure names as the destination. */
7
+ export function backendHost(backend) {
8
+ return BACKENDS[backend].host;
9
+ }
10
+ /** Adapt the consent text to the active backend by substituting the destination host. */
11
+ export function disclosureFor(backend, disclosure) {
12
+ return backend === "typesafe" ? disclosure : disclosure.replace(BACKENDS.typesafe.host, BACKENDS[backend].host);
13
+ }
14
+ /**
15
+ * Whether the right credential exists for the chosen backend.
16
+ * TypeSafe uses the pi-typesafe keystore; other backends use an environment variable.
17
+ */
18
+ export function keyAvailable(backend, env, keystoreUsable) {
19
+ const keyEnv = BACKENDS[backend].keyEnv;
20
+ return keyEnv ? (env[keyEnv]?.trim() ?? "") !== "" : keystoreUsable();
21
+ }
22
+ /**
23
+ * Build the options forwarded to `createTypeSafe`.
24
+ * The caller must gate on `keyAvailable` before calling `createTypeSafe`.
25
+ */
26
+ export function judgeOptions(config) {
27
+ return {
28
+ maxRequests: config.maxRequests,
29
+ timeoutMs: config.timeoutMs,
30
+ ...(config.typesafeBackend === "typesafe" ? {} : { backend: config.typesafeBackend }),
31
+ };
32
+ }
33
+ /** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
34
+ export function resolveBackend(raw) {
35
+ return raw === "typesafe" || raw === "openrouter" ? raw : "typesafe";
36
+ }
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { JudgmentBackend } from "./backend.js";
1
2
  import type { WidgetConfig } from "./widget.js";
2
3
  export interface Threshold {
3
4
  /** P(yes) at or above this shows a warning and continues. */
@@ -217,6 +218,8 @@ export interface WardenConfig {
217
218
  enabled: boolean;
218
219
  /** Consent to send task and action summaries to api.typesafe.ai. Set by /warden enable; never by a project file. */
219
220
  typesafe: boolean;
221
+ /** The decisions service the judgments go to. User file only: a project must not redirect judgments to another vendor. */
222
+ typesafeBackend: JudgmentBackend;
220
223
  /**
221
224
  * steer (default): a confirm-level call is held and the agent receives the judgment as its tool result, so it re-plans or asks
222
225
  * the user in chat. confirm: open a dialog and let the user decide (falls back to steer without a UI). advise: never hold; report only.
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { resolveBackend } from "./backend.js";
4
5
  import { COMMAND_TOOLS } from "./tools.js";
5
6
  import { defaultWidgetConfig } from "./widget.js";
6
7
  const RECALL_TOOLS = ["auto", "rg", "ag", "ugrep", "git-grep", "grep", "select-string", "findstr", "none"];
@@ -15,6 +16,7 @@ export function defaultConfig() {
15
16
  return {
16
17
  enabled: true,
17
18
  typesafe: false,
19
+ typesafeBackend: "typesafe",
18
20
  mode: "steer",
19
21
  timeoutMs: 5000,
20
22
  maxRequests: 500,
@@ -404,6 +406,7 @@ export function applyUserOverrides(base, raw) {
404
406
  return {
405
407
  enabled: boolean(raw.enabled, base.enabled),
406
408
  typesafe: boolean(raw.typesafe, base.typesafe),
409
+ typesafeBackend: resolveBackend(raw.typesafeBackend),
407
410
  mode: isMode(raw.mode) ? raw.mode : base.mode,
408
411
  ...shared,
409
412
  ...applyGuards(base, raw, shared.timeoutMs, "user"),
package/dist/extension.js CHANGED
@@ -1,6 +1,8 @@
1
- import { MouseRegion } from "@earendil-works/pi-tui";
1
+ import * as tuiModule from "@earendil-works/pi-tui";
2
+ const MouseRegion = tuiModule.MouseRegion;
2
3
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
3
4
  import { ensureApiKey } from "pi-typesafe/ui";
5
+ import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js";
4
6
  import { ActionGuard } from "./action-guard.js";
5
7
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
6
8
  import * as configModule from "./config.js";
@@ -290,11 +292,13 @@ export default function wardenExtension(pi) {
290
292
  };
291
293
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
292
294
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
293
- /** A consent flag is not proof that judgments happen; ask pi-typesafe for the real key state. */
295
+ /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
294
296
  const judgeFor = (config) => {
295
- if (!consentGiven(config) || budgetExhausted || !authState().usable)
297
+ if (!consentGiven(config) || budgetExhausted)
296
298
  return undefined;
297
- return client ??= createTypeSafe({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs });
299
+ if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable))
300
+ return undefined;
301
+ return client ??= createTypeSafe(judgeOptions(config));
298
302
  };
299
303
  const noteError = (ctx, message, code) => {
300
304
  stats.errors++;
@@ -326,12 +330,18 @@ export default function wardenExtension(pi) {
326
330
  }
327
331
  const entries = [...widget].map(([guard, line]) => ({ guard, line }));
328
332
  // A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
329
- ctx.ui.setWidget(WIDGET, (_tui, theme) => new MouseRegion(statusWidget(entries, theme), event => {
330
- if (event.type !== "click" || event.button !== "left")
331
- return undefined;
332
- togglePanel(lastUi, config);
333
- return { handled: true };
334
- }), { placement: config.widget.placement });
333
+ // When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
334
+ // renders the same text, but a click does nothing. A missing named import is a link-time error
335
+ // that kills the entire extension silently; a missing namespace property is undefined.
336
+ ctx.ui.setWidget(WIDGET, (_tui, theme) => {
337
+ const body = statusWidget(entries, theme);
338
+ return MouseRegion ? new MouseRegion(body, event => {
339
+ if (event.type !== "click" || event.button !== "left")
340
+ return undefined;
341
+ togglePanel(lastUi, config);
342
+ return { handled: true };
343
+ }) : body;
344
+ }, { placement: config.widget.placement });
335
345
  };
336
346
  const record = (ctx, config, guard, line, details) => {
337
347
  widget.set(guard, line);
@@ -1138,7 +1148,7 @@ export default function wardenExtension(pi) {
1138
1148
  const usage = client?.getUsage();
1139
1149
  const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
1140
1150
  report([
1141
- `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}; ${auth.text}`,
1151
+ `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
1142
1152
  `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
1143
1153
  formatSteers(stats),
1144
1154
  `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
@@ -1225,7 +1235,7 @@ export default function wardenExtension(pi) {
1225
1235
  }
1226
1236
  if (action === "test") {
1227
1237
  const judge = judgeFor(config);
1228
- if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to api.typesafe.ai and may incur charges. ${disclosure}`))
1238
+ if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to ${backendHost(config.typesafeBackend)} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
1229
1239
  return;
1230
1240
  const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
1231
1241
  record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }));