chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
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 - runtime: node
task: protocol task: protocol
command: pnpm protocol:check command: pnpm protocol:check
- runtime: bun - runtime: node
task: lint task: format
command: bunx biome check src command: pnpm format
- runtime: bun - runtime: bun
task: test task: test
command: bunx vitest run 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`. - Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs. - Node remains supported for running built output (`dist/*`) and production installs.
- Type-check/build: `pnpm build` (tsc) - 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` - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`. - 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. - 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`. - 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. - 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:restart": "bash scripts/restart-mac.sh",
"mac:package": "bash scripts/package-mac-app.sh", "mac:package": "bash scripts/package-mac-app.sh",
"mac:open": "open dist/Clawdbot.app", "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:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:all": "pnpm lint && pnpm lint:swift", "lint:all": "pnpm lint && pnpm lint:swift",
"lint:fix": "biome check --write --unsafe src && biome format --write src", "lint:fix": "pnpm format:fix && oxlint --type-aware --fix src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
"format": "biome format src", "format": "oxfmt --check src test",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources", "format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:all": "pnpm format && pnpm format:swift", "format:all": "pnpm format && pnpm format:swift",
"format:fix": "biome format src --write", "format:fix": "oxfmt --write src test",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "pnpm --dir ui test", "test:ui": "pnpm --dir ui test",
@@ -176,7 +176,6 @@
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.11",
"@grammyjs/types": "^3.23.0", "@grammyjs/types": "^3.23.0",
"@lit-labs/signals": "^0.2.0", "@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6", "@lit/context": "^1.1.6",
@@ -194,7 +193,8 @@
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.562.0", "lucide": "^0.562.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"oxlint": "^1.38.0", "oxfmt": "0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.0", "oxlint-tsgolint": "^0.11.0",
"quicktype-core": "^23.2.6", "quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59", "rolldown": "1.0.0-beta.59",

184
pnpm-lock.yaml generated
View File

@@ -149,9 +149,6 @@ importers:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
devDependencies: devDependencies:
'@biomejs/biome':
specifier: ^2.3.11
version: 2.3.11
'@grammyjs/types': '@grammyjs/types':
specifier: ^3.23.0 specifier: ^3.23.0
version: 3.23.0 version: 3.23.0
@@ -203,8 +200,11 @@ importers:
ollama: ollama:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
oxfmt:
specifier: 0.24.0
version: 0.24.0
oxlint: oxlint:
specifier: ^1.38.0 specifier: ^1.39.0
version: 1.39.0(oxlint-tsgolint@0.11.0) version: 1.39.0(oxlint-tsgolint@0.11.0)
oxlint-tsgolint: oxlint-tsgolint:
specifier: ^0.11.0 specifier: ^0.11.0
@@ -452,59 +452,6 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} 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': '@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
@@ -1245,6 +1192,46 @@ packages:
'@oxc-project/types@0.107.0': '@oxc-project/types@0.107.0':
resolution: {integrity: sha512-QFDRbYfV2LVx8tyqtyiah3jQPUj1mK2+RYwxyFWyGoys6XJnwTdlzO6rdNNHOPorHAu5Uo34oWRKcvNpbJarmQ==} 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': '@oxlint-tsgolint/darwin-arm64@0.11.0':
resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==} resolution: {integrity: sha512-F67T8dXgYIrgv6wpd52fKQFdmieSOHaxBkscgso64YdtEHrV3s52ASiZGNzw62TKihn9Ox9ek3PYx9XsxIJDUw==}
cpu: [arm64] cpu: [arm64]
@@ -3324,6 +3311,11 @@ packages:
resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==} resolution: {integrity: sha512-GJR9XnS8dQ+sAdbhX90RA4WbmEyrso7X9aHMws4MaQ2GRpfEjnOUSZIdOXJQfnIfBoy9oCc7US/MNFCyuJQzjg==}
engines: {node: '>=20'} 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: oxlint-tsgolint@0.11.0:
resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==} resolution: {integrity: sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==}
hasBin: true hasBin: true
@@ -3858,6 +3850,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} 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: tinyrainbow@3.0.3:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -4641,41 +4637,6 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {} '@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': {} '@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)': '@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)':
@@ -5420,6 +5381,30 @@ snapshots:
'@oxc-project/types@0.107.0': {} '@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': '@oxlint-tsgolint/darwin-arm64@0.11.0':
optional: true optional: true
@@ -7653,6 +7638,19 @@ snapshots:
osc-progress@0.2.0: {} 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: oxlint-tsgolint@0.11.0:
optionalDependencies: optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.11.0 '@oxlint-tsgolint/darwin-arm64': 0.11.0
@@ -8291,6 +8289,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tinypool@2.0.0: {}
tinyrainbow@3.0.3: {} tinyrainbow@3.0.3: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:

View File

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

View File

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

View File

@@ -13,9 +13,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
type AgentEntry = NonNullable< type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = { type ResolvedAgentConfig = {
name?: string; name?: string;
@@ -36,9 +34,7 @@ let defaultAgentWarned = false;
function listAgents(cfg: ClawdbotConfig): AgentEntry[] { function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list; const list = cfg.agents?.list;
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry => return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
Boolean(entry && typeof entry === "object"),
);
} }
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string { export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
@@ -47,24 +43,20 @@ export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const defaults = agents.filter((agent) => agent?.default); const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) { if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true; defaultAgentWarned = true;
console.warn( console.warn("Multiple agents marked default=true; using the first entry as default.");
"Multiple agents marked default=true; using the first entry as default.",
);
} }
const chosen = (defaults[0] ?? agents[0])?.id?.trim(); const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID); return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
} }
export function resolveSessionAgentIds(params: { export function resolveSessionAgentIds(params: { sessionKey?: string; config?: ClawdbotConfig }): {
sessionKey?: string; defaultAgentId: string;
config?: ClawdbotConfig; sessionAgentId: string;
}): { defaultAgentId: string; sessionAgentId: string } { } {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {}); const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim(); const sessionKey = params.sessionKey?.trim();
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null; const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const sessionAgentId = parsed?.agentId const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
? normalizeAgentId(parsed.agentId)
: defaultAgentId;
return { defaultAgentId, sessionAgentId }; return { defaultAgentId, sessionAgentId };
} }
@@ -75,10 +67,7 @@ export function resolveSessionAgentId(params: {
return resolveSessionAgentIds(params).sessionAgentId; return resolveSessionAgentIds(params).sessionAgentId;
} }
function resolveAgentEntry( function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId); const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id); return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
} }
@@ -92,31 +81,23 @@ export function resolveAgentConfig(
if (!entry) return undefined; if (!entry) return undefined;
return { return {
name: typeof entry.name === "string" ? entry.name : undefined, name: typeof entry.name === "string" ? entry.name : undefined,
workspace: workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: model:
typeof entry.model === "string" || typeof entry.model === "string" || (entry.model && typeof entry.model === "object")
(entry.model && typeof entry.model === "object")
? entry.model ? entry.model
: undefined, : undefined,
memorySearch: entry.memorySearch, memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay, humanDelay: entry.humanDelay,
identity: entry.identity, identity: entry.identity,
groupChat: entry.groupChat, groupChat: entry.groupChat,
subagents: subagents: typeof entry.subagents === "object" && entry.subagents ? entry.subagents : undefined,
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
: undefined,
sandbox: entry.sandbox, sandbox: entry.sandbox,
tools: entry.tools, tools: entry.tools,
}; };
} }
export function resolveAgentModelPrimary( export function resolveAgentModelPrimary(cfg: ClawdbotConfig, agentId: string): string | undefined {
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model; const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) return undefined; if (!raw) return undefined;
if (typeof raw === "string") return raw.trim() || 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 path from "node:path";
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai"; import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import { import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
ANTHROPIC_SETUP_TOKEN_PREFIX, 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 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_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE = const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? ""; const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = const SETUP_TOKEN_MODEL = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
const SETUP_TOKEN_MODEL =
process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
const ENABLED = const ENABLED = LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
const describeLive = ENABLED ? describe : describe.skip; const describeLive = ENABLED ? describe : describe.skip;
type TokenSource = { type TokenSource = {
@@ -60,11 +53,7 @@ function listSetupTokenProfiles(store: {
} }
function pickSetupTokenProfile(candidates: string[]): string { function pickSetupTokenProfile(candidates: string[]): string {
const preferred = [ const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
"anthropic:setup-token-test",
"anthropic:setup-token",
"anthropic:default",
];
for (const id of preferred) { for (const id of preferred) {
if (candidates.includes(id)) return id; if (candidates.includes(id)) return id;
} }
@@ -73,17 +62,14 @@ function pickSetupTokenProfile(candidates: string[]): string {
async function resolveTokenSource(): Promise<TokenSource> { async function resolveTokenSource(): Promise<TokenSource> {
const explicitToken = const explicitToken =
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || (SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
SETUP_TOKEN_VALUE;
if (explicitToken) { if (explicitToken) {
const error = validateAnthropicSetupToken(explicitToken); const error = validateAnthropicSetupToken(explicitToken);
if (error) { if (error) {
throw new Error(`Invalid setup-token: ${error}`); throw new Error(`Invalid setup-token: ${error}`);
} }
const tempDir = await fs.mkdtemp( const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-setup-token-"));
path.join(os.tmpdir(), "clawdbot-setup-token-"),
);
const profileId = `anthropic:setup-token-live-${randomUUID()}`; const profileId = `anthropic:setup-token-live-${randomUUID()}`;
const store = ensureAuthProfileStore(tempDir, { const store = ensureAuthProfileStore(tempDir, {
allowKeychainPrompt: false, allowKeychainPrompt: false,
@@ -111,8 +97,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
const candidates = listSetupTokenProfiles(store); const candidates = listSetupTokenProfiles(store);
if (SETUP_TOKEN_PROFILE) { if (SETUP_TOKEN_PROFILE) {
if (!candidates.includes(SETUP_TOKEN_PROFILE)) { if (!candidates.includes(SETUP_TOKEN_PROFILE)) {
const available = const available = candidates.length > 0 ? candidates.join(", ") : "(none)";
candidates.length > 0 ? candidates.join(", ") : "(none)";
throw new Error( throw new Error(
`Setup-token profile "${SETUP_TOKEN_PROFILE}" not found. Available: ${available}.`, `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 }; return { agentDir, profileId: SETUP_TOKEN_PROFILE };
} }
if ( if (SETUP_TOKEN_RAW && SETUP_TOKEN_RAW !== "1" && SETUP_TOKEN_RAW !== "auto") {
SETUP_TOKEN_RAW &&
SETUP_TOKEN_RAW !== "1" &&
SETUP_TOKEN_RAW !== "auto"
) {
throw new Error( throw new Error(
"CLAWDBOT_LIVE_SETUP_TOKEN did not look like a setup-token. Use CLAWDBOT_LIVE_SETUP_TOKEN_VALUE for raw tokens.", "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 ( return (
models.find( models.find(
(model) => (model) =>
normalizeProviderId(model.provider) === parsed.provider && normalizeProviderId(model.provider) === parsed.provider && model.id === parsed.model,
model.id === parsed.model,
) ?? null ) ?? null
); );
} }
@@ -176,9 +156,7 @@ describeLive("live anthropic setup-token", () => {
const authStorage = discoverAuthStorage(tokenSource.agentDir); const authStorage = discoverAuthStorage(tokenSource.agentDir);
const modelRegistry = discoverModels(authStorage, tokenSource.agentDir); const modelRegistry = discoverModels(authStorage, tokenSource.agentDir);
const all = Array.isArray(modelRegistry) const all = Array.isArray(modelRegistry) ? modelRegistry : modelRegistry.getAll();
? modelRegistry
: modelRegistry.getAll();
const candidates = all.filter( const candidates = all.filter(
(model) => normalizeProviderId(model.provider) === "anthropic", (model) => normalizeProviderId(model.provider) === "anthropic",
) as Array<Model<Api>>; ) as Array<Model<Api>>;
@@ -201,9 +179,7 @@ describeLive("live anthropic setup-token", () => {
}); });
const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey); const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey);
if (tokenError) { if (tokenError) {
throw new Error( throw new Error(`Resolved profile is not a setup-token: ${tokenError}`);
`Resolved profile is not a setup-token: ${tokenError}`,
);
} }
const res = await completeSimple( const res = await completeSimple(

View File

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

View File

@@ -282,10 +282,7 @@ function checkPatchBoundariesLenient(lines: string[]): string[] {
} }
const first = lines[0]; const first = lines[0];
const last = lines[lines.length - 1]; const last = lines[lines.length - 1];
if ( if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) {
(first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') &&
last.endsWith("EOF")
) {
const inner = lines.slice(1, lines.length - 1); const inner = lines.slice(1, lines.length - 1);
const innerError = checkPatchBoundariesStrict(inner); const innerError = checkPatchBoundariesStrict(inner);
if (!innerError) return 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'"; return "The last line of the patch must be '*** End Patch'";
} }
function parseOneHunk( function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } {
lines: string[],
lineNumber: number,
): { hunk: Hunk; consumed: number } {
if (lines.length === 0) { if (lines.length === 0) {
throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`); 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 { afterEach, describe, expect, it, vi } from "vitest";
import { import { buildAuthHealthSummary, DEFAULT_OAUTH_WARN_MS } from "./auth-health.js";
buildAuthHealthSummary,
DEFAULT_OAUTH_WARN_MS,
} from "./auth-health.js";
describe("buildAuthHealthSummary", () => { describe("buildAuthHealthSummary", () => {
const now = 1_700_000_000_000; const now = 1_700_000_000_000;
@@ -59,9 +56,7 @@ describe("buildAuthHealthSummary", () => {
expect(statuses["anthropic:expired"]).toBe("expired"); expect(statuses["anthropic:expired"]).toBe("expired");
expect(statuses["anthropic:api"]).toBe("static"); expect(statuses["anthropic:api"]).toBe("static");
const provider = summary.providers.find( const provider = summary.providers.find((entry) => entry.provider === "anthropic");
(entry) => entry.provider === "anthropic",
);
expect(provider?.status).toBe("expired"); expect(provider?.status).toBe("expired");
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,4 @@
export { export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
} from "./auth-profiles/constants.js";
export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js"; export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js"; export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
export { resolveApiKeyForProfile } from "./auth-profiles/oauth.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 configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
const email = const email =
configEmail || configEmail ||
(profile && "email" in profile (profile && "email" in profile ? (profile.email as string | undefined)?.trim() : undefined);
? (profile.email as string | undefined)?.trim()
: undefined);
if (email) return `${profileId} (${email})`; if (email) return `${profileId} (${email})`;
return profileId; return profileId;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeProviderId } from "../model-selection.js"; import { normalizeProviderId } from "../model-selection.js";
import { import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
saveAuthProfileStore, import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
updateAuthProfileStoreWithLock,
} from "./store.js";
import type {
AuthProfileFailureReason,
AuthProfileStore,
ProfileUsageStats,
} from "./types.js";
function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null { function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
const values = [stats.cooldownUntil, stats.disabledUntil] 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). * Check if a profile is currently in cooldown (due to rate limiting or errors).
*/ */
export function isProfileInCooldown( export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean {
store: AuthProfileStore,
profileId: string,
): boolean {
const stats = store.usageStats?.[profileId]; const stats = store.usageStats?.[profileId];
if (!stats) return false; if (!stats) return false;
const unusableUntil = resolveProfileUnusableUntil(stats); const unusableUntil = resolveProfileUnusableUntil(stats);
@@ -102,9 +92,7 @@ function resolveAuthCooldownConfig(params: {
} as const; } as const;
const resolveHours = (value: unknown, fallback: number) => const resolveHours = (value: unknown, fallback: number) =>
typeof value === "number" && Number.isFinite(value) && value > 0 typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
? value
: fallback;
const cooldowns = params.cfg?.auth?.cooldowns; const cooldowns = params.cfg?.auth?.cooldowns;
const billingOverride = (() => { const billingOverride = (() => {
@@ -120,10 +108,7 @@ function resolveAuthCooldownConfig(params: {
billingOverride ?? cooldowns?.billingBackoffHours, billingOverride ?? cooldowns?.billingBackoffHours,
defaults.billingBackoffHours, defaults.billingBackoffHours,
); );
const billingMaxHours = resolveHours( const billingMaxHours = resolveHours(cooldowns?.billingMaxHours, defaults.billingMaxHours);
cooldowns?.billingMaxHours,
defaults.billingMaxHours,
);
const failureWindowHours = resolveHours( const failureWindowHours = resolveHours(
cooldowns?.failureWindowHours, cooldowns?.failureWindowHours,
defaults.failureWindowHours, defaults.failureWindowHours,
@@ -172,9 +157,7 @@ function computeNextProfileUsageStats(params: {
const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0); const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
const nextErrorCount = baseErrorCount + 1; const nextErrorCount = baseErrorCount + 1;
const failureCounts = windowExpired const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts };
? {}
: { ...params.existing.failureCounts };
failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1; failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
const updatedStats: ProfileUsageStats = { const updatedStats: ProfileUsageStats = {
@@ -246,9 +229,7 @@ export async function markAuthProfileFailure(params: {
store.usageStats = store.usageStats ?? {}; store.usageStats = store.usageStats ?? {};
const existing = store.usageStats[profileId] ?? {}; const existing = store.usageStats[profileId] ?? {};
const now = Date.now(); const now = Date.now();
const providerKey = normalizeProviderId( const providerKey = normalizeProviderId(store.profiles[profileId]?.provider ?? "");
store.profiles[profileId]?.provider ?? "",
);
const cfgResolved = resolveAuthCooldownConfig({ const cfgResolved = resolveAuthCooldownConfig({
cfg, cfg,
providerId: providerKey, 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); return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS);
} }
let jobTtlMs = clampTtl( let jobTtlMs = clampTtl(Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10));
Number.parseInt(process.env.PI_BASH_JOB_TTL_MS ?? "", 10),
);
export type ProcessStatus = "running" | "completed" | "failed" | "killed"; export type ProcessStatus = "running" | "completed" | "failed" | "killed";
@@ -75,24 +73,15 @@ export function deleteSession(id: string) {
finishedSessions.delete(id); finishedSessions.delete(id);
} }
export function appendOutput( export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
session: ProcessSession,
stream: "stdout" | "stderr",
chunk: string,
) {
session.pendingStdout ??= []; session.pendingStdout ??= [];
session.pendingStderr ??= []; session.pendingStderr ??= [];
const buffer = const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
stream === "stdout" ? session.pendingStdout : session.pendingStderr;
buffer.push(chunk); buffer.push(chunk);
session.totalOutputChars += chunk.length; session.totalOutputChars += chunk.length;
const aggregated = trimWithCap( const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
session.aggregated + chunk,
session.maxOutputChars,
);
session.truncated = session.truncated =
session.truncated || session.truncated || aggregated.length < session.aggregated.length + chunk.length;
aggregated.length < session.aggregated.length + chunk.length;
session.aggregated = aggregated; session.aggregated = aggregated;
session.tail = tail(session.aggregated, 2000); 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 { Type } from "@sinclair/typebox";
import { logInfo } from "../logger.js"; import { logInfo } from "../logger.js";
import { import { addSession, appendOutput, markBackgrounded, markExited } from "./bash-process-registry.js";
addSession,
appendOutput,
markBackgrounded,
markExited,
} from "./bash-process-registry.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js";
import { import {
buildDockerExecArgs, buildDockerExecArgs,
@@ -32,8 +27,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
150_000, 150_000,
); );
const DEFAULT_PATH = const DEFAULT_PATH =
process.env.PATH ?? process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
export type ExecToolDefaults = { export type ExecToolDefaults = {
backgroundMs?: number; backgroundMs?: number;
@@ -55,18 +49,14 @@ export type ExecElevatedDefaults = {
const execSchema = Type.Object({ const execSchema = Type.Object({
command: Type.String({ description: "Shell command to execute" }), command: Type.String({ description: "Shell command to execute" }),
workdir: Type.Optional( workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })),
Type.String({ description: "Working directory (defaults to cwd)" }),
),
env: Type.Optional(Type.Record(Type.String(), Type.String())), env: Type.Optional(Type.Record(Type.String(), Type.String())),
yieldMs: Type.Optional( yieldMs: Type.Optional(
Type.Number({ Type.Number({
description: "Milliseconds to wait before backgrounding (default 10000)", description: "Milliseconds to wait before backgrounding (default 10000)",
}), }),
), ),
background: Type.Optional( background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
Type.Boolean({ description: "Run in background immediately" }),
),
timeout: Type.Optional( timeout: Type.Optional(
Type.Number({ Type.Number({
description: "Timeout in seconds (optional, kills process on expiry)", description: "Timeout in seconds (optional, kills process on expiry)",
@@ -140,19 +130,12 @@ export function createExecTool(
const backgroundRequested = params.background === true; const backgroundRequested = params.background === true;
const yieldRequested = typeof params.yieldMs === "number"; const yieldRequested = typeof params.yieldMs === "number";
if (!allowBackground && (backgroundRequested || yieldRequested)) { if (!allowBackground && (backgroundRequested || yieldRequested)) {
warnings.push( warnings.push("Warning: background execution is disabled; running synchronously.");
"Warning: background execution is disabled; running synchronously.",
);
} }
const yieldWindow = allowBackground const yieldWindow = allowBackground
? backgroundRequested ? backgroundRequested
? 0 ? 0
: clampNumber( : clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
params.yieldMs ?? defaultBackgroundMs,
defaultBackgroundMs,
10,
120_000,
)
: null; : null;
const elevatedDefaults = defaults?.elevated; const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn = const elevatedDefaultOn =
@@ -160,17 +143,13 @@ export function createExecTool(
elevatedDefaults.enabled && elevatedDefaults.enabled &&
elevatedDefaults.allowed; elevatedDefaults.allowed;
const elevatedRequested = const elevatedRequested =
typeof params.elevated === "boolean" typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
? params.elevated
: elevatedDefaultOn;
if (elevatedRequested) { if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct"; const runtime = defaults?.sandbox ? "sandboxed" : "direct";
const gates: string[] = []; const gates: string[] = [];
if (!elevatedDefaults?.enabled) { if (!elevatedDefaults?.enabled) {
gates.push( gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
"enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)",
);
} else { } else {
gates.push( gates.push(
"allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)", "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 sandbox = elevatedRequested ? undefined : defaults?.sandbox;
const rawWorkdir = const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
params.workdir?.trim() || defaults?.cwd || process.cwd();
let workdir = rawWorkdir; let workdir = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir; let containerWorkdir = sandbox?.containerWorkdir;
if (sandbox) { if (sandbox) {
@@ -335,121 +313,111 @@ export function createExecTool(
} }
}); });
return new Promise<AgentToolResult<ExecToolDetails>>( return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
(resolve, reject) => { const resolveRunning = () => {
const resolveRunning = () => { settle(() =>
settle(() => resolve({
resolve({ content: [
content: [ {
{ type: "text",
type: "text", text:
text: `${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` + `Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` + "Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
"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,
}, },
}), ],
); details: {
}; status: "running",
sessionId,
pid: session.pid ?? undefined,
startedAt,
cwd: session.cwd,
tail: session.tail,
},
}),
);
};
const onYieldNow = () => { const onYieldNow = () => {
if (yieldTimer) clearTimeout(yieldTimer); if (yieldTimer) clearTimeout(yieldTimer);
if (settled) return; if (settled) return;
yielded = true; yielded = true;
markBackgrounded(session); markBackgrounded(session);
resolveRunning(); resolveRunning();
}; };
if (allowBackground && yieldWindow !== null) { if (allowBackground && yieldWindow !== null) {
if (yieldWindow === 0) { if (yieldWindow === 0) {
onYieldNow(); onYieldNow();
} else { } else {
yieldTimer = setTimeout(() => { yieldTimer = setTimeout(() => {
if (settled) return; if (settled) return;
yielded = true; yielded = true;
markBackgrounded(session); markBackgrounded(session);
resolveRunning(); resolveRunning();
}, yieldWindow); }, 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 = ( settle(() =>
code: number | null, resolve({
exitSignal: NodeJS.Signals | number | null, content: [
) => { {
if (yieldTimer) clearTimeout(yieldTimer); type: "text",
if (timeoutTimer) clearTimeout(timeoutTimer); text:
const durationMs = Date.now() - startedAt; `${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
const wasSignal = exitSignal != null; (aggregated || "(no output)"),
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,
}, },
}), ],
); details: {
}; status: "completed",
exitCode: code ?? 0,
durationMs,
aggregated,
cwd: session.cwd,
},
}),
);
};
// `exit` can fire before stdio fully flushes (notably on Windows). // `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete. // `close` waits for streams to close, so aggregated output is complete.
child.once("close", (code, exitSignal) => { child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal); handleExit(code, exitSignal);
}); });
child.once("error", (err) => { child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer); if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed"); markExited(session, null, null, "failed");
settle(() => reject(err)); settle(() => reject(err));
}); });
}, });
);
}, },
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -14,10 +14,7 @@ describe("chutes-oauth", () => {
if (url === CHUTES_TOKEN_ENDPOINT) { if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
expect( expect(
String( String(init?.headers && (init.headers as Record<string, string>)["Content-Type"]),
init?.headers &&
(init.headers as Record<string, string>)["Content-Type"],
),
).toContain("application/x-www-form-urlencoded"); ).toContain("application/x-www-form-urlencoded");
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -30,18 +27,12 @@ describe("chutes-oauth", () => {
} }
if (url === CHUTES_USERINFO_ENDPOINT) { if (url === CHUTES_USERINFO_ENDPOINT) {
expect( expect(
String( String(init?.headers && (init.headers as Record<string, string>).Authorization),
init?.headers &&
(init.headers as Record<string, string>).Authorization,
),
).toBe("Bearer at_123"); ).toBe("Bearer at_123");
return new Response( return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), {
JSON.stringify({ username: "fred", sub: "sub_1" }), status: 200,
{ headers: { "Content-Type": "application/json" },
status: 200, });
headers: { "Content-Type": "application/json" },
},
);
} }
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
}; };
@@ -62,20 +53,15 @@ describe("chutes-oauth", () => {
expect(creds.access).toBe("at_123"); expect(creds.access).toBe("at_123");
expect(creds.refresh).toBe("rt_123"); expect(creds.refresh).toBe("rt_123");
expect(creds.email).toBe("fred"); expect(creds.email).toBe("fred");
expect((creds as unknown as { accountId?: string }).accountId).toBe( expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1");
"sub_1", expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test");
);
expect((creds as unknown as { clientId?: string }).clientId).toBe(
"cid_test",
);
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000); 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 () => { it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn: typeof fetch = async (input, init) => { const fetchFn: typeof fetch = async (input, init) => {
const url = String(input); const url = String(input);
if (url !== CHUTES_TOKEN_ENDPOINT) if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
return new Response("not found", { status: 404 });
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
const body = init?.body as URLSearchParams; const body = init?.body as URLSearchParams;
expect(String(body.get("grant_type"))).toBe("refresh_token"); 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 { function coerceExpiresAt(expiresInSeconds: number, now: number): number {
const value = const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
now +
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
DEFAULT_EXPIRES_BUFFER_MS;
return Math.max(value, now + 30_000); return Math.max(value, now + 30_000);
} }
@@ -122,8 +119,7 @@ export async function exchangeChutesCodeForTokens(params: {
const refresh = data.refresh_token?.trim(); const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0; const expiresIn = data.expires_in ?? 0;
if (!access) if (!access) throw new Error("Chutes token exchange returned no access_token");
throw new Error("Chutes token exchange returned no access_token");
if (!refresh) { if (!refresh) {
throw new Error("Chutes token exchange returned no refresh_token"); 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"); throw new Error("Chutes OAuth credential is missing refresh token");
} }
const clientId = const clientId = params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
if (!clientId) { if (!clientId) {
throw new Error( throw new Error("Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).");
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
);
} }
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts), callGateway: (opts: unknown) => callGatewayMock(opts),
})); }));
let configOverride: ReturnType< let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
typeof import("../config/config.js")["loadConfig"]
> = {
session: { session: {
mainKey: "main", mainKey: "main",
scope: "per-sender", scope: "per-sender",
@@ -85,17 +83,14 @@ describe("clawdbot-tools: subagents", () => {
}; };
} }
if (request.method === "agent.wait") { if (request.method === "agent.wait") {
const params = request.params as const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
| { runId?: string; timeoutMs?: number }
| undefined;
waitCalls.push(params ?? {}); waitCalls.push(params ?? {});
const status = params?.runId === childRunId ? "timeout" : "ok"; const status = params?.runId === childRunId ? "timeout" : "ok";
return { runId: params?.runId ?? "run-1", status }; return { runId: params?.runId ?? "run-1", status };
} }
if (request.method === "chat.history") { if (request.method === "chat.history") {
const params = request.params as { sessionKey?: string } | undefined; const params = request.params as { sessionKey?: string } | undefined;
const text = const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
return { return {
messages: [{ role: "assistant", content: [{ type: "text", text }] }], 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 { createCronTool } from "./tools/cron-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js"; import { createImageTool } from "./tools/image-tool.js";
import { import { createMemoryGetTool, createMemorySearchTool } from "./tools/memory-tool.js";
createMemoryGetTool,
createMemorySearchTool,
} from "./tools/memory-tool.js";
import { createMessageTool } from "./tools/message-tool.js"; import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js";
@@ -105,9 +102,7 @@ export function createClawdbotTools(options?: {
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
config: options?.config, config: options?.config,
}), }),
...(memorySearchTool && memoryGetTool ...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []),
? [memorySearchTool, memoryGetTool]
: []),
...(imageTool ? [imageTool] : []), ...(imageTool ? [imageTool] : []),
]; ];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,11 +16,7 @@ export function getCliSessionId(
return undefined; return undefined;
} }
export function setCliSessionId( export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
entry: SessionEntry,
provider: string,
sessionId: string,
): void {
const normalized = normalizeProviderId(provider); const normalized = normalizeProviderId(provider);
const trimmed = sessionId.trim(); const trimmed = sessionId.trim();
if (!trimmed) return; 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_HARD_MIN_TOKENS = 16_000;
export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000; export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;
export type ContextWindowSource = export type ContextWindowSource = "model" | "modelsConfig" | "agentContextTokens" | "default";
| "model"
| "modelsConfig"
| "agentContextTokens"
| "default";
export type ContextWindowInfo = { export type ContextWindowInfo = {
tokens: number; tokens: number;
@@ -32,26 +28,17 @@ export function resolveContextWindowInfo(params: {
const fromModelsConfig = (() => { const fromModelsConfig = (() => {
const providers = params.cfg?.models?.providers as const providers = params.cfg?.models?.providers as
| Record< | Record<string, { models?: Array<{ id?: string; contextWindow?: number }> }>
string,
{ models?: Array<{ id?: string; contextWindow?: number }> }
>
| undefined; | undefined;
const providerEntry = providers?.[params.provider]; const providerEntry = providers?.[params.provider];
const models = Array.isArray(providerEntry?.models) const models = Array.isArray(providerEntry?.models) ? providerEntry.models : [];
? providerEntry.models
: [];
const match = models.find((m) => m?.id === params.modelId); const match = models.find((m) => m?.id === params.modelId);
return normalizePositiveInt(match?.contextWindow); return normalizePositiveInt(match?.contextWindow);
})(); })();
if (fromModelsConfig) if (fromModelsConfig) return { tokens: fromModelsConfig, source: "modelsConfig" };
return { tokens: fromModelsConfig, source: "modelsConfig" };
const fromAgentConfig = normalizePositiveInt( const fromAgentConfig = normalizePositiveInt(params.cfg?.agents?.defaults?.contextTokens);
params.cfg?.agents?.defaults?.contextTokens, if (fromAgentConfig) return { tokens: fromAgentConfig, source: "agentContextTokens" };
);
if (fromAgentConfig)
return { tokens: fromAgentConfig, source: "agentContextTokens" };
return { tokens: Math.floor(params.defaultTokens), source: "default" }; return { tokens: Math.floor(params.defaultTokens), source: "default" };
} }
@@ -70,10 +57,7 @@ export function evaluateContextWindowGuard(params: {
1, 1,
Math.floor(params.warnBelowTokens ?? CONTEXT_WINDOW_WARN_BELOW_TOKENS), Math.floor(params.warnBelowTokens ?? CONTEXT_WINDOW_WARN_BELOW_TOKENS),
); );
const hardMin = Math.max( const hardMin = Math.max(1, Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS));
1,
Math.floor(params.hardMinTokens ?? CONTEXT_WINDOW_HARD_MIN_TOKENS),
);
const tokens = Math.max(0, Math.floor(params.info.tokens)); const tokens = Math.max(0, Math.floor(params.info.tokens));
return { return {
...params.info, ...params.info,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,9 +52,7 @@ function resolveStorePath(agentId: string, raw?: string): string {
const stateDir = resolveStateDir(process.env, os.homedir); const stateDir = resolveStateDir(process.env, os.homedir);
const fallback = path.join(stateDir, "memory", `${agentId}.sqlite`); const fallback = path.join(stateDir, "memory", `${agentId}.sqlite`);
if (!raw) return fallback; if (!raw) return fallback;
const withToken = raw.includes("{agentId}") const withToken = raw.includes("{agentId}") ? raw.replaceAll("{agentId}", agentId) : raw;
? raw.replaceAll("{agentId}", agentId)
: raw;
return resolveUserPath(withToken); return resolveUserPath(withToken);
} }
@@ -77,47 +75,29 @@ function mergeConfig(
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL; const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
const local = { const local = {
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath, modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
modelCacheDir: modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
}; };
const store = { const store = {
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite", driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
path: resolveStorePath( path: resolveStorePath(agentId, overrides?.store?.path ?? defaults?.store?.path),
agentId,
overrides?.store?.path ?? defaults?.store?.path,
),
}; };
const chunking = { const chunking = {
tokens: tokens: overrides?.chunking?.tokens ?? defaults?.chunking?.tokens ?? DEFAULT_CHUNK_TOKENS,
overrides?.chunking?.tokens ?? overlap: overrides?.chunking?.overlap ?? defaults?.chunking?.overlap ?? DEFAULT_CHUNK_OVERLAP,
defaults?.chunking?.tokens ??
DEFAULT_CHUNK_TOKENS,
overlap:
overrides?.chunking?.overlap ??
defaults?.chunking?.overlap ??
DEFAULT_CHUNK_OVERLAP,
}; };
const sync = { const sync = {
onSessionStart: onSessionStart: overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true,
onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true, onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true,
watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true, watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true,
watchDebounceMs: watchDebounceMs:
overrides?.sync?.watchDebounceMs ?? overrides?.sync?.watchDebounceMs ??
defaults?.sync?.watchDebounceMs ?? defaults?.sync?.watchDebounceMs ??
DEFAULT_WATCH_DEBOUNCE_MS, DEFAULT_WATCH_DEBOUNCE_MS,
intervalMinutes: intervalMinutes: overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
overrides?.sync?.intervalMinutes ?? defaults?.sync?.intervalMinutes ?? 0,
}; };
const query = { const query = {
maxResults: maxResults: overrides?.query?.maxResults ?? defaults?.query?.maxResults ?? DEFAULT_MAX_RESULTS,
overrides?.query?.maxResults ?? minScore: overrides?.query?.minScore ?? defaults?.query?.minScore ?? DEFAULT_MIN_SCORE,
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)); 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(); const imageDataUrl = params.imageDataUrl.trim();
if (!imageDataUrl) throw new Error("MiniMax VLM: imageDataUrl required"); if (!imageDataUrl) throw new Error("MiniMax VLM: imageDataUrl required");
if (!/^data:image\/(png|jpeg|webp);base64,/i.test(imageDataUrl)) { if (!/^data:image\/(png|jpeg|webp);base64,/i.test(imageDataUrl)) {
throw new Error( throw new Error("MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL");
"MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL",
);
} }
const host = coerceApiHost({ const host = coerceApiHost({
@@ -92,17 +90,12 @@ export async function minimaxUnderstandImage(params: {
throw new Error(`MiniMax VLM response was not JSON.${trace}`); throw new Error(`MiniMax VLM response was not JSON.${trace}`);
} }
const baseResp = isRecord(json.base_resp) const baseResp = isRecord(json.base_resp) ? (json.base_resp as MinimaxBaseResp) : {};
? (json.base_resp as MinimaxBaseResp) const code = typeof baseResp.status_code === "number" ? baseResp.status_code : -1;
: {};
const code =
typeof baseResp.status_code === "number" ? baseResp.status_code : -1;
if (code !== 0) { if (code !== 0) {
const msg = (baseResp.status_msg ?? "").trim(); const msg = (baseResp.status_msg ?? "").trim();
const trace = traceId ? ` Trace-Id: ${traceId}` : ""; const trace = traceId ? ` Trace-Id: ${traceId}` : "";
throw new Error( throw new Error(`MiniMax VLM API error (${code})${msg ? `: ${msg}` : ""}.${trace}`);
`MiniMax VLM API error (${code})${msg ? `: ${msg}` : ""}.${trace}`,
);
} }
const content = pickString(json, "content").trim(); 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"; import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? ""; const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL = const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1"; const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "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.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilesPath = path.join( const authProfilesPath = path.join(tempDir, "agent", "auth-profiles.json");
tempDir,
"agent",
"auth-profiles.json",
);
await fs.mkdir(path.dirname(authProfilesPath), { await fs.mkdir(path.dirname(authProfilesPath), {
recursive: true, recursive: true,
mode: 0o700, mode: 0o700,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,9 +70,7 @@ describe("models-config", () => {
agentDir, agentDir,
); );
await expect( await expect(fs.stat(path.join(agentDir, "models.json"))).rejects.toThrow();
fs.stat(path.join(agentDir, "models.json")),
).rejects.toThrow();
expect(result.wrote).toBe(false); expect(result.wrote).toBe(false);
} finally { } finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
@@ -85,8 +83,7 @@ describe("models-config", () => {
else process.env.MINIMAX_API_KEY = previousMinimax; else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY; if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot; else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined) if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic; else process.env.SYNTHETIC_API_KEY = previousSynthetic;
} }
}); });
@@ -105,9 +102,7 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>; providers: Record<string, { baseUrl?: string }>;
}; };
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe( expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
"http://localhost:4000/v1",
);
}); });
}); });
it("adds minimax provider when MINIMAX_API_KEY is set", async () => { it("adds minimax provider when MINIMAX_API_KEY is set", async () => {
@@ -133,9 +128,7 @@ describe("models-config", () => {
} }
>; >;
}; };
expect(parsed.providers.minimax?.baseUrl).toBe( expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
"https://api.minimax.io/anthropic",
);
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id); const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1"); expect(ids).toContain("MiniMax-M2.1");
@@ -169,13 +162,9 @@ describe("models-config", () => {
} }
>; >;
}; };
expect(parsed.providers.synthetic?.baseUrl).toBe( expect(parsed.providers.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
"https://api.synthetic.new/anthropic",
);
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY"); expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
const ids = parsed.providers.synthetic?.models?.map( const ids = parsed.providers.synthetic?.models?.map((model) => model.id);
(model) => model.id,
);
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1"); expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
} finally { } finally {
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY; 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)); return Boolean(value && typeof value === "object" && !Array.isArray(value));
} }
function mergeProviderModels( function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
implicit: ProviderConfig,
explicit: ProviderConfig,
): ProviderConfig {
const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
if (implicitModels.length === 0) return { ...implicit, ...explicit }; if (implicitModels.length === 0) return { ...implicit, ...explicit };
@@ -55,16 +52,12 @@ function mergeProviders(params: {
implicit?: Record<string, ProviderConfig> | null; implicit?: Record<string, ProviderConfig> | null;
explicit?: Record<string, ProviderConfig> | null; explicit?: Record<string, ProviderConfig> | null;
}): Record<string, ProviderConfig> { }): Record<string, ProviderConfig> {
const out: Record<string, ProviderConfig> = params.implicit const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
? { ...params.implicit }
: {};
for (const [key, explicit] of Object.entries(params.explicit ?? {})) { for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
const providerKey = key.trim(); const providerKey = key.trim();
if (!providerKey) continue; if (!providerKey) continue;
const implicit = out[providerKey]; const implicit = out[providerKey];
out[providerKey] = implicit out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
? mergeProviderModels(implicit, explicit)
: explicit;
} }
return out; return out;
} }
@@ -83,14 +76,9 @@ export async function ensureClawdbotModelsJson(
agentDirOverride?: string, agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> { ): Promise<{ agentDir: string; wrote: boolean }> {
const cfg = config ?? loadConfig(); const cfg = config ?? loadConfig();
const agentDir = agentDirOverride?.trim() const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir();
? agentDirOverride.trim()
: resolveClawdbotAgentDir();
const explicitProviders = (cfg.models?.providers ?? {}) as Record< const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
string,
ProviderConfig
>;
const implicitProviders = resolveImplicitProviders({ agentDir }); const implicitProviders = resolveImplicitProviders({ agentDir });
const providers: Record<string, ProviderConfig> = mergeProviders({ const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders, implicit: implicitProviders,

View File

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

View File

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

View File

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

View File

@@ -29,9 +29,7 @@ describe("resolveOpencodeZenAlias", () => {
}); });
it("returns input if no alias exists", () => { it("returns input if no alias exists", () => {
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe( expect(resolveOpencodeZenAlias("some-unknown-model")).toBe("some-unknown-model");
"some-unknown-model",
);
}); });
it("is case-insensitive", () => { it("is case-insensitive", () => {
@@ -42,22 +40,12 @@ describe("resolveOpencodeZenAlias", () => {
describe("resolveOpencodeZenModelApi", () => { describe("resolveOpencodeZenModelApi", () => {
it("maps APIs by model family", () => { it("maps APIs by model family", () => {
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe( expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
"anthropic-messages", expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
); expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
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("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe( expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
"openai-completions", expect(resolveOpencodeZenModelApi("some-unknown-model")).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 { export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase(); const lower = modelId.toLowerCase();
if ( if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
lower.startsWith("claude-") ||
lower.startsWith("minimax") ||
lower.startsWith("alpha-gd4")
) {
return "anthropic-messages"; return "anthropic-messages";
} }
if (lower.startsWith("gemini-")) { if (lower.startsWith("gemini-")) {
@@ -274,9 +270,7 @@ interface ZenModelsResponse {
* @param apiKey - OpenCode Zen API key for authentication * @param apiKey - OpenCode Zen API key for authentication
* @returns Array of model definitions, or static fallback on failure * @returns Array of model definitions, or static fallback on failure
*/ */
export async function fetchOpencodeZenModels( export async function fetchOpencodeZenModels(apiKey?: string): Promise<ModelDefinitionConfig[]> {
apiKey?: string,
): Promise<ModelDefinitionConfig[]> {
// Return cached models if still valid // Return cached models if still valid
const now = Date.now(); const now = Date.now();
if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) { if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
@@ -298,9 +292,7 @@ export async function fetchOpencodeZenModels(
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(`API returned ${response.status}: ${response.statusText}`);
`API returned ${response.status}: ${response.statusText}`,
);
} }
const data = (await response.json()) as ZenModelsResponse; const data = (await response.json()) as ZenModelsResponse;
@@ -316,9 +308,7 @@ export async function fetchOpencodeZenModels(
return models; return models;
} catch (error) { } catch (error) {
console.warn( console.warn(`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`);
`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`,
);
return getOpencodeZenStaticFallbackModels(); return getOpencodeZenStaticFallbackModels();
} }
} }

View File

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

View File

@@ -1,13 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS } from "./pi-embedded-helpers.js";
buildBootstrapContextFiles,
DEFAULT_BOOTSTRAP_MAX_CHARS,
} from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const makeFile = ( const makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME, name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md", path: "/tmp/AGENTS.md",
content: "", content: "",
@@ -40,9 +35,7 @@ describe("buildBootstrapContextFiles", () => {
maxChars, maxChars,
warn: (message) => warnings.push(message), warn: (message) => warnings.push(message),
}); });
expect(result?.content).toContain( expect(result?.content).toContain("[...truncated, read TOOLS.md for full content...]");
"[...truncated, read TOOLS.md for full content...]",
);
expect(result?.content.length).toBeLessThan(long.length); expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true); expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true); expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
@@ -55,8 +48,6 @@ describe("buildBootstrapContextFiles", () => {
const files = [makeFile({ content: long })]; const files = [makeFile({ content: long })];
const [result] = buildBootstrapContextFiles(files); const [result] = buildBootstrapContextFiles(files);
expect(result?.content).toBe(long); expect(result?.content).toBe(long);
expect(result?.content).not.toContain( expect(result?.content).not.toContain("[...truncated, read AGENTS.md for full content...]");
"[...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 { classifyFailoverReason } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = ( const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME, name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md", path: "/tmp/AGENTS.md",
content: "", content: "",
@@ -17,9 +15,7 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("no credentials found")).toBe("auth"); expect(classifyFailoverReason("no credentials found")).toBe("auth");
expect(classifyFailoverReason("no api key found")).toBe("auth"); expect(classifyFailoverReason("no api key found")).toBe("auth");
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
expect(classifyFailoverReason("resource has been exhausted")).toBe( expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit");
"rate_limit",
);
expect( expect(
classifyFailoverReason( classifyFailoverReason(
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
@@ -28,16 +24,12 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("invalid request format")).toBe("format"); expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing"); expect(classifyFailoverReason("credit balance too low")).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe( expect(classifyFailoverReason("string should match pattern")).toBe("format");
"format",
);
expect(classifyFailoverReason("bad request")).toBeNull(); expect(classifyFailoverReason("bad request")).toBeNull();
}); });
it("classifies OpenAI usage limit errors as rate_limit", () => { it("classifies OpenAI usage limit errors as rate_limit", () => {
expect( expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
classifyFailoverReason( "rate_limit",
"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 { formatAssistantErrorText } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = ( const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
overrides: Partial<WorkspaceBootstrapFile>,
): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME, name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md", path: "/tmp/AGENTS.md",
content: "", content: "",
@@ -24,12 +22,8 @@ describe("formatAssistantErrorText", () => {
expect(formatAssistantErrorText(msg)).toContain("Context overflow"); expect(formatAssistantErrorText(msg)).toContain("Context overflow");
}); });
it("returns a friendly message for Anthropic role ordering", () => { it("returns a friendly message for Anthropic role ordering", () => {
const msg = makeAssistantError( const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
'messages: roles must alternate between "user" and "assistant"', expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
);
expect(formatAssistantErrorText(msg)).toContain(
"Message ordering conflict",
);
}); });
it("returns a friendly message for Anthropic overload errors", () => { it("returns a friendly message for Anthropic overload errors", () => {
const msg = makeAssistantError( const msg = makeAssistantError(

View File

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

View File

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

View File

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

View File

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