Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ How to keep this current: add the entry in the same pull request as the change,

<!-- Empty. Next release starts here. -->

## 0.40.0

### Fixed

- `/warden status` and `/warden enable` now describe the key of the configured `typesafeBackend`. With `"openrouter"`, status said "TypeSafe key: missing" on the same line that counted judgments, and enable opened the TypeSafe login prompt, so consent could not be saved. Both call pi-typesafe's backend-aware `authState` and `ensureApiKey`; with no OpenRouter key, enable reports the variable to set instead of prompting (#57).
- The headless hint and the enable confirmation name the backend's own environment variable.

### Changed

- `src/backend.ts` reads hosts and key variables from pi-typesafe's `DECISIONS_BACKENDS` instead of keeping its own copy; `keyAvailable` is replaced by `authState({ backend }).usable`, which also honours a rejected key. Requires pi-typesafe 0.7.0.

## 0.39.1

### Changed
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-warden",
"version": "0.39.1",
"version": "0.40.0",
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -81,6 +81,6 @@
"typescript": "^6.0.0"
},
"dependencies": {
"pi-typesafe": "^0.6.2"
"pi-typesafe": "^0.7.0"
}
}
37 changes: 12 additions & 25 deletions src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,37 @@
import type { TypeSafeOptions } from "pi-typesafe";
import { authState } from "pi-typesafe";
import { DECISIONS_BACKENDS, DEFAULT_BACKEND } from "pi-typesafe";

/** The judgment backend that receives pi-warden's Jev requests. */
export type JudgmentBackend = "typesafe" | "openrouter";

const BACKENDS: Record<JudgmentBackend, { host: string; keyEnv?: string }> = {
typesafe: { host: "api.typesafe.ai" },
openrouter: { host: "openrouter.ai", keyEnv: "OPENROUTER_API_KEY" },
};

/** The host the consent disclosure names as the destination. */
/** The host the consent disclosure names as the destination, without scheme: `api.typesafe.ai`, `openrouter.ai`. */
export function backendHost(backend: JudgmentBackend): string {
return BACKENDS[backend].host;
return new URL(DECISIONS_BACKENDS[backend].host).host;
}

/** Adapt the consent text to the active backend by substituting the destination host. */
export function disclosureFor(backend: JudgmentBackend, disclosure: string): string {
return backend === "typesafe" ? disclosure : disclosure.replace(BACKENDS.typesafe.host, BACKENDS[backend].host);
/** The environment variable that carries the backend's key, for messages that tell the user what to set. */
export function keyEnvFor(backend: JudgmentBackend): string {
return DECISIONS_BACKENDS[backend].keyEnv ?? DECISIONS_BACKENDS[DEFAULT_BACKEND].keyEnv ?? "TYPESAFE_API_KEY";
}

/**
* Whether the right credential exists for the chosen backend.
* TypeSafe uses the pi-typesafe keystore; other backends use an environment variable.
*/
export function keyAvailable(
backend: JudgmentBackend,
env: Record<string, string | undefined>,
keystoreUsable: () => boolean,
): boolean {
const keyEnv = BACKENDS[backend].keyEnv;
return keyEnv ? (env[keyEnv]?.trim() ?? "") !== "" : keystoreUsable();
/** Adapt the consent text to the active backend by substituting the destination host. */
export function disclosureFor(backend: JudgmentBackend, disclosure: string): string {
return backend === DEFAULT_BACKEND ? disclosure : disclosure.replace(backendHost(DEFAULT_BACKEND), backendHost(backend));
}

/**
* Build the options forwarded to `createTypeSafe`.
* The caller must gate on `keyAvailable` before calling `createTypeSafe`.
* The caller must gate on `authState({ backend }).usable` before calling `createTypeSafe`.
*/
export function judgeOptions(config: { maxRequests: number; timeoutMs: number; typesafeBackend: JudgmentBackend }): TypeSafeOptions {
return {
maxRequests: config.maxRequests,
timeoutMs: config.timeoutMs,
...(config.typesafeBackend === "typesafe" ? {} : { backend: config.typesafeBackend }),
...(config.typesafeBackend === DEFAULT_BACKEND ? {} : { backend: config.typesafeBackend }),
};
}

/** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
export function resolveBackend(raw: unknown): JudgmentBackend {
return raw === "typesafe" || raw === "openrouter" ? raw : "typesafe";
return raw === "typesafe" || raw === "openrouter" ? raw : DEFAULT_BACKEND;
}
15 changes: 8 additions & 7 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const MouseRegion: MouseRegionConstructor | undefined = (tuiModule as Partial<{
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
import type { TypeSafe } from "pi-typesafe";
import { ensureApiKey } from "pi-typesafe/ui";
import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js";
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js";
import { ActionGuard } from "./action-guard.js";
import type { ToolCallRef } from "./action-guard.js";
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
Expand Down Expand Up @@ -367,7 +367,7 @@ export default function wardenExtension(pi: ExtensionAPI): void {
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
const judgeFor = (config: WardenConfig): TypeSafe | undefined => {
if (!consentGiven(config) || budgetExhausted) return undefined;
if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable)) return undefined;
if (!authState({ backend: config.typesafeBackend }).usable) return undefined;
return client ??= createTypeSafe(judgeOptions(config));
};
const noteError = (ctx: ExtensionContext, message: string, code: string | undefined) => {
Expand Down Expand Up @@ -1666,7 +1666,7 @@ export default function wardenExtension(pi: ExtensionAPI): void {
try {
const config = configFor(ctx);
if (action === "status") {
const auth = describeAuth();
const auth = describeAuth(authState({ backend: config.typesafeBackend }));
const source = consentSource(config);
const usage = client?.getUsage();
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(", ");
Expand Down Expand Up @@ -1750,15 +1750,16 @@ export default function wardenExtension(pi: ExtensionAPI): void {
return;
}
if (action === "enable") {
if (!ctx.hasUI) { report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning"); return; }
if (!ctx.hasUI) { report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${keyEnvFor(config.typesafeBackend)} explicitly.`, "warning"); return; }
if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", disclosure)) return;
// One flow: consent, then a key if none is configured yet (hidden input, verified, stored for every pi-typesafe consumer).
const key = await ensureApiKey(ctx);
// One flow: consent, then a key if none is configured yet. TypeSafe prompts, verifies, and stores the key for every
// pi-typesafe consumer; any other backend has no login, so a missing key is reported with the variable to set.
const key = await ensureApiKey(ctx, { backend: config.typesafeBackend });
if (!key) { report("No key entered; pi-warden stays on pattern checks only. Run /warden enable again when you have a key from console.typesafe.ai.", "warning"); return; }
const path = setUserSetting("typesafe", true);
client = undefined;
budgetExhausted = false;
report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : "key from TYPESAFE_API_KEY"}`}. This stays on in new sessions until /warden disable.`);
report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : `key from ${keyEnvFor(config.typesafeBackend)}`}`}. This stays on in new sessions until /warden disable.`);
return;
}
if (action === "disable") {
Expand Down
26 changes: 9 additions & 17 deletions tests/backend.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "../src/backend.js";
import { DECISIONS_BACKENDS } from "pi-typesafe";
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "../src/backend.js";

test("resolveBackend: valid values pass through, junk falls back to typesafe", () => {
assert.equal(resolveBackend("typesafe"), "typesafe");
Expand Down Expand Up @@ -30,22 +31,13 @@ test("disclosureFor: openrouter replaces the host in the disclosure", () => {
assert.equal(result, "pi-warden sends to openrouter.ai: your latest request");
});

test("keyAvailable: typesafe uses the keystore callback", () => {
const env: Record<string, string | undefined> = {};
assert.equal(keyAvailable("typesafe", env, () => true), true);
assert.equal(keyAvailable("typesafe", env, () => false), false);
});

test("keyAvailable: openrouter uses the OPENROUTER_API_KEY env var", () => {
const usable = () => true;
assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: "sk-or-abc" }, usable), true);
assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: " " }, usable), false, "whitespace-only is not a key");
assert.equal(keyAvailable("openrouter", {}, usable), false, "missing env var");
assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: "" }, usable), false, "empty string");
});

test("keyAvailable: openrouter ignores the keystore", () => {
assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: "key" }, () => false), true, "keystore failure is irrelevant");
test("backendHost and keyEnvFor come from pi-typesafe's registry, not a local copy", () => {
for (const backend of ["typesafe", "openrouter"] as const) {
assert.equal(backendHost(backend), new URL(DECISIONS_BACKENDS[backend].host).host);
assert.equal(keyEnvFor(backend), DECISIONS_BACKENDS[backend].keyEnv);
}
assert.equal(keyEnvFor("typesafe"), "TYPESAFE_API_KEY");
assert.equal(keyEnvFor("openrouter"), "OPENROUTER_API_KEY");
});

test("judgeOptions: typesafe backend omits the backend field", () => {
Expand Down
Loading