chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
main
Peter Steinberger 2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@ -74,9 +74,9 @@ jobs:
- runtime: node
task: protocol
command: pnpm protocol:check
- runtime: bun
task: lint
command: bunx biome check src
- runtime: node
task: format
command: pnpm format
- runtime: bun
task: test
command: bunx vitest run

5
.oxfmtrc.jsonc Normal file
View File

@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"indentWidth": 2,
"printWidth": 100
}

4
.oxlintrc.jsonc Normal file
View File

@ -0,0 +1,4 @@
{
"$schema": "https://json.schemastore.org/oxlintrc",
"extends": ["recommended"]
}

View File

@ -25,12 +25,12 @@
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.

View File

@ -1,17 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/biome.json",
"formatter": {
"enabled": true,
"indentWidth": 2,
"indentStyle": "space"
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}

View File

@ -87,14 +87,14 @@
"mac:restart": "bash scripts/restart-mac.sh",
"mac:package": "bash scripts/package-mac-app.sh",
"mac:open": "open dist/Clawdbot.app",
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"lint": "oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:all": "pnpm lint && pnpm lint:swift",
"lint:fix": "biome check --write --unsafe src && biome format --write src",
"format": "biome format src",
"lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"format": "oxfmt --check src test",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:all": "pnpm format && pnpm format:swift",
"format:fix": "biome format src --write",
"format:fix": "oxfmt --write src test",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "pnpm --dir ui test",
@ -176,7 +176,6 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@grammyjs/types": "^3.23.0",
"@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6",
@ -194,7 +193,8 @@
"lit": "^3.3.2",
"lucide": "^0.562.0",
"ollama": "^0.6.3",
"oxlint": "^1.38.0",
"oxfmt": "0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.0",
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59",

View File

@ -149,9 +149,6 @@ importers:
specifier: ^4.3.5
version: 4.3.5
devDependencies:
'@biomejs/biome':
specifier: ^2.3.11
version: 2.3.11
'@grammyjs/types':
specifier: ^3.23.0
version: 3.23.0
@ -203,8 +200,11 @@ importers:
ollama:
specifier: ^0.6.3
version: 0.6.3
oxfmt:
specifier: 0.24.0
version: 0.24.0
oxlint:
specifier: ^1.38.0
specifier: ^1.39.0
version: 1.39.0(oxlint-tsgolint@0.11.0)
oxlint-tsgolint:
specifier: ^0.11.0
@ -452,59 +452,6 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@biomejs/biome@2.3.11':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.3.11':
resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.3.11':
resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.3.11':
resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.3.11':
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.3.11':
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.3.11':
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.3.11':
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.3.11':
resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
@ -1245,6 +1192,46 @@ packages:
'@oxc-project/types@0.107.0':
resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==}
'@oxfmt/darwin-arm64@0.24.0':
resolution: {integrity: sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==}
cpu: [arm64]
os: [darwin]
'@oxfmt/darwin-x64@0.24.0':
resolution: {integrity: sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==}
cpu: [x64]
os: [darwin]
'@oxfmt/linux-arm64-gnu@0.24.0':
resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-arm64-musl@0.24.0':
resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-x64-gnu@0.24.0':
resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
cpu: [x64]
os: [linux]
'@oxfmt/linux-x64-musl@0.24.0':
resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
cpu: [x64]
os: [linux]
'@oxfmt/win32-arm64@0.24.0':
resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
cpu: [arm64]
os: [win32]
'@oxfmt/win32-x64@0.24.0':
resolution: {integrity: sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.11.0':
resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==}
cpu: [arm64]
@ -3324,6 +3311,11 @@ packages:
resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==}
engines: {node: '>=20'}
oxfmt@0.24.0:
resolution: {integrity: sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.11.0:
resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==}
hasBin: true
@ -3858,6 +3850,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@2.0.0:
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@3.0.3:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
@ -4641,41 +4637,6 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@biomejs/biome@2.3.11':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.11
'@biomejs/cli-darwin-x64': 2.3.11
'@biomejs/cli-linux-arm64': 2.3.11
'@biomejs/cli-linux-arm64-musl': 2.3.11
'@biomejs/cli-linux-x64': 2.3.11
'@biomejs/cli-linux-x64-musl': 2.3.11
'@biomejs/cli-win32-arm64': 2.3.11
'@biomejs/cli-win32-x64': 2.3.11
'@biomejs/cli-darwin-arm64@2.3.11':
optional: true
'@biomejs/cli-darwin-x64@2.3.11':
optional: true
'@biomejs/cli-linux-arm64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-arm64@2.3.11':
optional: true
'@biomejs/cli-linux-x64-musl@2.3.11':
optional: true
'@biomejs/cli-linux-x64@2.3.11':
optional: true
'@biomejs/cli-win32-arm64@2.3.11':
optional: true
'@biomejs/cli-win32-x64@2.3.11':
optional: true
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)':
@ -5420,6 +5381,30 @@ snapshots:
'@oxc-project/types@0.107.0': {}
'@oxfmt/darwin-arm64@0.24.0':
optional: true
'@oxfmt/darwin-x64@0.24.0':
optional: true
'@oxfmt/linux-arm64-gnu@0.24.0':
optional: true
'@oxfmt/linux-arm64-musl@0.24.0':
optional: true
'@oxfmt/linux-x64-gnu@0.24.0':
optional: true
'@oxfmt/linux-x64-musl@0.24.0':
optional: true
'@oxfmt/win32-arm64@0.24.0':
optional: true
'@oxfmt/win32-x64@0.24.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.11.0':
optional: true
@ -7653,6 +7638,19 @@ snapshots:
osc-progress@0.2.0: {}
oxfmt@0.24.0:
dependencies:
tinypool: 2.0.0
optionalDependencies:
'@oxfmt/darwin-arm64': 0.24.0
'@oxfmt/darwin-x64': 0.24.0
'@oxfmt/linux-arm64-gnu': 0.24.0
'@oxfmt/linux-arm64-musl': 0.24.0
'@oxfmt/linux-x64-gnu': 0.24.0
'@oxfmt/linux-x64-musl': 0.24.0
'@oxfmt/win32-arm64': 0.24.0
'@oxfmt/win32-x64': 0.24.0
oxlint-tsgolint@0.11.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.11.0
@ -8291,6 +8289,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@2.0.0: {}
tinyrainbow@3.0.3: {}
to-regex-range@5.0.1:

View File

@ -6,15 +6,9 @@ import { resolveUserPath } from "../utils.js";
export function resolveClawdbotAgentDir(): string {
const override =
process.env.CLAWDBOT_AGENT_DIR?.trim() ||
process.env.PI_CODING_AGENT_DIR?.trim();
process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
if (override) return resolveUserPath(override);
const defaultAgentDir = path.join(
resolveStateDir(),
"agents",
DEFAULT_AGENT_ID,
"agent",
);
const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
return resolveUserPath(defaultAgentDir);
}

View File

@ -72,12 +72,8 @@ describe("resolveAgentConfig", () => {
},
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
"anthropic/claude-opus-4",
);
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
"openai/gpt-5.2",
]);
expect(resolveAgentModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]);
// If fallbacks isn't present, we don't override the global fallbacks.
const cfgNoOverride: ClawdbotConfig = {
@ -92,9 +88,7 @@ describe("resolveAgentConfig", () => {
],
},
};
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
undefined,
);
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(undefined);
// Explicit empty list disables global fallbacks for that agent.
const cfgDisable: ClawdbotConfig = {

View File

@ -13,9 +13,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
type ResolvedAgentConfig = {
name?: string;
@ -36,9 +34,7 @@ let defaultAgentWarned = false;
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
@ -47,24 +43,20 @@ export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
console.warn(
"Multiple agents marked default=true; using the first entry as default.",
);
console.warn("Multiple agents marked default=true; using the first entry as default.");
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
export function resolveSessionAgentIds(params: {
sessionKey?: string;
config?: ClawdbotConfig;
}): { defaultAgentId: string; sessionAgentId: string } {
export function resolveSessionAgentIds(params: { sessionKey?: string; config?: ClawdbotConfig }): {
defaultAgentId: string;
sessionAgentId: string;
} {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim();
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const sessionAgentId = parsed?.agentId
? normalizeAgentId(parsed.agentId)
: defaultAgentId;
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
return { defaultAgentId, sessionAgentId };
}
@ -75,10 +67,7 @@ export function resolveSessionAgentId(params: {
return resolveSessionAgentIds(params).sessionAgentId;
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
@ -92,31 +81,23 @@ export function resolveAgentConfig(
if (!entry) return undefined;
return {
name: typeof entry.name === "string" ? entry.name : undefined,
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model:
typeof entry.model === "string" ||
(entry.model && typeof entry.model === "object")
typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
? entry.model
: undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
identity: entry.identity,
groupChat: entry.groupChat,
subagents:
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
: undefined,
subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
sandbox: entry.sandbox,
tools: entry.tools,
};
}
export function resolveAgentModelPrimary(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
export function resolveAgentModelPrimary(cfg: ClawdbotConfig, agentId: string): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || undefined;

View File

@ -4,10 +4,7 @@ import os from "node:os";
import path from "node:path";
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import {
ANTHROPIC_SETUP_TOKEN_PREFIX,
@ -26,15 +23,11 @@ import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE =
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
const SETUP_TOKEN_PROFILE =
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const SETUP_TOKEN_MODEL =
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const SETUP_TOKEN_MODEL = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
const ENABLED =
LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
const ENABLED = LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
const describeLive = ENABLED ? describe : describe.skip;
type TokenSource = {
@ -60,11 +53,7 @@ function listSetupTokenProfiles(store: {
}
function pickSetupTokenProfile(candidates: string[]): string {
const preferred = [
"anthropic:setup-token-test",
"anthropic:setup-token",
"anthropic:default",
];
const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
for (const id of preferred) {
if (candidates.includes(id)) return id;
}
@ -73,17 +62,14 @@ function pickSetupTokenProfile(candidates: string[]): string {
async function resolveTokenSource(): Promise<TokenSource> {
const explicitToken =
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") ||
SETUP_TOKEN_VALUE;
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
if (explicitToken) {
const error = validateAnthropicSetupToken(explicitToken);
if (error) {
throw new Error(`Invalid setup-token: ${error}`);
}
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-setup-token-"),
);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-setup-token-"));
const profileId = `anthropic:setup-token-live-${randomUUID()}`;
const store = ensureAuthProfileStore(tempDir, {
allowKeychainPrompt: false,
@ -111,8 +97,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
const candidates = listSetupTokenProfiles(store);
if (SETUP_TOKEN_PROFILE) {
if (!candidates.includes(SETUP_TOKEN_PROFILE)) {
const available =
candidates.length > 0 ? candidates.join(", ") : "(none)";
const available = candidates.length > 0 ? candidates.join(", ") : "(none)";
throw new Error(
`Setup-token profile "${SETUP_TOKEN_PROFILE}" not found. Available: ${available}.`,
);
@ -120,11 +105,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
return { agentDir, profileId: SETUP_TOKEN_PROFILE };
}
if (
SETUP_TOKEN_RAW &&
SETUP_TOKEN_RAW !== "1" &&
SETUP_TOKEN_RAW !== "auto"
) {
if (SETUP_TOKEN_RAW && SETUP_TOKEN_RAW !== "1" && SETUP_TOKEN_RAW !== "auto") {
throw new Error(
"CLAWDBOT_LIVE_SETUP_TOKEN did not look like a setup-token. Use CLAWDBOT_LIVE_SETUP_TOKEN_VALUE for raw tokens.",
);
@ -146,8 +127,7 @@ function pickModel(models: Array<Model<Api>>, raw?: string): Model<Api> | null {
return (
models.find(
(model) =>
normalizeProviderId(model.provider) === parsed.provider &&
model.id === parsed.model,
normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model,
) ?? null
);
}
@ -176,9 +156,7 @@ describeLive("live anthropic setup-token", () => {
const authStorage = discoverAuthStorage(tokenSource.agentDir);
const modelRegistry = discoverModels(authStorage, tokenSource.agentDir);
const all = Array.isArray(modelRegistry)
? modelRegistry
: modelRegistry.getAll();
const all = Array.isArray(modelRegistry) ? modelRegistry : modelRegistry.getAll();
const candidates = all.filter(
(model) => normalizeProviderId(model.provider) === "anthropic",
) as Array<Model<Api>>;
@ -201,9 +179,7 @@ describeLive("live anthropic setup-token", () => {
});
const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey);
if (tokenError) {
throw new Error(
`Resolved profile is not a setup-token: ${tokenError}`,
);
throw new Error(`Resolved profile is not a setup-token: ${tokenError}`);
}
const res = await completeSimple(

View File

@ -16,10 +16,7 @@ export async function applyUpdateHunk(
});
const originalLines = originalContents.split("\n");
if (
originalLines.length > 0 &&
originalLines[originalLines.length - 1] === ""
) {
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
originalLines.pop();
}
@ -41,24 +38,16 @@ function computeReplacements(
for (const chunk of chunks) {
if (chunk.changeContext) {
const ctxIndex = seekSequence(
originalLines,
[chunk.changeContext],
lineIndex,
false,
);
const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
if (ctxIndex === null) {
throw new Error(
`Failed to find context '${chunk.changeContext}' in ${filePath}`,
);
throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`);
}
lineIndex = ctxIndex + 1;
}
if (chunk.oldLines.length === 0) {
const insertionIndex =
originalLines.length > 0 &&
originalLines[originalLines.length - 1] === ""
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length;
replacements.push([insertionIndex, 0, chunk.newLines]);
@ -67,24 +56,14 @@ function computeReplacements(
let pattern = chunk.oldLines;
let newSlice = chunk.newLines;
let found = seekSequence(
originalLines,
pattern,
lineIndex,
chunk.isEndOfFile,
);
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
if (found === null && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1);
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1);
}
found = seekSequence(
originalLines,
pattern,
lineIndex,
chunk.isEndOfFile,
);
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
}
if (found === null) {
@ -142,11 +121,7 @@ function seekSequence(
if (linesMatch(lines, pattern, i, (value) => value.trim())) return i;
}
for (let i = searchStart; i <= maxStart; i += 1) {
if (
linesMatch(lines, pattern, i, (value) =>
normalizePunctuation(value.trim()),
)
) {
if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) {
return i;
}
}

View File

@ -282,10 +282,7 @@ function checkPatchBoundariesLenient(lines: string[]): string[] {
}
const first = lines[0];
const last = lines[lines.length - 1];
if (
(first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') &&
last.endsWith("EOF")
) {
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) {
const inner = lines.slice(1, lines.length - 1);
const innerError = checkPatchBoundariesStrict(inner);
if (!innerError) return inner;
@ -308,10 +305,7 @@ function checkPatchBoundariesStrict(lines: string[]): string | null {
return "The last line of the patch must be '*** End Patch'";
}
function parseOneHunk(
lines: string[],
lineNumber: number,
): { hunk: Hunk; consumed: number } {
function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
if (lines.length === 0) {
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`);
}

View File

@ -1,9 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
} from "./auth-health.js";
import { buildAuthHealthSummary, DEFAULT_OAUTH_WARN_MS } from "./auth-health.js";
describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000;
@ -59,9 +56,7 @@ describe("buildAuthHealthSummary", () => {
expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find(
(entry) => entry.provider === "anthropic",
);
const provider = summary.providers.find((entry) => entry.provider === "anthropic");
expect(provider?.status).toBe("expired");
});
});

View File

@ -9,12 +9,7 @@ import {
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
export type AuthProfileHealth = {
profileId: string;
@ -27,12 +22,7 @@ export type AuthProfileHealth = {
label: string;
};
export type AuthProviderHealthStatus =
| "ok"
| "expiring"
| "expired"
| "missing"
| "static";
export type AuthProviderHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
export type AuthProviderHealth = {
provider: string;
@ -111,8 +101,7 @@ function buildProfileHealth(params: {
if (credential.type === "token") {
const expiresAt =
typeof credential.expires === "number" &&
Number.isFinite(credential.expires)
typeof credential.expires === "number" && Number.isFinite(credential.expires)
? credential.expires
: undefined;
if (!expiresAt || expiresAt <= 0) {
@ -125,11 +114,7 @@ function buildProfileHealth(params: {
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
expiresAt,
now,
warnAfterMs,
);
const { status, remainingMs } = resolveOAuthStatus(expiresAt, now, warnAfterMs);
return {
profileId,
provider: credential.provider,
@ -142,11 +127,7 @@ function buildProfileHealth(params: {
};
}
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
warnAfterMs,
);
const { status, remainingMs } = resolveOAuthStatus(credential.expires, now, warnAfterMs);
return {
profileId,
provider: credential.provider,
@ -172,9 +153,7 @@ export function buildAuthHealthSummary(params: {
: null;
const profiles = Object.entries(params.store.profiles)
.filter(([_, cred]) =>
providerFilter ? providerFilter.has(cred.provider) : true,
)
.filter(([_, cred]) => (providerFilter ? providerFilter.has(cred.provider) : true))
.map(([profileId, credential]) =>
buildProfileHealth({
profileId,
@ -226,9 +205,7 @@ export function buildAuthHealthSummary(params: {
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
const apiKeyProfiles = provider.profiles.filter((p) => p.type === "api_key");
const expirable = [...oauthProfiles, ...tokenProfiles];
if (expirable.length === 0) {

View File

@ -8,10 +8,7 @@ import {
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
import {
CHUTES_TOKEN_ENDPOINT,
type ChutesStoredOAuth,
} from "./chutes-oauth.js";
import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js";
describe("auth-profiles (chutes)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@ -30,32 +27,19 @@ describe("auth-profiles (chutes)", () => {
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined)
delete process.env.PI_CODING_AGENT_DIR;
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
if (previousChutesClientId === undefined)
delete process.env.CHUTES_CLIENT_ID;
if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID;
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
});
it("refreshes expired Chutes OAuth credentials", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(
tempDir,
"agents",
"main",
"agent",
);
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join(
tempDir,
"agents",
"main",
"agent",
"auth-profiles.json",
);
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = {
@ -75,8 +59,7 @@ describe("auth-profiles (chutes)", () => {
const fetchSpy = vi.fn(async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url !== CHUTES_TOKEN_ENDPOINT)
return new Response("not found", { status: 404 });
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
return new Response(
JSON.stringify({
access_token: "at_new",
@ -96,9 +79,7 @@ describe("auth-profiles (chutes)", () => {
expect(resolved?.apiKey).toBe("at_new");
expect(fetchSpy).toHaveBeenCalled();
const persisted = JSON.parse(
await fs.readFile(authProfilePath, "utf8"),
) as {
const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as {
profiles?: Record<string, { access?: string }>;
};
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");

View File

@ -6,9 +6,7 @@ import { ensureAuthProfileStore } from "./auth-profiles.js";
describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-auth-profiles-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-profiles-"));
try {
const legacyPath = path.join(agentDir, "auth.json");
fs.writeFileSync(

View File

@ -3,16 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "./auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("does not overwrite API keys when syncing external CLI creds", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-no-overwrite-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-no-overwrite-"));
try {
await withTempHome(
async (tempHome) => {
@ -26,10 +21,7 @@ describe("external CLI credential sync", () => {
expiresAt: Date.now() + 30 * 60 * 1000,
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
@ -50,9 +42,7 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir);
// Should keep the store's API key and still add the CLI profile.
expect(
(store.profiles["anthropic:default"] as { key: string }).key,
).toBe("sk-store");
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-store");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
@ -62,9 +52,7 @@ describe("external CLI credential sync", () => {
}
});
it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"));
try {
await withTempHome(
async (tempHome) => {
@ -103,9 +91,7 @@ describe("external CLI credential sync", () => {
// OAuth should be preferred over token because it can auto-refresh
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe(
"cli-oauth-access",
);
expect((cliProfile as { access: string }).access).toBe("cli-oauth-access");
},
{ prefix: "clawdbot-home-" },
);

View File

@ -3,16 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "./auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("does not overwrite fresher store oauth with older CLI oauth", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"));
try {
await withTempHome(
async (tempHome) => {
@ -52,9 +47,7 @@ describe("external CLI credential sync", () => {
// Fresher store oauth should be kept
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe(
"store-oauth-access",
);
expect((cliProfile as { access: string }).access).toBe("store-oauth-access");
},
{ prefix: "clawdbot-home-" },
);
@ -63,9 +56,7 @@ describe("external CLI credential sync", () => {
}
});
it("does not downgrade store oauth to token when CLI lacks refresh token", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-"));
try {
await withTempHome(
async (tempHome) => {
@ -104,9 +95,7 @@ describe("external CLI credential sync", () => {
// Keep oauth to preserve auto-refresh capability
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe(
"store-oauth-access",
);
expect((cliProfile as { access: string }).access).toBe("store-oauth-access");
},
{ prefix: "clawdbot-home-" },
);

View File

@ -3,16 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "./auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("syncs Claude CLI OAuth credentials into anthropic:claude-cli", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-sync-"));
try {
// Create a temp home with Claude CLI credentials
await withTempHome(
@ -27,10 +22,7 @@ describe("external CLI credential sync", () => {
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
@ -52,22 +44,14 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect(
(store.profiles["anthropic:default"] as { key: string }).key,
).toBe("sk-default");
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-default");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
// Should be stored as OAuth credential (type: "oauth") for auto-refresh
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe(
"fresh-access-token",
);
expect((cliProfile as { refresh: string }).refresh).toBe(
"fresh-refresh-token",
);
expect((cliProfile as { expires: number }).expires).toBeGreaterThan(
Date.now(),
);
expect((cliProfile as { access: string }).access).toBe("fresh-access-token");
expect((cliProfile as { refresh: string }).refresh).toBe("fresh-refresh-token");
expect((cliProfile as { expires: number }).expires).toBeGreaterThan(Date.now());
},
{ prefix: "clawdbot-home-" },
);
@ -76,9 +60,7 @@ describe("external CLI credential sync", () => {
}
});
it("syncs Claude CLI credentials without refreshToken as token type", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-token-sync-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-token-sync-"));
try {
await withTempHome(
async (tempHome) => {
@ -92,16 +74,10 @@ describe("external CLI credential sync", () => {
expiresAt: Date.now() + 60 * 60 * 1000,
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({ version: 1, profiles: {} }),
);
fs.writeFileSync(authPath, JSON.stringify({ version: 1, profiles: {} }));
const store = ensureAuthProfileStore(agentDir);
@ -109,9 +85,7 @@ describe("external CLI credential sync", () => {
// Should be stored as token type (no refresh capability)
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("token");
expect((cliProfile as { token: string }).token).toBe(
"access-only-token",
);
expect((cliProfile as { token: string }).token).toBe("access-only-token");
},
{ prefix: "clawdbot-home-" },
);

View File

@ -3,16 +3,11 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "./auth-profiles.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("updates codex-cli profile when Codex CLI refresh token changes", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"));
try {
await withTempHome(
async (tempHome) => {
@ -48,10 +43,9 @@ describe("external CLI credential sync", () => {
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string })
.refresh,
).toBe("new-refresh");
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh).toBe(
"new-refresh",
);
},
{ prefix: "clawdbot-home-" },
);

View File

@ -11,9 +11,7 @@ import {
describe("external CLI credential sync", () => {
it("upgrades token to oauth when Claude CLI gets refreshToken", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-upgrade-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-upgrade-"));
try {
await withTempHome(
async (tempHome) => {
@ -53,12 +51,8 @@ describe("external CLI credential sync", () => {
// Should upgrade from token to oauth
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe(
"new-oauth-access",
);
expect((cliProfile as { refresh: string }).refresh).toBe(
"new-refresh-token",
);
expect((cliProfile as { access: string }).access).toBe("new-oauth-access");
expect((cliProfile as { refresh: string }).refresh).toBe("new-refresh-token");
},
{ prefix: "clawdbot-home-" },
);
@ -67,9 +61,7 @@ describe("external CLI credential sync", () => {
}
});
it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-sync-"),
);
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-sync-"));
try {
await withTempHome(
async (tempHome) => {
@ -98,9 +90,9 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
).toBe("codex-access-token");
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"codex-access-token",
);
},
{ prefix: "clawdbot-home-" },
);

View File

@ -2,10 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
ensureAuthProfileStore,
markAuthProfileFailure,
} from "./auth-profiles.js";
import { ensureAuthProfileStore, markAuthProfileFailure } from "./auth-profiles.js";
describe("markAuthProfileFailure", () => {
it("disables billing failures for ~5 hours by default", async () => {
@ -35,8 +32,7 @@ describe("markAuthProfileFailure", () => {
agentDir,
});
const disabledUntil =
store.usageStats?.["anthropic:default"]?.disabledUntil;
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
@ -80,8 +76,7 @@ describe("markAuthProfileFailure", () => {
} as never,
});
const disabledUntil =
store.usageStats?.["anthropic:default"]?.disabledUntil;
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
@ -128,9 +123,7 @@ describe("markAuthProfileFailure", () => {
});
expect(store.usageStats?.["anthropic:default"]?.errorCount).toBe(1);
expect(
store.usageStats?.["anthropic:default"]?.failureCounts?.billing,
).toBe(1);
expect(store.usageStats?.["anthropic:default"]?.failureCounts?.billing).toBe(1);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}

View File

@ -91,10 +91,6 @@ describe("resolveAuthProfileOrder", () => {
},
provider: "anthropic",
});
expect(order).toEqual([
"anthropic:ready",
"anthropic:cool2",
"anthropic:cool1",
]);
expect(order).toEqual(["anthropic:ready", "anthropic:cool2", "anthropic:cool1"]);
});
});

View File

@ -1,7 +1,4 @@
export {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
} from "./auth-profiles/constants.js";
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
export { resolveApiKeyForProfile } from "./auth-profiles/oauth.js";

View File

@ -11,9 +11,7 @@ export function resolveAuthProfileDisplayLabel(params: {
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
const email =
configEmail ||
(profile && "email" in profile
? (profile.email as string | undefined)?.trim()
: undefined);
(profile && "email" in profile ? (profile.email as string | undefined)?.trim() : undefined);
if (email) return `${profileId} (${email})`;
return profileId;
}

View File

@ -33,9 +33,7 @@ export function formatAuthDoctorHint(params: {
"Doctor hint (for GitHub issue):",
`- provider: ${providerKey}`,
`- config: ${legacyProfileId}${
cfgProvider || cfgMode
? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})`
: ""
cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
}`,
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
`- suggested profile: ${suggested}`,

View File

@ -16,10 +16,7 @@ import type {
TokenCredential,
} from "./types.js";
function shallowEqualOAuthCredentials(
a: OAuthCredential | undefined,
b: OAuthCredential,
): boolean {
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) return false;
if (a.type !== "oauth") return false;
return (
@ -34,10 +31,7 @@ function shallowEqualOAuthCredentials(
);
}
function shallowEqualTokenCredentials(
a: TokenCredential | undefined,
b: TokenCredential,
): boolean {
function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
@ -48,10 +42,7 @@ function shallowEqualTokenCredentials(
);
}
function isExternalProfileFresh(
cred: AuthProfileCredential | undefined,
now: number,
): boolean {
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
if (!cred) return false;
if (cred.type !== "oauth" && cred.type !== "token") return false;
if (cred.provider !== "anthropic" && cred.provider !== "openai-codex") {
@ -104,8 +95,7 @@ export function syncExternalCliCredentials(
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCredsExpires > now &&
claudeCredsExpires > existingOAuth.expires);
(claudeCredsExpires > now && claudeCredsExpires > existingOAuth.expires);
} else {
const existingToken = existing?.type === "token" ? existing : undefined;
isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds);
@ -114,8 +104,7 @@ export function syncExternalCliCredentials(
!existingToken ||
existingToken.provider !== "anthropic" ||
(existingToken.expires ?? 0) <= now ||
(claudeCredsExpires > now &&
claudeCredsExpires > (existingToken.expires ?? 0));
(claudeCredsExpires > now && claudeCredsExpires > (existingToken.expires ?? 0));
}
// Also update if credential type changed (token -> oauth upgrade)
@ -166,10 +155,7 @@ export function syncExternalCliCredentials(
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
if (
shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, codexCreds)
) {
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {

View File

@ -1,8 +1,4 @@
import {
getOAuthApiKey,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import { getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
@ -15,12 +11,8 @@ import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
import type { AuthProfileStore } from "./types.js";
function buildOAuthApiKey(
provider: string,
credentials: OAuthCredentials,
): string {
const needsProjectId =
provider === "google-gemini-cli" || provider === "google-antigravity";
function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
return needsProjectId
? JSON.stringify({
token: credentials.access,
@ -76,10 +68,7 @@ async function refreshOAuthTokenWithLock(params: {
// Sync refreshed credentials back to Claude CLI if this is the claude-cli profile
// This ensures Claude Code continues to work after ClawdBot refreshes the token
if (
params.profileId === CLAUDE_CLI_PROFILE_ID &&
cred.provider === "anthropic"
) {
if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") {
writeClaudeCliCredentials(result.newCredentials);
}

View File

@ -43,17 +43,12 @@ export function resolveAuthProfileOrder(params: {
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(
([, profile]) =>
normalizeProviderId(profile.provider) === providerKey,
)
.filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
.map(([profileId]) => profileId)
: [];
const baseOrder =
explicitOrder ??
(explicitProfiles.length > 0
? explicitProfiles
: listProfilesForProvider(store, providerKey));
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
if (baseOrder.length === 0) return [];
const filtered = baseOrder.filter((profileId) => {
@ -66,8 +61,7 @@ export function resolveAuthProfileOrder(params: {
return false;
}
if (profileConfig.mode !== cred.type) {
const oauthCompatible =
profileConfig.mode === "oauth" && cred.type === "token";
const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
if (!oauthCompatible) return false;
}
}
@ -104,8 +98,7 @@ export function resolveAuthProfileOrder(params: {
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
for (const profileId of deduped) {
const cooldownUntil =
resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? 0;
if (
typeof cooldownUntil === "number" &&
Number.isFinite(cooldownUntil) &&
@ -126,10 +119,7 @@ export function resolveAuthProfileOrder(params: {
// Still put preferredProfile first if specified
if (preferredProfile && ordered.includes(preferredProfile)) {
return [
preferredProfile,
...ordered.filter((e) => e !== preferredProfile),
];
return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)];
}
return ordered;
}
@ -146,10 +136,7 @@ export function resolveAuthProfileOrder(params: {
return sorted;
}
function orderProfilesByMode(
order: string[],
store: AuthProfileStore,
): string[] {
function orderProfilesByMode(order: string[], store: AuthProfileStore): string[] {
const now = Date.now();
// Partition into available and in-cooldown
@ -168,8 +155,7 @@ function orderProfilesByMode(
// Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
const typeScore =
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const typeScore = type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed };
});
@ -189,8 +175,7 @@ function orderProfilesByMode(
const cooldownSorted = inCooldown
.map((profileId) => ({
profileId,
cooldownUntil:
resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
cooldownUntil: resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
}))
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
.map((entry) => entry.profileId);

View File

@ -4,11 +4,7 @@ import path from "node:path";
import { saveJsonFile } from "../../infra/json-file.js";
import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import {
AUTH_PROFILE_FILENAME,
AUTH_STORE_VERSION,
LEGACY_AUTH_FILENAME,
} from "./constants.js";
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
import type { AuthProfileStore } from "./types.js";
export function resolveAuthStorePath(agentDir?: string): string {

View File

@ -50,10 +50,7 @@ export function upsertAuthProfile(params: {
saveAuthProfileStore(store, params.agentDir);
}
export function listProfilesForProvider(
store: AuthProfileStore,
provider: string,
): string[] {
export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
const providerKey = normalizeProviderId(provider);
return Object.entries(store.profiles)
.filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey)

View File

@ -35,10 +35,9 @@ export function suggestOAuthProfileIdForLegacyDefault(params: {
return null;
}
const oauthProfiles = listProfilesForProvider(
params.store,
providerKey,
).filter((id) => params.store.profiles[id]?.type === "oauth");
const oauthProfiles = listProfilesForProvider(params.store, providerKey).filter(
(id) => params.store.profiles[id]?.type === "oauth",
);
if (oauthProfiles.length === 0) return null;
const configuredEmail = legacyCfg?.email?.trim();
@ -47,16 +46,12 @@ export function suggestOAuthProfileIdForLegacyDefault(params: {
const cred = params.store.profiles[id];
if (!cred || cred.type !== "oauth") return false;
const email = (cred.email as string | undefined)?.trim();
return (
email === configuredEmail || id === `${providerKey}:${configuredEmail}`
);
return email === configuredEmail || id === `${providerKey}:${configuredEmail}`;
});
if (byEmail) return byEmail;
}
const lastGood =
params.store.lastGood?.[providerKey] ??
params.store.lastGood?.[params.provider];
const lastGood = params.store.lastGood?.[providerKey] ?? params.store.lastGood?.[params.provider];
if (lastGood && oauthProfiles.includes(lastGood)) return lastGood;
const nonLegacy = oauthProfiles.filter((id) => id !== params.legacyProfileId);
@ -83,10 +78,7 @@ export function repairOAuthProfileIdMismatch(params: {
if (legacyCfg.mode !== "oauth") {
return { config: params.cfg, changes: [], migrated: false };
}
if (
normalizeProviderId(legacyCfg.provider) !==
normalizeProviderId(params.provider)
) {
if (normalizeProviderId(legacyCfg.provider) !== normalizeProviderId(params.provider)) {
return { config: params.cfg, changes: [], migrated: false };
}
@ -102,14 +94,10 @@ export function repairOAuthProfileIdMismatch(params: {
const toCred = params.store.profiles[toProfileId];
const toEmail =
toCred?.type === "oauth"
? (toCred.email as string | undefined)?.trim()
: undefined;
toCred?.type === "oauth" ? (toCred.email as string | undefined)?.trim() : undefined;
const nextProfiles = {
...(params.cfg.auth?.profiles as
| Record<string, AuthProfileConfig>
| undefined),
...(params.cfg.auth?.profiles as Record<string, AuthProfileConfig> | undefined),
} as Record<string, AuthProfileConfig>;
delete nextProfiles[legacyProfileId];
nextProfiles[toProfileId] = {
@ -121,17 +109,13 @@ export function repairOAuthProfileIdMismatch(params: {
const nextOrder = (() => {
const order = params.cfg.auth?.order;
if (!order) return undefined;
const resolvedKey = Object.keys(order).find(
(key) => normalizeProviderId(key) === providerKey,
);
const resolvedKey = Object.keys(order).find((key) => normalizeProviderId(key) === providerKey);
if (!resolvedKey) return order;
const existing = order[resolvedKey];
if (!Array.isArray(existing)) return order;
const replaced = existing
.map((id) => (id === legacyProfileId ? toProfileId : id))
.filter(
(id): id is string => typeof id === "string" && id.trim().length > 0,
);
.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
const deduped: string[] = [];
for (const entry of replaced) {
if (!deduped.includes(entry)) deduped.push(entry);
@ -148,9 +132,7 @@ export function repairOAuthProfileIdMismatch(params: {
},
};
const changes = [
`Auth: migrate ${legacyProfileId}${toProfileId} (OAuth profile id)`,
];
const changes = [`Auth: migrate ${legacyProfileId}${toProfileId} (OAuth profile id)`];
return {
config: nextCfg,

View File

@ -3,29 +3,14 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
log,
} from "./constants.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import { syncExternalCliCredentials } from "./external-cli-sync.js";
import {
ensureAuthStoreFile,
resolveAuthStorePath,
resolveLegacyAuthStorePath,
} from "./paths.js";
import type {
AuthProfileCredential,
AuthProfileStore,
ProfileUsageStats,
} from "./types.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
type LegacyAuthStore = Record<string, AuthProfileCredential>;
function _syncAuthProfileStore(
target: AuthProfileStore,
source: AuthProfileStore,
): void {
function _syncAuthProfileStore(target: AuthProfileStore, source: AuthProfileStore): void {
target.version = source.version;
target.profiles = source.profiles;
target.order = source.order;
@ -70,11 +55,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
continue;
}
entries[key] = {
@ -94,11 +75,7 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
continue;
}
if (!typed.provider) continue;
@ -188,9 +165,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
type: "token",
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
@ -253,9 +228,7 @@ export function ensureAuthProfileStore(
type: "token",
provider: String(cred.provider ?? provider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(typeof cred.expires === "number" ? { expires: cred.expires } : {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
@ -301,10 +274,7 @@ export function ensureAuthProfileStore(
return store;
}
export function saveAuthProfileStore(
store: AuthProfileStore,
agentDir?: string,
): void {
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
const authPath = resolveAuthStorePath(agentDir);
const payload = {
version: AUTH_STORE_VERSION,

View File

@ -29,10 +29,7 @@ export type OAuthCredential = OAuthCredentials & {
email?: string;
};
export type AuthProfileCredential =
| ApiKeyCredential
| TokenCredential
| OAuthCredential;
export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential;
export type AuthProfileFailureReason =
| "auth"

View File

@ -1,14 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeProviderId } from "../model-selection.js";
import {
saveAuthProfileStore,
updateAuthProfileStoreWithLock,
} from "./store.js";
import type {
AuthProfileFailureReason,
AuthProfileStore,
ProfileUsageStats,
} from "./types.js";
import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
const values = [stats.cooldownUntil, stats.disabledUntil]
@ -21,10 +14,7 @@ function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
/**
* Check if a profile is currently in cooldown (due to rate limiting or errors).
*/
export function isProfileInCooldown(
store: AuthProfileStore,
profileId: string,
): boolean {
export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean {
const stats = store.usageStats?.[profileId];
if (!stats) return false;
const unusableUntil = resolveProfileUnusableUntil(stats);
@ -102,9 +92,7 @@ function resolveAuthCooldownConfig(params: {
} as const;
const resolveHours = (value: unknown, fallback: number) =>
typeof value === "number" && Number.isFinite(value) && value > 0
? value
: fallback;
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
const cooldowns = params.cfg?.auth?.cooldowns;
const billingOverride = (() => {
@ -120,10 +108,7 @@ function resolveAuthCooldownConfig(params: {
billingOverride ?? cooldowns?.billingBackoffHours,
defaults.billingBackoffHours,
);
const billingMaxHours = resolveHours(
cooldowns?.billingMaxHours,
defaults.billingMaxHours,
);
const billingMaxHours = resolveHours(cooldowns?.billingMaxHours, defaults.billingMaxHours);
const failureWindowHours = resolveHours(
cooldowns?.failureWindowHours,
defaults.failureWindowHours,
@ -172,9 +157,7 @@ function computeNextProfileUsageStats(params: {
const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
const nextErrorCount = baseErrorCount + 1;
const failureCounts = windowExpired
? {}
: { ...params.existing.failureCounts };
const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts };
failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
const updatedStats: ProfileUsageStats = {
@ -246,9 +229,7 @@ export async function markAuthProfileFailure(params: {
store.usageStats = store.usageStats ?? {};
const existing = store.usageStats[profileId] ?? {};
const now = Date.now();
const providerKey = normalizeProviderId(
store.profiles[profileId]?.provider ?? "",
);
const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
const cfgResolved = resolveAuthCooldownConfig({
cfg,
providerId: providerKey,

View File

@ -9,9 +9,7 @@ function clampTtl(value: number | undefined) {
return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS);
}
let jobTtlMs = clampTtl(
Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10),
);
let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10));
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
@ -75,24 +73,15 @@ export function deleteSession(id: string) {
finishedSessions.delete(id);
}
export function appendOutput(
session: ProcessSession,
stream: "stdout" | "stderr",
chunk: string,
) {
export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
session.pendingStdout ??= [];
session.pendingStderr ??= [];
const buffer =
stream === "stdout" ? session.pendingStdout : session.pendingStderr;
const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
buffer.push(chunk);
session.totalOutputChars += chunk.length;
const aggregated = trimWithCap(
session.aggregated + chunk,
session.maxOutputChars,
);
const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
session.truncated =
session.truncated ||
aggregated.length < session.aggregated.length + chunk.length;
session.truncated || aggregated.length < session.aggregated.length + chunk.length;
session.aggregated = aggregated;
session.tail = tail(session.aggregated, 2000);
}

View File

@ -4,12 +4,7 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { logInfo } from "../logger.js";
import {
addSession,
appendOutput,
markBackgrounded,
markExited,
} from "./bash-process-registry.js";
import { addSession, appendOutput, markBackgrounded, markExited } from "./bash-process-registry.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
import {
buildDockerExecArgs,
@ -32,8 +27,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
150_000,
);
const DEFAULT_PATH =
process.env.PATH ??
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
export type ExecToolDefaults = {
backgroundMs?: number;
@ -55,18 +49,14 @@ export type ExecElevatedDefaults = {
const execSchema = Type.Object({
command: Type.String({ description: "Shell command to execute" }),
workdir: Type.Optional(
Type.String({ description: "Working directory (defaults to cwd)" }),
),
workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })),
env: Type.Optional(Type.Record(Type.String(), Type.String())),
yieldMs: Type.Optional(
Type.Number({
description: "Milliseconds to wait before backgrounding (default 10000)",
}),
),
background: Type.Optional(
Type.Boolean({ description: "Run in background immediately" }),
),
background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
timeout: Type.Optional(
Type.Number({
description: "Timeout in seconds (optional, kills process on expiry)",
@ -140,19 +130,12 @@ export function createExecTool(
const backgroundRequested = params.background === true;
const yieldRequested = typeof params.yieldMs === "number";
if (!allowBackground && (backgroundRequested || yieldRequested)) {
warnings.push(
"Warning: background execution is disabled; running synchronously.",
);
warnings.push("Warning: background execution is disabled; running synchronously.");
}
const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampNumber(
params.yieldMs ?? defaultBackgroundMs,
defaultBackgroundMs,
10,
120_000,
)
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn =
@ -160,17 +143,13 @@ export function createExecTool(
elevatedDefaults.enabled &&
elevatedDefaults.allowed;
const elevatedRequested =
typeof params.elevated === "boolean"
? params.elevated
: elevatedDefaultOn;
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
const gates: string[] = [];
if (!elevatedDefaults?.enabled) {
gates.push(
"enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)",
);
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
} else {
gates.push(
"allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)",
@ -197,8 +176,7 @@ export function createExecTool(
}
const sandbox = elevatedRequested ? undefined : defaults?.sandbox;
const rawWorkdir =
params.workdir?.trim() || defaults?.cwd || process.cwd();
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
let workdir = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir;
if (sandbox) {
@ -335,121 +313,111 @@ export function createExecTool(
}
});
return new Promise<AgentToolResult<ExecToolDetails>>(
(resolve, reject) => {
const resolveRunning = () => {
settle(() =>
resolve({
content: [
{
type: "text",
text:
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
},
],
details: {
status: "running",
sessionId,
pid: session.pid ?? undefined,
startedAt,
cwd: session.cwd,
tail: session.tail,
return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
const resolveRunning = () => {
settle(() =>
resolve({
content: [
{
type: "text",
text:
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
},
}),
);
};
],
details: {
status: "running",
sessionId,
pid: session.pid ?? undefined,
startedAt,
cwd: session.cwd,
tail: session.tail,
},
}),
);
};
const onYieldNow = () => {
if (yieldTimer) clearTimeout(yieldTimer);
if (settled) return;
yielded = true;
markBackgrounded(session);
resolveRunning();
};
const onYieldNow = () => {
if (yieldTimer) clearTimeout(yieldTimer);
if (settled) return;
yielded = true;
markBackgrounded(session);
resolveRunning();
};
if (allowBackground && yieldWindow !== null) {
if (yieldWindow === 0) {
onYieldNow();
} else {
yieldTimer = setTimeout(() => {
if (settled) return;
yielded = true;
markBackgrounded(session);
resolveRunning();
}, yieldWindow);
}
if (allowBackground && yieldWindow !== null) {
if (yieldWindow === 0) {
onYieldNow();
} else {
yieldTimer = setTimeout(() => {
if (settled) return;
yielded = true;
markBackgrounded(session);
resolveRunning();
}, yieldWindow);
}
}
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
const durationMs = Date.now() - startedAt;
const wasSignal = exitSignal != null;
const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status);
if (yielded || session.backgrounded) return;
const aggregated = session.aggregated.trim();
if (!isSuccess) {
const reason = timedOut
? `Command timed out after ${effectiveTimeout} seconds`
: wasSignal && exitSignal
? `Command aborted by signal ${exitSignal}`
: code === null
? "Command aborted before exit code was captured"
: `Command exited with code ${code}`;
const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
settle(() => reject(new Error(message)));
return;
}
const handleExit = (
code: number | null,
exitSignal: NodeJS.Signals | number | null,
) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
const durationMs = Date.now() - startedAt;
const wasSignal = exitSignal != null;
const isSuccess =
code === 0 && !wasSignal && !signal?.aborted && !timedOut;
const status: "completed" | "failed" = isSuccess
? "completed"
: "failed";
markExited(session, code, exitSignal, status);
if (yielded || session.backgrounded) return;
const aggregated = session.aggregated.trim();
if (!isSuccess) {
const reason = timedOut
? `Command timed out after ${effectiveTimeout} seconds`
: wasSignal && exitSignal
? `Command aborted by signal ${exitSignal}`
: code === null
? "Command aborted before exit code was captured"
: `Command exited with code ${code}`;
const message = aggregated
? `${aggregated}\n\n${reason}`
: reason;
settle(() => reject(new Error(message)));
return;
}
settle(() =>
resolve({
content: [
{
type: "text",
text:
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
(aggregated || "(no output)"),
},
],
details: {
status: "completed",
exitCode: code ?? 0,
durationMs,
aggregated,
cwd: session.cwd,
settle(() =>
resolve({
content: [
{
type: "text",
text:
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
(aggregated || "(no output)"),
},
}),
);
};
],
details: {
status: "completed",
exitCode: code ?? 0,
durationMs,
aggregated,
cwd: session.cwd,
},
}),
);
};
// `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
// `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
},
);
child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
});
},
};
}

View File

@ -27,9 +27,7 @@ export type ProcessToolDefaults = {
const processSchema = Type.Object({
action: Type.String({ description: "Process action" }),
sessionId: Type.Optional(
Type.String({ description: "Session id for actions other than list" }),
),
sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })),
data: Type.Optional(Type.String({ description: "Data to write for write" })),
eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })),
offset: Type.Optional(Type.Number({ description: "Log offset" })),
@ -96,9 +94,7 @@ export function createProcessTool(
const lines = [...running, ...finished]
.sort((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name
? truncateMiddle(s.name, 80)
: truncateMiddle(s.command, 120);
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId.slice(0, 8)} ${pad(
s.status,
9,
@ -117,9 +113,7 @@ export function createProcessTool(
if (!params.sessionId) {
return {
content: [
{ type: "text", text: "sessionId is required for this action." },
],
content: [{ type: "text", text: "sessionId is required for this action." }],
details: { status: "failed" },
};
}
@ -150,10 +144,7 @@ export function createProcessTool(
},
],
details: {
status:
scopedFinished.status === "completed"
? "completed"
: "failed",
status: scopedFinished.status === "completed" ? "completed" : "failed",
sessionId: params.sessionId,
exitCode: scopedFinished.exitCode ?? undefined,
aggregated: scopedFinished.aggregated,
@ -187,8 +178,7 @@ export function createProcessTool(
const exitCode = scopedSession.exitCode ?? 0;
const exitSignal = scopedSession.exitSignal ?? undefined;
if (exited) {
const status =
exitCode === 0 && exitSignal == null ? "completed" : "failed";
const status = exitCode === 0 && exitSignal == null ? "completed" : "failed";
markExited(
scopedSession,
scopedSession.exitCode ?? null,
@ -201,10 +191,7 @@ export function createProcessTool(
? "completed"
: "failed"
: "running";
const output = [stdout.trimEnd(), stderr.trimEnd()]
.filter(Boolean)
.join("\n")
.trim();
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n").trim();
return {
content: [
{
@ -265,12 +252,9 @@ export function createProcessTool(
params.offset,
params.limit,
);
const status =
scopedFinished.status === "completed" ? "completed" : "failed";
const status = scopedFinished.status === "completed" ? "completed" : "failed";
return {
content: [
{ type: "text", text: slice || "(no output recorded)" },
],
content: [{ type: "text", text: slice || "(no output recorded)" }],
details: {
status,
sessionId: params.sessionId,
@ -318,10 +302,7 @@ export function createProcessTool(
details: { status: "failed" },
};
}
if (
!scopedSession.child?.stdin ||
scopedSession.child.stdin.destroyed
) {
if (!scopedSession.child?.stdin || scopedSession.child.stdin.destroyed) {
return {
content: [
{
@ -353,9 +334,7 @@ export function createProcessTool(
details: {
status: "running",
sessionId: params.sessionId,
name: scopedSession
? deriveSessionName(scopedSession.command)
: undefined,
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
},
};
}
@ -386,14 +365,10 @@ export function createProcessTool(
killSession(scopedSession);
markExited(scopedSession, null, "SIGKILL", "failed");
return {
content: [
{ type: "text", text: `Killed session ${params.sessionId}.` },
],
content: [{ type: "text", text: `Killed session ${params.sessionId}.` }],
details: {
status: "failed",
name: scopedSession
? deriveSessionName(scopedSession.command)
: undefined,
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
},
};
}
@ -402,9 +377,7 @@ export function createProcessTool(
if (scopedFinished) {
deleteSession(params.sessionId);
return {
content: [
{ type: "text", text: `Cleared session ${params.sessionId}.` },
],
content: [{ type: "text", text: `Cleared session ${params.sessionId}.` }],
details: { status: "completed" },
};
}
@ -424,23 +397,17 @@ export function createProcessTool(
killSession(scopedSession);
markExited(scopedSession, null, "SIGKILL", "failed");
return {
content: [
{ type: "text", text: `Removed session ${params.sessionId}.` },
],
content: [{ type: "text", text: `Removed session ${params.sessionId}.` }],
details: {
status: "failed",
name: scopedSession
? deriveSessionName(scopedSession.command)
: undefined,
name: scopedSession ? deriveSessionName(scopedSession.command) : undefined,
},
};
}
if (scopedFinished) {
deleteSession(params.sessionId);
return {
content: [
{ type: "text", text: `Removed session ${params.sessionId}.` },
],
content: [{ type: "text", text: `Removed session ${params.sessionId}.` }],
details: { status: "completed" },
};
}
@ -457,9 +424,7 @@ export function createProcessTool(
}
return {
content: [
{ type: "text", text: `Unknown action ${params.action as string}` },
],
content: [{ type: "text", text: `Unknown action ${params.action as string}` }],
details: { status: "failed" },
};
},

View File

@ -98,10 +98,7 @@ export async function resolveSandboxWorkdir(params: {
}
}
export function killSession(session: {
pid?: number;
child?: ChildProcessWithoutNullStreams;
}) {
export function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams }) {
const pid = session.pid ?? session.child?.pid;
if (pid) {
killProcessTree(pid);
@ -117,9 +114,7 @@ export function resolveWorkdir(workdir: string, warnings: string[]) {
} catch {
// ignore, fallback below
}
warnings.push(
`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`,
);
warnings.push(`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`);
return fallback;
}
@ -177,9 +172,7 @@ export function sliceLogLines(
const totalLines = lines.length;
const totalChars = text.length;
let start =
typeof offset === "number" && Number.isFinite(offset)
? Math.max(0, Math.floor(offset))
: 0;
typeof offset === "number" && Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
if (limit !== undefined && offset === undefined) {
const tailCount = Math.max(0, Math.floor(limit));
start = Math.max(totalLines - tailCount, 0);
@ -203,8 +196,7 @@ export function deriveSessionName(command: string): string | undefined {
}
function tokenizeCommand(command: string): string[] {
const matches =
command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? [];
const matches = command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? [];
return matches.map((token) => stripQuotes(token)).filter(Boolean);
}

View File

@ -1,11 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import {
createExecTool,
createProcessTool,
execTool,
processTool,
} from "./bash-tools.js";
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
import { sanitizeBinaryOutput } from "./shell-utils.js";
const isWin = process.platform === "win32";
@ -15,10 +10,8 @@ const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
const longDelayCmd = isWin ? "Start-Sleep -Seconds 2" : "sleep 2";
// Both PowerShell and bash use ; for command separation
const joinCommands = (commands: string[]) => commands.join("; ");
const echoAfterDelay = (message: string) =>
joinCommands([shortDelayCmd, `echo ${message}`]);
const echoLines = (lines: string[]) =>
joinCommands(lines.map((line) => `echo ${line}`));
const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, `echo ${message}`]);
const echoLines = (lines: string[]) => joinCommands(lines.map((line) => `echo ${line}`));
const normalizeText = (value?: string) =>
sanitizeBinaryOutput(value ?? "")
.replace(/\r\n/g, "\n")
@ -74,8 +67,7 @@ describe("exec tool backgrounding", () => {
let status = "running";
let output = "";
const deadline =
Date.now() + (process.platform === "win32" ? 8000 : 2000);
const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call2", {
@ -106,9 +98,7 @@ describe("exec tool backgrounding", () => {
const sessionId = (result.details as { sessionId: string }).sessionId;
const list = await processTool.execute("call2", { action: "list" });
const sessions = (
list.details as { sessions: Array<{ sessionId: string }> }
).sessions;
const sessions = (list.details as { sessions: Array<{ sessionId: string }> }).sessions;
expect(sessions.some((s) => s.sessionId === sessionId)).toBe(true);
});
@ -121,9 +111,8 @@ describe("exec tool backgrounding", () => {
await sleep(25);
const list = await processTool.execute("call2", { action: "list" });
const sessions = (
list.details as { sessions: Array<{ sessionId: string; name?: string }> }
).sessions;
const sessions = (list.details as { sessions: Array<{ sessionId: string; name?: string }> })
.sessions;
const entry = sessions.find((s) => s.sessionId === sessionId);
expect(entry?.name).toBe("echo hello");
});
@ -239,9 +228,7 @@ describe("exec tool backgrounding", () => {
const sessionB = (resultB.details as { sessionId: string }).sessionId;
const listA = await processA.execute("call3", { action: "list" });
const sessionsA = (
listA.details as { sessions: Array<{ sessionId: string }> }
).sessions;
const sessionsA = (listA.details as { sessions: Array<{ sessionId: string }> }).sessions;
expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true);
expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false);

View File

@ -2,9 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAgentTool } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
export function listChannelAgentTools(params: {
cfg?: ClawdbotConfig;
}): ChannelAgentTool[] {
export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): ChannelAgentTool[] {
// Channel docking: aggregate channel-owned tools (login, etc.).
const tools: ChannelAgentTool[] = [];
for (const plugin of listChannelPlugins()) {

View File

@ -14,10 +14,7 @@ describe("chutes-oauth", () => {
if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST");
expect(
String(
init?.headers &&
(init.headers as Record<string, string>)["Content-Type"],
),
String(init?.headers && (init.headers as Record<string, string>)["Content-Type"]),
).toContain("application/x-www-form-urlencoded");
return new Response(
JSON.stringify({
@ -30,18 +27,12 @@ describe("chutes-oauth", () => {
}
if (url === CHUTES_USERINFO_ENDPOINT) {
expect(
String(
init?.headers &&
(init.headers as Record<string, string>).Authorization,
),
String(init?.headers && (init.headers as Record<string, string>).Authorization),
).toBe("Bearer at_123");
return new Response(
JSON.stringify({ username: "fred", sub: "sub_1" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("not found", { status: 404 });
};
@ -62,20 +53,15 @@ describe("chutes-oauth", () => {
expect(creds.access).toBe("at_123");
expect(creds.refresh).toBe("rt_123");
expect(creds.email).toBe("fred");
expect((creds as unknown as { accountId?: string }).accountId).toBe(
"sub_1",
);
expect((creds as unknown as { clientId?: string }).clientId).toBe(
"cid_test",
);
expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1");
expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test");
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
});
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = String(input);
if (url !== CHUTES_TOKEN_ENDPOINT)
return new Response("not found", { status: 404 });
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
expect(init?.method).toBe("POST");
const body = init?.body as URLSearchParams;
expect(String(body.get("grant_type"))).toBe("refresh_token");

View File

@ -59,10 +59,7 @@ export function parseOAuthCallbackInput(
}
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
const value =
now +
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
DEFAULT_EXPIRES_BUFFER_MS;
const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
return Math.max(value, now + 30_000);
}
@ -122,8 +119,7 @@ export async function exchangeChutesCodeForTokens(params: {
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access)
throw new Error("Chutes token exchange returned no access_token");
if (!access) throw new Error("Chutes token exchange returned no access_token");
if (!refresh) {
throw new Error("Chutes token exchange returned no refresh_token");
}
@ -153,12 +149,9 @@ export async function refreshChutesTokens(params: {
throw new Error("Chutes OAuth credential is missing refresh token");
}
const clientId =
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
const clientId = params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
if (!clientId) {
throw new Error(
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
);
throw new Error("Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).");
}
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;

View File

@ -18,10 +18,7 @@ function createDeferred<T>() {
};
}
async function waitForCalls(
mockFn: { mock: { calls: unknown[][] } },
count: number,
) {
async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: number) {
for (let i = 0; i < 50; i += 1) {
if (mockFn.mock.calls.length >= count) return;
await new Promise((resolve) => setTimeout(resolve, 0));
@ -30,8 +27,7 @@ async function waitForCalls(
}
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) =>
runCommandWithTimeoutMock(...args),
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
describe("runClaudeCliAgent", () => {

View File

@ -41,9 +41,7 @@ describe("gateway tool", () => {
payload?: { kind?: string; doctorHint?: string | null };
};
expect(parsed.payload?.kind).toBe("restart");
expect(parsed.payload?.doctorHint).toBe(
"Run: clawdbot doctor --non-interactive",
);
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
expect(kill).not.toHaveBeenCalled();
await vi.runAllTimersAsync();

View File

@ -1,8 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@ -41,8 +39,7 @@ describe("agents_list", () => {
requester: "main",
allowAny: false,
});
const agents = (result.details as { agents?: Array<{ id: string }> })
.agents;
const agents = (result.details as { agents?: Array<{ id: string }> }).agents;
expect(agents?.map((agent) => agent.id)).toEqual(["main"]);
});
@ -123,11 +120,7 @@ describe("agents_list", () => {
agents?: Array<{ id: string }>;
}
).agents;
expect(agents?.map((agent) => agent.id)).toEqual([
"main",
"coder",
"research",
]);
expect(agents?.map((agent) => agent.id)).toEqual(["main", "coder", "research"]);
});
it("marks allowlisted-but-unconfigured agents", async () => {

View File

@ -35,9 +35,7 @@ describe("nodes camera_snap", () => {
throw new Error(`unexpected method: ${String(method)}`);
});
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "nodes",
);
const tool = createClawdbotTools().find((candidate) => candidate.name === "nodes");
if (!tool) throw new Error("missing nodes tool");
const result = await tool.execute("call1", {
@ -46,9 +44,7 @@ describe("nodes camera_snap", () => {
facing: "front",
});
const images = (result.content ?? []).filter(
(block) => block.type === "image",
);
const images = (result.content ?? []).filter((block) => block.type === "image");
expect(images).toHaveLength(1);
expect(images[0]?.mimeType).toBe("image/jpeg");
});
@ -75,9 +71,7 @@ describe("nodes camera_snap", () => {
throw new Error(`unexpected method: ${String(method)}`);
});
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "nodes",
);
const tool = createClawdbotTools().find((candidate) => candidate.name === "nodes");
if (!tool) throw new Error("missing nodes tool");
await tool.execute("call1", {
@ -118,9 +112,7 @@ describe("nodes run", () => {
throw new Error(`unexpected method: ${String(method)}`);
});
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "nodes",
);
const tool = createClawdbotTools().find((candidate) => candidate.name === "nodes");
if (!tool) throw new Error("missing nodes tool");
await tool.execute("call1", {

View File

@ -54,9 +54,7 @@ describe("sessions tools", () => {
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number");
expect(schemaProp("sessions_list", "messageLimit").type).toBe("number");
expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe(
"number",
);
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number");
});
@ -108,9 +106,7 @@ describe("sessions tools", () => {
return {};
});
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "sessions_list",
);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_list");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_list tool");
@ -147,9 +143,7 @@ describe("sessions tools", () => {
return {};
});
const tool = createClawdbotTools().find(
(candidate) => candidate.name === "sessions_history",
);
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_history tool");
@ -181,9 +175,7 @@ describe("sessions tools", () => {
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| { message?: string; sessionKey?: string }
| undefined;
const params = request.params as { message?: string; sessionKey?: string } | undefined;
const message = params?.message ?? "";
let reply = "REPLY_SKIP";
if (message === "ping" || message === "wait") {
@ -207,8 +199,7 @@ describe("sessions tools", () => {
}
if (request.method === "chat.history") {
_historyCallCount += 1;
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
@ -268,9 +259,7 @@ describe("sessions tools", () => {
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history",
);
const historyOnlyCalls = calls.filter((call) => call.method === "chat.history");
expect(agentCalls).toHaveLength(8);
for (const call of agentCalls) {
expect(call.params).toMatchObject({
@ -281,31 +270,28 @@ describe("sessions tools", () => {
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent message context"),
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
"Agent-to-agent message context",
),
),
).toBe(true);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
"Agent-to-agent reply step",
),
),
).toBe(true);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
"Agent-to-agent announce step",
),
),
).toBe(true);
expect(waitCalls).toHaveLength(8);
@ -339,9 +325,7 @@ describe("sessions tools", () => {
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
}
if (
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
) {
if (params?.extraSystemPrompt?.includes("Agent-to-agent announce step")) {
reply = "announce now";
}
replyByRunId.set(runId, reply);
@ -357,8 +341,7 @@ describe("sessions tools", () => {
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
@ -414,11 +397,10 @@ describe("sessions tools", () => {
const replySteps = calls.filter(
(call) =>
call.method === "agent" &&
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
typeof (call.params as { extraSystemPrompt?: string })?.extraSystemPrompt === "string" &&
(call.params as { extraSystemPrompt?: string })?.extraSystemPrompt?.includes(
"Agent-to-agent reply step",
),
);
expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@ -78,9 +76,7 @@ describe("clawdbot-tools: subagents", () => {
};
}
if (request.method === "agent.wait") {
const params = request.params as
| { runId?: string; timeoutMs?: number }
| undefined;
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
return {
runId: params?.runId ?? "run-1",
@ -91,8 +87,7 @@ describe("clawdbot-tools: subagents", () => {
}
if (request.method === "chat.history") {
const params = request.params as { sessionKey?: string } | undefined;
const text =
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
return {
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
};

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@ -79,17 +77,14 @@ describe("clawdbot-tools: subagents", () => {
};
}
if (request.method === "agent.wait") {
const params = request.params as
| { runId?: string; timeoutMs?: number }
| undefined;
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok";
return { runId: params?.runId ?? "run-1", status };
}
if (request.method === "chat.history") {
const params = request.params as { sessionKey?: string } | undefined;
const text =
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
return {
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
};

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@ -83,9 +81,7 @@ describe("clawdbot-tools: subagents", () => {
modelApplied: true,
});
const patchIndex = calls.findIndex(
(call) => call.method === "sessions.patch",
);
const patchIndex = calls.findIndex((call) => call.method === "sessions.patch");
const agentIndex = calls.findIndex((call) => call.method === "agent");
expect(patchIndex).toBeGreaterThan(-1);
expect(agentIndex).toBeGreaterThan(-1);

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@ -124,9 +122,9 @@ describe("clawdbot-tools: subagents", () => {
status: "accepted",
modelApplied: false,
});
expect(
String((result.details as { warning?: string }).warning ?? ""),
).toContain("invalid model");
expect(String((result.details as { warning?: string }).warning ?? "")).toContain(
"invalid model",
);
expect(calls.some((call) => call.method === "agent")).toBe(true);
});
it("sessions_spawn supports legacy timeoutSeconds alias", async () => {

View File

@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
@ -85,17 +83,14 @@ describe("clawdbot-tools: subagents", () => {
};
}
if (request.method === "agent.wait") {
const params = request.params as
| { runId?: string; timeoutMs?: number }
| undefined;
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok";
return { runId: params?.runId ?? "run-1", status };
}
if (request.method === "chat.history") {
const params = request.params as { sessionKey?: string } | undefined;
const text =
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
return {
messages: [{ role: "assistant", content: [{ type: "text", text }] }],
};

View File

@ -9,10 +9,7 @@ import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import {
createMemoryGetTool,
createMemorySearchTool,
} from "./tools/memory-tool.js";
import { createMemoryGetTool, createMemorySearchTool } from "./tools/memory-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js";
@ -105,9 +102,7 @@ export function createClawdbotTools(options?: {
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
...(memorySearchTool && memoryGetTool
? [memorySearchTool, memoryGetTool]
: []),
...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []),
...(imageTool ? [imageTool] : []),
];

View File

@ -34,12 +34,7 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
modelAliases: CLAUDE_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
],
sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
@ -49,15 +44,7 @@ const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
command: "codex",
args: [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"read-only",
"--skip-git-repo-check",
],
args: ["exec", "--json", "--color", "never", "--sandbox", "read-only", "--skip-git-repo-check"],
resumeArgs: [
"exec",
"resume",
@ -93,10 +80,7 @@ function pickBackendConfig(
return undefined;
}
function mergeBackendConfig(
base: CliBackendConfig,
override?: CliBackendConfig,
): CliBackendConfig {
function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig): CliBackendConfig {
if (!override) return { ...base };
return {
...base,
@ -104,9 +88,7 @@ function mergeBackendConfig(
args: override.args ?? base.args,
env: { ...base.env, ...override.env },
modelAliases: { ...base.modelAliases, ...override.modelAliases },
clearEnv: Array.from(
new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]),
),
clearEnv: Array.from(new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])])),
sessionIdFields: override.sessionIdFields ?? base.sessionIdFields,
sessionArgs: override.sessionArgs ?? base.sessionArgs,
resumeArgs: override.resumeArgs ?? base.resumeArgs,

View File

@ -42,9 +42,7 @@ describe("cli credentials", () => {
return "";
});
const { writeClaudeCliKeychainCredentials } = await import(
"./cli-credentials.js"
);
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
const ok = writeClaudeCliKeychainCredentials({
access: "new-access",
@ -53,13 +51,9 @@ describe("cli credentials", () => {
});
expect(ok).toBe(true);
expect(
commands.some((cmd) => cmd.includes("delete-generic-password")),
).toBe(false);
expect(commands.some((cmd) => cmd.includes("delete-generic-password"))).toBe(false);
const updateCommand = commands.find((cmd) =>
cmd.includes("add-generic-password"),
);
const updateCommand = commands.find((cmd) => cmd.includes("add-generic-password"));
expect(updateCommand).toContain("-U");
});
@ -130,9 +124,7 @@ describe("cli credentials", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const { readClaudeCliCredentialsCached } = await import(
"./cli-credentials.js"
);
const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js");
const first = readClaudeCliCredentialsCached({
allowKeychainPrompt: true,
@ -163,9 +155,7 @@ describe("cli credentials", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const { readClaudeCliCredentialsCached } = await import(
"./cli-credentials.js"
);
const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js");
const first = readClaudeCliCredentialsCached({
allowKeychainPrompt: true,

View File

@ -56,10 +56,7 @@ type ClaudeCliFileOptions = {
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
platform?: NodeJS.Platform;
writeKeychain?: (credentials: OAuthCredentials) => boolean;
writeFile?: (
credentials: OAuthCredentials,
options?: ClaudeCliFileOptions,
) => boolean;
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
};
function resolveClaudeCliCredentialsPath(homeDir?: string) {
@ -73,9 +70,7 @@ function resolveCodexCliAuthPath() {
function resolveCodexHomePath() {
const configured = process.env.CODEX_HOME;
const home = configured
? resolveUserPath(configured)
: resolveUserPath("~/.codex");
const home = configured ? resolveUserPath(configured) : resolveUserPath("~/.codex");
try {
return fs.realpathSync.native(home);
} catch {
@ -98,10 +93,11 @@ function readCodexKeychainCredentials(options?: {
const account = computeCodexKeychainAccount(codexHome);
try {
const secret = execSync(
`security find-generic-password -s "Codex Auth" -a "${account}" -w`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
).trim();
const secret = execSync(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, {
encoding: "utf8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const parsed = JSON.parse(secret) as Record<string, unknown>;
const tokens = parsed.tokens as Record<string, unknown> | undefined;
@ -253,9 +249,7 @@ export function readClaudeCliCredentialsCached(options?: {
return value;
}
export function writeClaudeCliKeychainCredentials(
newCredentials: OAuthCredentials,
): boolean {
export function writeClaudeCliKeychainCredentials(newCredentials: OAuthCredentials): boolean {
try {
const existingResult = execSync(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`,
@ -309,9 +303,7 @@ export function writeClaudeCliFileCredentials(
if (!raw || typeof raw !== "object") return false;
const data = raw as Record<string, unknown>;
const existingOauth = data.claudeAiOauth as
| Record<string, unknown>
| undefined;
const existingOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
if (!existingOauth || typeof existingOauth !== "object") return false;
data.claudeAiOauth = {
@ -339,12 +331,10 @@ export function writeClaudeCliCredentials(
options?: ClaudeCliWriteOptions,
): boolean {
const platform = options?.platform ?? process.platform;
const writeKeychain =
options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
const writeKeychain = options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
const writeFile =
options?.writeFile ??
((credentials, fileOptions) =>
writeClaudeCliFileCredentials(credentials, fileOptions));
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
if (platform === "darwin") {
const didWriteKeychain = writeKeychain(newCredentials);

View File

@ -6,8 +6,7 @@ const runCommandWithTimeoutMock = vi.fn();
const runExecMock = vi.fn();
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) =>
runCommandWithTimeoutMock(...args),
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
runExec: (...args: unknown[]) => runExecMock(...args),
}));

View File

@ -30,10 +30,7 @@ import {
resolveBootstrapMaxChars,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import {
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles,
} from "./workspace.js";
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "./workspace.js";
const log = createSubsystemLogger("agent/claude-cli");
@ -58,10 +55,7 @@ export async function runCliAgent(params: {
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
const backendResolved = resolveCliBackendConfig(
params.provider,
params.config,
);
const backendResolved = resolveCliBackendConfig(params.provider, params.config);
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
@ -92,9 +86,7 @@ export async function runCliAgent(params: {
});
const heartbeatPrompt =
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(
params.config?.agents?.defaults?.heartbeat?.prompt,
)
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined;
const systemPrompt = buildSystemPrompt({
workspaceDir,
@ -114,14 +106,12 @@ export async function runCliAgent(params: {
});
const useResume = Boolean(
params.cliSessionId &&
cliSessionIdToSend &&
backend.resumeArgs &&
backend.resumeArgs.length > 0,
cliSessionIdToSend &&
backend.resumeArgs &&
backend.resumeArgs.length > 0,
);
const sessionIdSent = cliSessionIdToSend
? useResume ||
Boolean(backend.sessionArg) ||
Boolean(backend.sessionArgs?.length)
? useResume || Boolean(backend.sessionArg) || Boolean(backend.sessionArgs?.length)
? cliSessionIdToSend
: undefined
: undefined;
@ -148,13 +138,9 @@ export async function runCliAgent(params: {
prompt,
});
const stdinPayload = stdin ?? "";
const baseArgs = useResume
? (backend.resumeArgs ?? backend.args ?? [])
: (backend.args ?? []);
const baseArgs = useResume ? (backend.resumeArgs ?? backend.args ?? []) : (backend.args ?? []);
const resolvedArgs = useResume
? baseArgs.map((entry) =>
entry.replaceAll("{sessionId}", cliSessionIdToSend ?? ""),
)
? baseArgs.map((entry) => entry.replaceAll("{sessionId}", cliSessionIdToSend ?? ""))
: baseArgs;
const args = buildCliArgs({
backend,
@ -168,9 +154,7 @@ export async function runCliAgent(params: {
});
const serialize = backend.serialize ?? true;
const queueKey = serialize
? backendResolved.id
: `${backendResolved.id}:${params.runId}`;
const queueKey = serialize ? backendResolved.id : `${backendResolved.id}:${params.runId}`;
try {
const output = await enqueueCliRun(queueKey, async () => {
@ -184,10 +168,7 @@ export async function runCliAgent(params: {
const arg = args[i] ?? "";
if (arg === backend.systemPromptArg) {
const systemPromptValue = args[i + 1] ?? "";
logArgs.push(
arg,
`<systemPrompt:${systemPromptValue.length} chars>`,
);
logArgs.push(arg, `<systemPrompt:${systemPromptValue.length} chars>`);
i += 1;
continue;
}
@ -259,9 +240,7 @@ export async function runCliAgent(params: {
});
}
const outputMode = useResume
? (backend.resumeOutput ?? backend.output)
: backend.output;
const outputMode = useResume ? (backend.resumeOutput ?? backend.output) : backend.output;
if (outputMode === "text") {
return { text: stdout, sessionId: undefined };
@ -283,8 +262,7 @@ export async function runCliAgent(params: {
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId:
output.sessionId ?? sessionIdSent ?? params.sessionId ?? "",
sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId ?? "",
provider: params.provider,
model: modelId,
usage: output.usage,

View File

@ -29,9 +29,7 @@ export async function cleanupResumeProcesses(
const commandToken = path.basename(backend.command ?? "").trim();
if (!commandToken) return;
const resumeTokens = resumeArgs.map((arg) =>
arg.replaceAll("{sessionId}", sessionId),
);
const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId));
const pattern = [commandToken, ...resumeTokens]
.filter(Boolean)
.map((token) => escapeRegex(token))
@ -45,10 +43,7 @@ export async function cleanupResumeProcesses(
}
}
export function enqueueCliRun<T>(
key: string,
task: () => Promise<T>,
): Promise<T> {
export function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
const chained = prior.catch(() => undefined).then(task);
const tracked = chained.finally(() => {
@ -78,9 +73,7 @@ function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(
new Date(),
);
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
@ -106,14 +99,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (
!map.weekday ||
!map.year ||
!map.month ||
!map.day ||
!map.hour ||
!map.minute
)
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
return undefined;
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
@ -127,9 +113,7 @@ function buildModelAliasLines(cfg?: ClawdbotConfig) {
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
if (!model) continue;
const alias = String(
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
).trim();
const alias = String((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim();
if (!alias) continue;
entries.push({ alias, model });
}
@ -149,9 +133,7 @@ export function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
@ -175,10 +157,7 @@ export function buildSystemPrompt(params: {
});
}
export function normalizeCliModel(
modelId: string,
backend: CliBackendConfig,
): string {
export function normalizeCliModel(modelId: string, backend: CliBackendConfig): string {
const trimmed = modelId.trim();
if (!trimmed) return trimmed;
const direct = backend.modelAliases?.[trimmed];
@ -191,19 +170,14 @@ export function normalizeCliModel(
function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
const pick = (key: string) =>
typeof raw[key] === "number" && raw[key] > 0
? (raw[key] as number)
: undefined;
typeof raw[key] === "number" && raw[key] > 0 ? (raw[key] as number) : undefined;
const input = pick("input_tokens") ?? pick("inputTokens");
const output = pick("output_tokens") ?? pick("outputTokens");
const cacheRead =
pick("cache_read_input_tokens") ??
pick("cached_input_tokens") ??
pick("cacheRead");
pick("cache_read_input_tokens") ?? pick("cached_input_tokens") ?? pick("cacheRead");
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
const total = pick("total_tokens") ?? pick("total");
if (!input && !output && !cacheRead && !cacheWrite && !total)
return undefined;
if (!input && !output && !cacheRead && !cacheWrite && !total) return undefined;
return { input, output, cacheRead, cacheWrite, total };
}
@ -214,8 +188,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function collectText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value))
return value.map((entry) => collectText(entry)).join("");
if (Array.isArray(value)) return value.map((entry) => collectText(entry)).join("");
if (!isRecord(value)) return "";
if (typeof value.text === "string") return value.text;
if (typeof value.content === "string") return value.content;
@ -242,10 +215,7 @@ function pickSessionId(
return undefined;
}
export function parseCliJson(
raw: string,
backend: CliBackendConfig,
): CliOutput | null {
export function parseCliJson(raw: string, backend: CliBackendConfig): CliOutput | null {
const trimmed = raw.trim();
if (!trimmed) return null;
let parsed: unknown;
@ -265,10 +235,7 @@ export function parseCliJson(
return { text: text.trim(), sessionId, usage };
}
export function parseCliJsonl(
raw: string,
backend: CliBackendConfig,
): CliOutput | null {
export function parseCliJsonl(raw: string, backend: CliBackendConfig): CliOutput | null {
const lines = raw
.split(/\r?\n/g)
.map((line) => line.trim())
@ -331,18 +298,15 @@ export function resolveSessionIdToSend(params: {
return { sessionId: crypto.randomUUID(), isNew: true };
}
export function resolvePromptInput(params: {
backend: CliBackendConfig;
prompt: string;
}): { argsPrompt?: string; stdin?: string } {
export function resolvePromptInput(params: { backend: CliBackendConfig; prompt: string }): {
argsPrompt?: string;
stdin?: string;
} {
const inputMode = params.backend.input ?? "arg";
if (inputMode === "stdin") {
return { stdin: params.prompt };
}
if (
params.backend.maxPromptArgChars &&
params.prompt.length > params.backend.maxPromptArgChars
) {
if (params.backend.maxPromptArgChars && params.prompt.length > params.backend.maxPromptArgChars) {
return { stdin: params.prompt };
}
return { argsPrompt: params.prompt };
@ -357,10 +321,7 @@ function resolveImageExtension(mimeType: string): string {
return "bin";
}
export function appendImagePathsToPrompt(
prompt: string,
paths: string[],
): string {
export function appendImagePathsToPrompt(prompt: string, paths: string[]): string {
if (!paths.length) return prompt;
const trimmed = prompt.trimEnd();
const separator = trimmed ? "\n\n" : "";
@ -370,9 +331,7 @@ export function appendImagePathsToPrompt(
export async function writeCliImages(
images: ImageContent[],
): Promise<{ paths: string[]; cleanup: () => Promise<void> }> {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-cli-images-"),
);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cli-images-"));
const paths: string[] = [];
for (let i = 0; i < images.length; i += 1) {
const image = images[i];
@ -402,11 +361,7 @@ export function buildCliArgs(params: {
if (!params.useResume && params.backend.modelArg && params.modelId) {
args.push(params.backend.modelArg, params.modelId);
}
if (
!params.useResume &&
params.systemPrompt &&
params.backend.systemPromptArg
) {
if (!params.useResume && params.systemPrompt && params.backend.systemPromptArg) {
args.push(params.backend.systemPromptArg, params.systemPrompt);
}
if (!params.useResume && params.sessionId) {

View File

@ -16,11 +16,7 @@ export function getCliSessionId(
return undefined;
}
export function setCliSessionId(
entry: SessionEntry,
provider: string,
sessionId: string,
): void {
export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
const normalized = normalizeProviderId(provider);
const trimmed = sessionId.trim();
if (!trimmed) return;

View File

@ -3,11 +3,7 @@ import type { ClawdbotConfig } from "../config/config.js";
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;
export type ContextWindowSource =
| "model"
| "modelsConfig"
| "agentContextTokens"
| "default";
export type ContextWindowSource = "model" | "modelsConfig" | "agentContextTokens" | "default";
export type ContextWindowInfo = {
tokens: number;
@ -32,26 +28,17 @@ export function resolveContextWindowInfo(params: {
const fromModelsConfig = (() => {
const providers = params.cfg?.models?.providers as
| Record<
string,
{ models?: Array<{ id?: string; contextWindow?: number }> }
>
| Record<string, { models?: Array<{ id?: string; contextWindow?: number }> }>
| undefined;
const providerEntry = providers?.[params.provider];
const models = Array.isArray(providerEntry?.models)
? providerEntry.models
: [];
const models = Array.isArray(providerEntry?.models) ? providerEntry.models : [];
const match = models.find((m) => m?.id === params.modelId);
return normalizePositiveInt(match?.contextWindow);
})();
if (fromModelsConfig)
return { tokens: fromModelsConfig, source: "modelsConfig" };
if (fromModelsConfig) return { tokens: fromModelsConfig, source: "modelsConfig" };
const fromAgentConfig = normalizePositiveInt(
params.cfg?.agents?.defaults?.contextTokens,
);
if (fromAgentConfig)
return { tokens: fromAgentConfig, source: "agentContextTokens" };
const fromAgentConfig = normalizePositiveInt(params.cfg?.agents?.defaults?.contextTokens);
if (fromAgentConfig) return { tokens: fromAgentConfig, source: "agentContextTokens" };
return { tokens: Math.floor(params.defaultTokens), source: "default" };
}
@ -70,10 +57,7 @@ export function evaluateContextWindowGuard(params: {
1,
Math.floor(params.warnBelowTokens ?? CONTEXT_WINDOW_WARN_BELOW_TOKENS),
);
const hardMin = Math.max(
1,
Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS),
);
const hardMin = Math.max(1, Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS));
const tokens = Math.max(0, Math.floor(params.info.tokens));
return {
...params.info,

View File

@ -10,9 +10,7 @@ type ModelEntry = { id: string; contextWindow?: number };
const MODEL_CACHE = new Map<string, number>();
const loadPromise = (async () => {
try {
const { discoverAuthStorage, discoverModels } = await import(
"@mariozechner/pi-coding-agent"
);
const { discoverAuthStorage, discoverModels } = await import("@mariozechner/pi-coding-agent");
const cfg = loadConfig();
await ensureClawdbotModelsJson(cfg);
const agentDir = resolveClawdbotAgentDir();

View File

@ -8,9 +8,7 @@ import {
describe("failover-error", () => {
it("infers failover reason from HTTP status", () => {
expect(resolveFailoverReasonFromError({ status: 402 })).toBe("billing");
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe(
"rate_limit",
);
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit");
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
});
@ -24,12 +22,8 @@ describe("failover-error", () => {
});
it("infers timeout from common node error codes", () => {
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe(
"timeout",
);
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe(
"timeout",
);
expect(resolveFailoverReasonFromError({ code: "ETIMEDOUT" })).toBe("timeout");
expect(resolveFailoverReasonFromError({ code: "ECONNRESET" })).toBe("timeout");
});
it("coerces failover-worthy errors into FailoverError with metadata", () => {

View File

@ -1,7 +1,4 @@
import {
classifyFailoverReason,
type FailoverReason,
} from "./pi-embedded-helpers.js";
import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
export class FailoverError extends Error {
readonly reason: FailoverReason;
@ -38,9 +35,7 @@ export function isFailoverError(err: unknown): err is FailoverError {
return err instanceof FailoverError;
}
export function resolveFailoverStatus(
reason: FailoverReason,
): number | undefined {
export function resolveFailoverStatus(reason: FailoverReason): number | undefined {
switch (reason) {
case "billing":
return 402;
@ -80,11 +75,7 @@ function getErrorCode(err: unknown): string | undefined {
function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
if (
typeof err === "number" ||
typeof err === "boolean" ||
typeof err === "bigint"
) {
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
return String(err);
}
if (typeof err === "symbol") return err.description ?? "";
@ -95,9 +86,7 @@ function getErrorMessage(err: unknown): string {
return "";
}
export function resolveFailoverReasonFromError(
err: unknown,
): FailoverReason | null {
export function resolveFailoverReasonFromError(err: unknown): FailoverReason | null {
if (isFailoverError(err)) return err.reason;
const status = getStatusCode(err);
@ -107,11 +96,7 @@ export function resolveFailoverReasonFromError(
if (status === 408) return "timeout";
const code = (getErrorCode(err) ?? "").toUpperCase();
if (
["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(
code,
)
) {
if (["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(code)) {
return "timeout";
}

View File

@ -1,8 +1,4 @@
import type {
ClawdbotConfig,
HumanDelayConfig,
IdentityConfig,
} from "../config/config.js";
import type { ClawdbotConfig, HumanDelayConfig, IdentityConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
const DEFAULT_ACK_REACTION = "👀";
@ -14,10 +10,7 @@ export function resolveAgentIdentity(
return resolveAgentConfig(cfg, agentId)?.identity;
}
export function resolveAckReaction(
cfg: ClawdbotConfig,
agentId: string,
): string {
export function resolveAckReaction(cfg: ClawdbotConfig, agentId: string): string {
const configured = cfg.messages?.ackReaction;
if (configured !== undefined) return configured.trim();
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
@ -44,15 +37,10 @@ export function resolveMessagePrefix(
const hasAllowFrom = opts?.hasAllowFrom === true;
if (hasAllowFrom) return "";
return (
resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]"
);
return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]";
}
export function resolveResponsePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
export function resolveResponsePrefix(cfg: ClawdbotConfig, agentId: string): string | undefined {
const configured = cfg.messages?.responsePrefix;
if (configured !== undefined) {
if (configured === "auto") {

View File

@ -3,11 +3,7 @@ export type ModelRef = {
id?: string | null;
};
const ANTHROPIC_PREFIXES = [
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
];
const ANTHROPIC_PREFIXES = ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"];
const OPENAI_MODELS = ["gpt-5.2", "gpt-5.0"];
const CODEX_MODELS = [
"gpt-5.2",
@ -55,10 +51,7 @@ export function isModernModelRef(ref: ModelRef): boolean {
}
if (provider === "google-antigravity") {
return (
matchesPrefix(id, GOOGLE_PREFIXES) ||
matchesPrefix(id, ANTHROPIC_PREFIXES)
);
return matchesPrefix(id, GOOGLE_PREFIXES) || matchesPrefix(id, ANTHROPIC_PREFIXES);
}
if (provider === "zai") {

View File

@ -52,9 +52,7 @@ function resolveStorePath(agentId: string, raw?: string): string {
const stateDir = resolveStateDir(process.env, os.homedir);
const fallback = path.join(stateDir, "memory", `${agentId}.sqlite`);
if (!raw) return fallback;
const withToken = raw.includes("{agentId}")
? raw.replaceAll("{agentId}", agentId)
: raw;
const withToken = raw.includes("{agentId}") ? raw.replaceAll("{agentId}", agentId) : raw;
return resolveUserPath(withToken);
}
@ -77,47 +75,29 @@ function mergeConfig(
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
const local = {
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
modelCacheDir:
overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
};
const store = {
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
path: resolveStorePath(
agentId,
overrides?.store?.path ?? defaults?.store?.path,
),
path: resolveStorePath(agentId, overrides?.store?.path ?? defaults?.store?.path),
};
const chunking = {
tokens:
overrides?.chunking?.tokens ??
defaults?.chunking?.tokens ??
DEFAULT_CHUNK_TOKENS,
overlap:
overrides?.chunking?.overlap ??
defaults?.chunking?.overlap ??
DEFAULT_CHUNK_OVERLAP,
tokens: overrides?.chunking?.tokens ?? defaults?.chunking?.tokens ?? DEFAULT_CHUNK_TOKENS,
overlap: overrides?.chunking?.overlap ?? defaults?.chunking?.overlap ?? DEFAULT_CHUNK_OVERLAP,
};
const sync = {
onSessionStart:
overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
onSessionStart: overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true,
watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true,
watchDebounceMs:
overrides?.sync?.watchDebounceMs ??
defaults?.sync?.watchDebounceMs ??
DEFAULT_WATCH_DEBOUNCE_MS,
intervalMinutes:
overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
intervalMinutes: overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
};
const query = {
maxResults:
overrides?.query?.maxResults ??
defaults?.query?.maxResults ??
DEFAULT_MAX_RESULTS,
minScore:
overrides?.query?.minScore ??
defaults?.query?.minScore ??
DEFAULT_MIN_SCORE,
maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS,
minScore: overrides?.query?.minScore ?? defaults?.query?.minScore ?? DEFAULT_MIN_SCORE,
};
const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));

View File

@ -51,9 +51,7 @@ export async function minimaxUnderstandImage(params: {
const imageDataUrl = params.imageDataUrl.trim();
if (!imageDataUrl) throw new Error("MiniMax VLM: imageDataUrl required");
if (!/^data:image\/(png|jpeg|webp);base64,/i.test(imageDataUrl)) {
throw new Error(
"MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL",
);
throw new Error("MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL");
}
const host = coerceApiHost({
@ -92,17 +90,12 @@ export async function minimaxUnderstandImage(params: {
throw new Error(`MiniMax VLM response was not JSON.${trace}`);
}
const baseResp = isRecord(json.base_resp)
? (json.base_resp as MinimaxBaseResp)
: {};
const code =
typeof baseResp.status_code === "number" ? baseResp.status_code : -1;
const baseResp = isRecord(json.base_resp) ? (json.base_resp as MinimaxBaseResp) : {};
const code = typeof baseResp.status_code === "number" ? baseResp.status_code : -1;
if (code !== 0) {
const msg = (baseResp.status_msg ?? "").trim();
const trace = traceId ? ` Trace-Id: ${traceId}` : "";
throw new Error(
`MiniMax VLM API error (${code})${msg ? `: ${msg}` : ""}.${trace}`,
);
throw new Error(`MiniMax VLM API error (${code})${msg ? `: ${msg}` : ""}.${trace}`);
}
const content = pickString(json, "content").trim();

View File

@ -2,8 +2,7 @@ import { completeSimple, type Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";

View File

@ -107,11 +107,7 @@ describe("getApiKeyForModel", () => {
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilesPath = path.join(
tempDir,
"agent",
"auth-profiles.json",
);
const authProfilesPath = path.join(tempDir, "agent", "auth-profiles.json");
await fs.mkdir(path.dirname(authProfilesPath), {
recursive: true,
mode: 0o700,

View File

@ -11,10 +11,7 @@ import {
} from "./auth-profiles.js";
import { normalizeProviderId } from "./model-selection.js";
export {
ensureAuthProfileStore,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
export function getCustomProviderApiKey(
cfg: ClawdbotConfig | undefined,
@ -109,16 +106,12 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = process.env[envVar]?.trim();
if (!value) return null;
const source = applied.has(envVar)
? `shell env: ${envVar}`
: `env: ${envVar}`;
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
return { apiKey: value, source };
};
if (normalized === "github-copilot") {
return (
pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN")
);
return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN");
}
if (normalized === "anthropic") {

View File

@ -58,8 +58,7 @@ export async function loadModelCatalog(params?: {
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
? entry.contextWindow
: undefined;
const reasoning =
typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
models.push({ id, name, provider, contextWindow, reasoning });
}

View File

@ -20,10 +20,7 @@ function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
describe("runWithModelFallback", () => {
it("does not fall back on non-auth errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("bad request"))
.mockResolvedValueOnce("ok");
const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok");
await expect(
runWithModelFallback({
@ -60,9 +57,7 @@ describe("runWithModelFallback", () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("payment required"), { status: 402 }),
)
.mockRejectedValueOnce(Object.assign(new Error("payment required"), { status: 402 }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
@ -106,9 +101,7 @@ describe("runWithModelFallback", () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
new Error('No credentials found for profile "anthropic:claude-cli".'),
)
.mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:claude-cli".'))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
@ -136,9 +129,7 @@ describe("runWithModelFallback", () => {
});
const run = vi
.fn()
.mockImplementation(() =>
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
);
.mockImplementation(() => Promise.reject(Object.assign(new Error("nope"), { status: 401 })));
await expect(
runWithModelFallback({
@ -219,9 +210,7 @@ describe("runWithModelFallback", () => {
}),
).rejects.toThrow("primary failed");
expect(calls).toEqual([
{ provider: "anthropic", model: "claude-opus-4-5" },
]);
expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]);
});
it("falls back on missing API key errors", async () => {
@ -277,9 +266,7 @@ describe("runWithModelFallback", () => {
});
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
)
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({

View File

@ -1,10 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
coerceToFailoverError,
describeFailoverError,
isFailoverError,
} from "./failover-error.js";
import { coerceToFailoverError, describeFailoverError, isFailoverError } from "./failover-error.js";
import {
buildModelAliasIndex,
modelKey,
@ -33,9 +29,7 @@ function isAbortError(err: unknown): boolean {
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message =
"message" in err && typeof err.message === "string"
? err.message.toLowerCase()
: "";
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
return message.includes("aborted");
}
@ -70,10 +64,7 @@ function resolveImageFallbackCandidates(params: {
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (
candidate: ModelCandidate,
enforceAllowlist: boolean,
) => {
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
@ -99,8 +90,7 @@ function resolveImageFallbackCandidates(params: {
| { primary?: string }
| string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
const primary = typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
if (primary?.trim()) addRaw(primary, false);
}
@ -146,10 +136,7 @@ function resolveFallbackCandidates(params: {
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (
candidate: ModelCandidate,
enforceAllowlist: boolean,
) => {
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
@ -180,11 +167,7 @@ function resolveFallbackCandidates(params: {
addCandidate(resolved.ref, true);
}
if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
if (params.fallbacksOverride === undefined && primary?.provider && primary.model) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
@ -271,10 +254,9 @@ export async function runWithModelFallback<T>(params: {
)
.join(" | ")
: "unknown";
throw new Error(
`All models failed (${attempts.length || candidates.length}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
throw new Error(`All models failed (${attempts.length || candidates.length}): ${summary}`, {
cause: lastError instanceof Error ? lastError : undefined,
});
}
export async function runWithImageModelFallback<T>(params: {
@ -340,14 +322,10 @@ export async function runWithImageModelFallback<T>(params: {
const summary =
attempts.length > 0
? attempts
.map(
(attempt) =>
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
)
.map((attempt) => `${attempt.provider}/${attempt.model}: ${attempt.error}`)
.join(" | ")
: "unknown";
throw new Error(
`All image models failed (${attempts.length || candidates.length}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
throw new Error(`All image models failed (${attempts.length || candidates.length}): ${summary}`, {
cause: lastError instanceof Error ? lastError : undefined,
});
}

View File

@ -79,11 +79,7 @@ export type OpenRouterScanOptions = {
maxAgeDays?: number;
providerFilter?: string;
probe?: boolean;
onProgress?: (update: {
phase: "catalog" | "probe";
completed: number;
total: number;
}) => void;
onProgress?: (update: { phase: "catalog" | "probe"; completed: number; total: number }) => void;
};
type OpenAIModel = Model<"openai-completions">;
@ -97,9 +93,7 @@ function normalizeCreatedAtMs(value: unknown): number | null {
function inferParamBFromIdOrName(text: string): number | null {
const raw = text.toLowerCase();
const matches = raw.matchAll(
/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g,
);
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
let best: number | null = null;
for (const match of matches) {
const numRaw = match[1];
@ -169,9 +163,7 @@ async function withTimeout<T>(
}
}
async function fetchOpenRouterModels(
fetchImpl: typeof fetch,
): Promise<OpenRouterModelMeta[]> {
async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise<OpenRouterModelMeta[]> {
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
});
@ -187,21 +179,17 @@ async function fetchOpenRouterModels(
const obj = entry as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
if (!id) return null;
const name =
typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const contextLength =
typeof obj.context_length === "number" &&
Number.isFinite(obj.context_length)
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
? obj.context_length
: null;
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" &&
Number.isFinite(obj.max_completion_tokens)
typeof obj.max_completion_tokens === "number" && Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" &&
Number.isFinite(obj.max_output_tokens)
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
: null;
@ -216,9 +204,7 @@ async function fetchOpenRouterModels(
const supportsToolsMeta = supportedParameters.includes("tools");
const modality =
typeof obj.modality === "string" && obj.modality.trim()
? obj.modality.trim()
: null;
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
@ -268,9 +254,7 @@ async function probeTool(
} satisfies OpenAICompletionsOptions),
);
const hasToolCall = message.content.some(
(block) => block.type === "toolCall",
);
const hasToolCall = message.content.some((block) => block.type === "toolCall");
if (!hasToolCall) {
return {
ok: false,
@ -361,9 +345,7 @@ async function mapWithConcurrency<T, R>(
return results;
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
);
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
return results;
}
@ -374,19 +356,11 @@ export async function scanOpenRouterModels(
const probe = options.probe ?? true;
const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
if (probe && !apiKey) {
throw new Error(
"Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.",
);
throw new Error("Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.");
}
const timeoutMs = Math.max(
1,
Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
);
const concurrency = Math.max(
1,
Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY),
);
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
const concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY));
const minParamB = Math.max(0, Math.floor(options.minParamB ?? 0));
const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0));
const providerFilter = options.providerFilter?.trim().toLowerCase() ?? "";

View File

@ -39,9 +39,7 @@ describe("buildAllowedModelSet", () => {
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
});
it("includes the default model when no allowlist is set", () => {
@ -58,9 +56,7 @@ describe("buildAllowedModelSet", () => {
expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
true,
);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
});
it("allows explicit custom providers from models.providers", () => {
@ -93,9 +89,7 @@ describe("buildAllowedModelSet", () => {
});
expect(allowed.allowAny).toBe(false);
expect(
allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview")),
).toBe(true);
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
});
});

View File

@ -7,13 +7,7 @@ export type ModelRef = {
model: string;
};
export type ThinkLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh";
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type ModelAliasIndex = {
byAlias: Map<string, { alias: string; ref: ModelRef }>;
@ -40,9 +34,7 @@ export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
if (normalized === "claude-cli") return true;
if (normalized === "codex-cli") return true;
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
return Object.keys(backends).some(
(key) => normalizeProviderId(key) === normalized,
);
return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized);
}
function normalizeAnthropicModelId(model: string): string {
@ -60,10 +52,7 @@ function normalizeProviderModelId(provider: string, model: string): string {
return model;
}
export function parseModelRef(
raw: string,
defaultProvider: string,
): ModelRef | null {
export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const slash = trimmed.indexOf("/");
@ -91,9 +80,7 @@ export function buildModelAliasIndex(params: {
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
if (!parsed) continue;
const alias = String(
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
).trim();
const alias = String((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim();
if (!alias) continue;
const aliasKey = normalizeAliasKey(alias);
byAlias.set(aliasKey, { alias, ref: parsed });
@ -131,10 +118,7 @@ export function resolveConfiguredModelRef(params: {
defaultModel: string;
}): ModelRef {
const rawModel = (() => {
const raw = params.cfg.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
const raw = params.cfg.agents?.defaults?.model as { primary?: string } | string | undefined;
if (typeof raw === "string") return raw.trim();
return raw?.primary?.trim() ?? "";
})();
@ -176,9 +160,7 @@ export function buildAllowedModelSet(params: {
defaultModel && params.defaultProvider
? modelKey(params.defaultProvider, defaultModel)
: undefined;
const catalogKeys = new Set(
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),
);
const catalogKeys = new Set(params.catalog.map((entry) => modelKey(entry.provider, entry.id)));
if (allowAny) {
if (defaultKey) catalogKeys.add(defaultKey);
@ -190,10 +172,7 @@ export function buildAllowedModelSet(params: {
}
const allowedKeys = new Set<string>();
const configuredProviders = (params.cfg.models?.providers ?? {}) as Record<
string,
unknown
>;
const configuredProviders = (params.cfg.models?.providers ?? {}) as Record<string, unknown>;
for (const raw of rawAllowlist) {
const parsed = parseModelRef(String(raw), params.defaultProvider);
if (!parsed) continue;
@ -253,9 +232,7 @@ export function getModelRefStatus(params: {
const key = modelKey(params.ref.provider, params.ref.model);
return {
key,
inCatalog: params.catalog.some(
(entry) => modelKey(entry.provider, entry.id) === key,
),
inCatalog: params.catalog.some((entry) => modelKey(entry.provider, entry.id) === key),
allowAny: allowed.allowAny,
allowed: allowed.allowAny || allowed.allowedKeys.has(key),
};

View File

@ -52,8 +52,7 @@ describe("models-config", () => {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
@ -67,17 +66,12 @@ describe("models-config", () => {
const agentDir = path.join(home, "agent-default-base-url");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
const raw = await fs.readFile(
path.join(agentDir, "models.json"),
"utf8",
);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://api.copilot.example",
);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
@ -104,8 +98,7 @@ describe("models-config", () => {
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));

View File

@ -62,17 +62,12 @@ describe("models-config", () => {
await ensureClawdbotModelsJson({ models: { providers: {} } });
const agentDir = resolveClawdbotAgentDir();
const raw = await fs.readFile(
path.join(agentDir, "models.json"),
"utf8",
);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://api.default.test",
);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
@ -111,8 +106,7 @@ describe("models-config", () => {
);
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
@ -125,17 +119,12 @@ describe("models-config", () => {
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
const raw = await fs.readFile(
path.join(agentDir, "models.json"),
"utf8",
);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://api.copilot.example",
);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;

View File

@ -79,10 +79,7 @@ describe("models-config", () => {
const modelPath = path.join(resolveClawdbotAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<
string,
{ apiKey?: string; models?: Array<{ id: string }> }
>;
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
};
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
@ -138,12 +135,8 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers.existing?.baseUrl).toBe(
"http://localhost:1234/v1",
);
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe(
"http://localhost:4000/v1",
);
expect(parsed.providers.existing?.baseUrl).toBe("http://localhost:1234/v1");
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
});
});
});

View File

@ -3,10 +3,7 @@ import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "./auth-profiles.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveEnvApiKey } from "./model-auth.js";
import {
buildSyntheticModelDefinition,
@ -104,8 +101,7 @@ export function normalizeProviders(params: {
// Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR".
if (
normalizedProvider.apiKey &&
normalizeApiKeyConfig(normalizedProvider.apiKey) !==
normalizedProvider.apiKey
normalizeApiKeyConfig(normalizedProvider.apiKey) !== normalizedProvider.apiKey
) {
mutated = true;
normalizedProvider = {
@ -117,8 +113,7 @@ export function normalizeProviders(params: {
// If a provider defines models, pi's ModelRegistry requires apiKey to be set.
// Fill it from the environment or auth profiles when possible.
const hasModels =
Array.isArray(normalizedProvider.models) &&
normalizedProvider.models.length > 0;
Array.isArray(normalizedProvider.models) && normalizedProvider.models.length > 0;
if (hasModels && !normalizedProvider.apiKey?.trim()) {
const fromEnv = resolveEnvApiKeyVarName(normalizedKey);
const fromProfiles = resolveApiKeyFromProfiles({
@ -197,9 +192,7 @@ function buildSyntheticProvider(): ProviderConfig {
};
}
export function resolveImplicitProviders(params: {
agentDir: string;
}): ModelsConfig["providers"] {
export function resolveImplicitProviders(params: { agentDir: string }): ModelsConfig["providers"] {
const providers: Record<string, ProviderConfig> = {};
const authStore = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
@ -235,8 +228,7 @@ export async function resolveImplicitCopilotProvider(params: {
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir);
const hasProfile =
listProfilesForProvider(authStore, "github-copilot").length > 0;
const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0;
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
const githubToken = (envToken ?? "").trim();

View File

@ -70,9 +70,7 @@ describe("models-config", () => {
agentDir,
);
await expect(
fs.stat(path.join(agentDir, "models.json")),
).rejects.toThrow();
await expect(fs.stat(path.join(agentDir, "models.json"))).rejects.toThrow();
expect(result.wrote).toBe(false);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
@ -85,8 +83,7 @@ describe("models-config", () => {
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined)
delete process.env.SYNTHETIC_API_KEY;
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
}
});
@ -105,9 +102,7 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe(
"http://localhost:4000/v1",
);
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
});
});
it("adds minimax provider when MINIMAX_API_KEY is set", async () => {
@ -133,9 +128,7 @@ describe("models-config", () => {
}
>;
};
expect(parsed.providers.minimax?.baseUrl).toBe(
"https://api.minimax.io/anthropic",
);
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1");
@ -169,13 +162,9 @@ describe("models-config", () => {
}
>;
};
expect(parsed.providers.synthetic?.baseUrl).toBe(
"https://api.synthetic.new/anthropic",
);
expect(parsed.providers.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
const ids = parsed.providers.synthetic?.models?.map(
(model) => model.id,
);
const ids = parsed.providers.synthetic?.models?.map((model) => model.id);
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
} finally {
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY;

View File

@ -18,10 +18,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function mergeProviderModels(
implicit: ProviderConfig,
explicit: ProviderConfig,
): ProviderConfig {
function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
if (implicitModels.length === 0) return { ...implicit, ...explicit };
@ -55,16 +52,12 @@ function mergeProviders(params: {
implicit?: Record<string, ProviderConfig> | null;
explicit?: Record<string, ProviderConfig> | null;
}): Record<string, ProviderConfig> {
const out: Record<string, ProviderConfig> = params.implicit
? { ...params.implicit }
: {};
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
const providerKey = key.trim();
if (!providerKey) continue;
const implicit = out[providerKey];
out[providerKey] = implicit
? mergeProviderModels(implicit, explicit)
: explicit;
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
}
return out;
}
@ -83,14 +76,9 @@ export async function ensureClawdbotModelsJson(
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
const cfg = config ?? loadConfig();
const agentDir = agentDirOverride?.trim()
? agentDirOverride.trim()
: resolveClawdbotAgentDir();
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir();
const explicitProviders = (cfg.models?.providers ?? {}) as Record<
string,
ProviderConfig
>;
const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
const implicitProviders = resolveImplicitProviders({ agentDir });
const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders,

View File

@ -88,8 +88,7 @@ describe("models-config", () => {
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
@ -119,8 +118,7 @@ describe("models-config", () => {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL:
"https://api.individual.githubcopilot.com",
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
@ -145,17 +143,12 @@ describe("models-config", () => {
});
const agentDir = resolveClawdbotAgentDir();
const raw = await fs.readFile(
path.join(agentDir, "models.json"),
"utf8",
);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot.local",
);
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://copilot.local");
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}

View File

@ -1,8 +1,5 @@
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../config/config.js";
@ -19,8 +16,7 @@ import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
const REQUIRE_PROFILE_KEYS =
process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
const REQUIRE_PROFILE_KEYS = process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
const describeLive = LIVE ? describe : describe.skip;
@ -62,8 +58,7 @@ function isModelNotFoundErrorMessage(raw: string): boolean {
if (!msg) return false;
if (/\b404\b/.test(msg) && /not[_-]?found/i.test(msg)) return true;
if (/not_found_error/i.test(msg)) return true;
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg))
return true;
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg)) return true;
return false;
}
@ -156,9 +151,7 @@ describeLive("live models (profile keys)", () => {
const anthropicKeys = collectAnthropicApiKeys();
if (anthropicKeys.length > 0) {
process.env.ANTHROPIC_API_KEY = anthropicKeys[0];
logProgress(
`[live-models] anthropic keys loaded: ${anthropicKeys.length}`,
);
logProgress(`[live-models] anthropic keys loaded: ${anthropicKeys.length}`);
}
const agentDir = resolveClawdbotAgentDir();
@ -171,13 +164,8 @@ describeLive("live models (profile keys)", () => {
const useExplicit = Boolean(rawModels) && !useModern;
const filter = useExplicit ? parseModelFilter(rawModels) : null;
const allowNotFoundSkip = useModern;
const providers = parseProviderFilter(
process.env.CLAWDBOT_LIVE_PROVIDERS,
);
const perModelTimeoutMs = toInt(
process.env.CLAWDBOT_LIVE_MODEL_TIMEOUT_MS,
30_000,
);
const providers = parseProviderFilter(process.env.CLAWDBOT_LIVE_PROVIDERS);
const perModelTimeoutMs = toInt(process.env.CLAWDBOT_LIVE_MODEL_TIMEOUT_MS, 30_000);
const failures: Array<{ model: string; error: string }> = [];
const skipped: Array<{ model: string; reason: string }> = [];
@ -197,10 +185,7 @@ describeLive("live models (profile keys)", () => {
}
try {
const apiKeyInfo = await getApiKeyForModel({ model, cfg });
if (
REQUIRE_PROFILE_KEYS &&
!apiKeyInfo.source.startsWith("profile:")
) {
if (REQUIRE_PROFILE_KEYS && !apiKeyInfo.source.startsWith("profile:")) {
skipped.push({
model: id,
reason: `non-profile credential source: ${apiKeyInfo.source}`,
@ -218,9 +203,7 @@ describeLive("live models (profile keys)", () => {
return;
}
logProgress(
`[live-models] selection=${useExplicit ? "explicit" : "modern"}`,
);
logProgress(`[live-models] selection=${useExplicit ? "explicit" : "modern"}`);
logProgress(`[live-models] running ${candidates.length} models`);
const total = candidates.length;
@ -229,9 +212,7 @@ describeLive("live models (profile keys)", () => {
const id = `${model.provider}/${model.id}`;
const progressLabel = `[live-models] ${index + 1}/${total} ${id}`;
const attemptMax =
model.provider === "anthropic" && anthropicKeys.length > 0
? anthropicKeys.length
: 1;
model.provider === "anthropic" && anthropicKeys.length > 0 ? anthropicKeys.length : 1;
for (let attempt = 0; attempt < attemptMax; attempt += 1) {
if (model.provider === "anthropic" && anthropicKeys.length > 0) {
process.env.ANTHROPIC_API_KEY = anthropicKeys[attempt];
@ -254,8 +235,7 @@ describeLive("live models (profile keys)", () => {
parameters: Type.Object({}, { additionalProperties: false }),
};
let firstUserContent =
"Call the tool `noop` with {}. Do not write any other text.";
let firstUserContent = "Call the tool `noop` with {}. Do not write any other text.";
let firstUser = {
role: "user" as const,
content: firstUserContent,
@ -282,11 +262,7 @@ describeLive("live models (profile keys)", () => {
// Occasional flake: model answers in text instead of tool call (or adds text).
// Retry a couple times with a stronger instruction so we still exercise the tool-only replay path.
for (
let i = 0;
i < 2 && (!toolCall || firstText.length > 0);
i += 1
) {
for (let i = 0; i < 2 && (!toolCall || firstText.length > 0); i += 1) {
firstUserContent =
"Call the tool `noop` with {}. IMPORTANT: respond ONLY with the tool call; no other text.";
firstUser = {
@ -405,29 +381,19 @@ describeLive("live models (profile keys)", () => {
isAnthropicRateLimitError(message) &&
attempt + 1 < attemptMax
) {
logProgress(
`${progressLabel}: rate limit, retrying with next key`,
);
logProgress(`${progressLabel}: rate limit, retrying with next key`);
continue;
}
if (
model.provider === "anthropic" &&
isAnthropicBillingError(message)
) {
if (model.provider === "anthropic" && isAnthropicBillingError(message)) {
if (attempt + 1 < attemptMax) {
logProgress(
`${progressLabel}: billing issue, retrying with next key`,
);
logProgress(`${progressLabel}: billing issue, retrying with next key`);
continue;
}
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (anthropic billing)`);
break;
}
if (
model.provider === "google" &&
isGoogleModelNotFoundError(err)
) {
if (model.provider === "google" && isGoogleModelNotFoundError(err)) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (google model not found)`);
break;
@ -462,9 +428,7 @@ describeLive("live models (profile keys)", () => {
.slice(0, 10)
.map((f) => `- ${f.model}: ${f.error}`)
.join("\n");
throw new Error(
`live model failures (${failures.length}):\n${preview}`,
);
throw new Error(`live model failures (${failures.length}):\n${preview}`);
}
void skipped;

View File

@ -1,8 +1,4 @@
import type {
AssistantMessage,
Model,
ToolResultMessage,
} from "@mariozechner/pi-ai";
import type { AssistantMessage, Model, ToolResultMessage } from "@mariozechner/pi-ai";
import { streamOpenAIResponses } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
@ -31,8 +27,7 @@ function installFailingFetchCapture() {
const bodyText = (() => {
if (!rawBody) return "";
if (typeof rawBody === "string") return rawBody;
if (rawBody instanceof Uint8Array)
return Buffer.from(rawBody).toString("utf8");
if (rawBody instanceof Uint8Array) return Buffer.from(rawBody).toString("utf8");
if (rawBody instanceof ArrayBuffer)
return Buffer.from(new Uint8Array(rawBody)).toString("utf8");
return String(rawBody);
@ -135,17 +130,13 @@ describe("openai-responses reasoning replay", () => {
const input = Array.isArray(body?.input) ? body?.input : [];
const types = input
.map((item) =>
item && typeof item === "object"
? (item as Record<string, unknown>).type
: undefined,
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
expect(types).toContain("reasoning");
expect(types).toContain("function_call");
expect(types.indexOf("reasoning")).toBeLessThan(
types.indexOf("function_call"),
);
expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call"));
} finally {
cap.restore();
}
@ -204,9 +195,7 @@ describe("openai-responses reasoning replay", () => {
const input = Array.isArray(body?.input) ? body?.input : [];
const types = input
.map((item) =>
item && typeof item === "object"
? (item as Record<string, unknown>).type
: undefined,
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");

View File

@ -29,9 +29,7 @@ describe("resolveOpencodeZenAlias", () => {
});
it("returns input if no alias exists", () => {
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe(
"some-unknown-model",
);
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe("some-unknown-model");
});
it("is case-insensitive", () => {
@ -42,22 +40,12 @@ describe("resolveOpencodeZenAlias", () => {
describe("resolveOpencodeZenModelApi", () => {
it("maps APIs by model family", () => {
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe(
"anthropic-messages",
);
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe(
"anthropic-messages",
);
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe(
"google-generative-ai",
);
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe(
"openai-completions",
);
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe(
"openai-completions",
);
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});
});

View File

@ -91,11 +91,7 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
*/
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase();
if (
lower.startsWith("claude-") ||
lower.startsWith("minimax") ||
lower.startsWith("alpha-gd4")
) {
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
return "anthropic-messages";
}
if (lower.startsWith("gemini-")) {
@ -274,9 +270,7 @@ interface ZenModelsResponse {
* @param apiKey - OpenCode Zen API key for authentication
* @returns Array of model definitions, or static fallback on failure
*/
export async function fetchOpencodeZenModels(
apiKey?: string,
): Promise<ModelDefinitionConfig[]> {
export async function fetchOpencodeZenModels(apiKey?: string): Promise<ModelDefinitionConfig[]> {
// Return cached models if still valid
const now = Date.now();
if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
@ -298,9 +292,7 @@ export async function fetchOpencodeZenModels(
});
if (!response.ok) {
throw new Error(
`API returned ${response.status}: ${response.statusText}`,
);
throw new Error(`API returned ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as ZenModelsResponse;
@ -316,9 +308,7 @@ export async function fetchOpencodeZenModels(
return models;
} catch (error) {
console.warn(
`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`,
);
console.warn(`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`);
return getOpencodeZenStaticFallbackModels();
}
}

View File

@ -1,8 +1,4 @@
import {
findFenceSpanAt,
isSafeFenceBreak,
parseFenceSpans,
} from "../markdown/fences.js";
import { findFenceSpanAt, isSafeFenceBreak, parseFenceSpans } from "../markdown/fences.js";
export type BlockReplyChunking = {
minChars: number;
@ -61,10 +57,7 @@ export class EmbeddedBlockChunker {
return;
}
while (
this.#buffer.length >= minChars ||
(force && this.#buffer.length > 0)
) {
while (this.#buffer.length >= minChars || (force && this.#buffer.length > 0)) {
const breakResult =
force && this.#buffer.length <= maxChars
? this.#pickSoftBreakIndex(this.#buffer, 1)
@ -80,9 +73,7 @@ export class EmbeddedBlockChunker {
const breakIdx = breakResult.index;
let rawChunk = this.#buffer.slice(0, breakIdx);
if (rawChunk.trim().length === 0) {
this.#buffer = stripLeadingNewlines(
this.#buffer.slice(breakIdx),
).trimStart();
this.#buffer = stripLeadingNewlines(this.#buffer.slice(breakIdx)).trimStart();
continue;
}
@ -118,10 +109,7 @@ export class EmbeddedBlockChunker {
}
#pickSoftBreakIndex(buffer: string, minCharsOverride?: number): BreakResult {
const minChars = Math.max(
1,
Math.floor(minCharsOverride ?? this.#chunking.minChars),
);
const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars));
if (buffer.length < minChars) return { index: -1 };
const fenceSpans = parseFenceSpans(buffer);
const preference = this.#chunking.breakPreference ?? "paragraph";
@ -144,10 +132,7 @@ export class EmbeddedBlockChunker {
if (preference === "paragraph" || preference === "newline") {
let newlineIdx = buffer.indexOf("\n");
while (newlineIdx !== -1) {
if (
newlineIdx >= minChars &&
isSafeFenceBreak(fenceSpans, newlineIdx)
) {
if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, newlineIdx)) {
return { index: newlineIdx };
}
newlineIdx = buffer.indexOf("\n", newlineIdx + 1);
@ -172,10 +157,7 @@ export class EmbeddedBlockChunker {
}
#pickBreakIndex(buffer: string, minCharsOverride?: number): BreakResult {
const minChars = Math.max(
1,
Math.floor(minCharsOverride ?? this.#chunking.minChars),
);
const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars));
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
if (buffer.length < minChars) return { index: -1 };
const window = buffer.slice(0, Math.min(maxChars, buffer.length));

View File

@ -1,13 +1,8 @@
import { describe, expect, it } from "vitest";
import {
buildBootstrapContextFiles,
DEFAULT_BOOTSTRAP_MAX_CHARS,
} from "./pi-embedded-helpers.js";
import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
@ -40,9 +35,7 @@ describe("buildBootstrapContextFiles", () => {
maxChars,
warn: (message) => warnings.push(message),
});
expect(result?.content).toContain(
"[...truncated, read TOOLS.md for full content...]",
);
expect(result?.content).toContain("[...truncated, read TOOLS.md for full content...]");
expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
@ -55,8 +48,6 @@ describe("buildBootstrapContextFiles", () => {
const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files);
expect(result?.content).toBe(long);
expect(result?.content).not.toContain(
"[...truncated, read AGENTS.md for full content...]",
);
expect(result?.content).not.toContain("[...truncated, read AGENTS.md for full content...]");
});
});

View File

@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { classifyFailoverReason } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
@ -17,9 +15,7 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("no credentials found")).toBe("auth");
expect(classifyFailoverReason("no api key found")).toBe("auth");
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
expect(classifyFailoverReason("resource has been exhausted")).toBe(
"rate_limit",
);
expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit");
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
@ -28,16 +24,12 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe(
"format",
);
expect(classifyFailoverReason("string should match pattern")).toBe("format");
expect(classifyFailoverReason("bad request")).toBeNull();
});
it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(
classifyFailoverReason(
"You have hit your ChatGPT usage limit (plus plan)",
),
).toBe("rate_limit");
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
"rate_limit",
);
});
});

View File

@ -3,9 +3,7 @@ import { describe, expect, it } from "vitest";
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
@ -24,12 +22,8 @@ describe("formatAssistantErrorText", () => {
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns a friendly message for Anthropic role ordering", () => {
const msg = makeAssistantError(
'messages: roles must alternate between "user" and "assistant"',
);
expect(formatAssistantErrorText(msg)).toContain(
"Message ordering conflict",
);
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
});
it("returns a friendly message for Anthropic overload errors", () => {
const msg = makeAssistantError(

View File

@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { isAuthErrorMessage } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",

View File

@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { isBillingErrorMessage } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",

View File

@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { isCloudCodeAssistFormatError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",

View File

@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { isCompactionFailureError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
@ -23,9 +21,7 @@ describe("isCompactionFailureError", () => {
}
});
it("ignores non-compaction overflow errors", () => {
expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(
false,
);
expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false);
expect(isCompactionFailureError("rate limit exceeded")).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More