From 15105443896fa840d99f24a9c6f0882c672158a6 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 24 Jul 2026 10:43:14 -0700 Subject: [PATCH] feat(claude): probe SDK models and default to Opus 5 - Update Claude SDK and client version metadata - Derive model efforts and fast-mode support from SDK capabilities --- package.json | 2 +- packages/agents-usage/src/clientVersions.ts | 4 +- pnpm-lock.yaml | 74 ++++---- pnpm-workspace.yaml | 10 ++ .../providers/claude/index.test.tsx | 17 ++ .../components/providers/claude/index.tsx | 1 + src/supervisor/agents/claude/argv.test.ts | 1 + src/supervisor/agents/claude/claude.test.ts | 15 +- src/supervisor/agents/claude/detection.ts | 53 ++---- src/supervisor/agents/claude/models.ts | 165 ++++++++++++++++++ src/supervisor/agents/claude/probe.test.ts | 69 +++++++- src/supervisor/agents/claude/probe.ts | 105 ++--------- .../agents/claude/sdkProbeWorker.ts | 3 + 13 files changed, 340 insertions(+), 179 deletions(-) create mode 100644 src/supervisor/agents/claude/models.ts diff --git a/package.json b/package.json index 235196c88..0a017b130 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.218", + "@anthropic-ai/claude-agent-sdk": "^0.3.219", "@aparajita/capacitor-secure-storage": "^8.0.0", "@capacitor/app": "^8.1.0", "@capacitor/push-notifications": "^8.1.1", diff --git a/packages/agents-usage/src/clientVersions.ts b/packages/agents-usage/src/clientVersions.ts index 862f1ffb2..7b8159e3f 100644 --- a/packages/agents-usage/src/clientVersions.ts +++ b/packages/agents-usage/src/clientVersions.ts @@ -5,10 +5,10 @@ * one), so these values rot. Bump them here when a provider starts rejecting * requests; the host can also override via `HostPort.clientVersions`. * - * Last reviewed: 2026-06-23. + * Last reviewed: 2026-07-24. */ export const DEFAULT_CLIENT_VERSIONS = { - claudeCode: "2.1.186", + claudeCode: "2.1.219", codex: "0.50.0", copilotChat: "0.26.7", editor: "vscode/1.96.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037c29cc2..727a19fdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ importers: specifier: ^1.2.1 version: 1.2.1(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.218 - version: 0.3.218(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.219 + version: 0.3.219(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@aparajita/capacitor-secure-storage': specifier: ^8.0.0 version: 8.0.0 @@ -512,52 +512,52 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.218': - resolution: {integrity: sha512-foTI71ua2r5lJo7sfcw/3UafBCJYjxPmOXZ98wOz1UWIJxaP/Oq9Xp4sUavBd1efFVRxPnAcRnrZ5yrmXK1xUw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219': + resolution: {integrity: sha512-TQhGAlbMsOGXi03dwf4nD274Lc5BOJg4QFJTuXcQyhLhx0hUqzxCmtvpXt6S70K6yl1Zgc+2n5W/r1Dgt8G8iw==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.218': - resolution: {integrity: sha512-rAdlR2ukJd01Osfsb7nPPv31MCVJBennBDaL7ZP/bv+dednwNuY6thuPO5PMFJUM+54Gbnziqm+hqoNTgndqDg==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219': + resolution: {integrity: sha512-zA/wHN+os4yqXASXJxvGGNSD1p7LZ6BMN71CCphoy5u+d6IpNOCtATnZwINEc2fGFHpv/QDci6jhywJ2O9Flbw==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.218': - resolution: {integrity: sha512-qfFBwOf0uh/mAtNGEkBJlLWt1XIgFqcQeeX966g2NexasCdSJGnwfc0YWA0QOeAAU/5xk6tSCW/Nn0zHGBO/3A==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.219': + resolution: {integrity: sha512-yiZo+UBCp42FAYqSakgVj/g6LfTH0AklWqIZNEHfVDPaofledXdSu0QJM5yScWNg0hVMaNRXPt0DmuS1oIWW6g==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.218': - resolution: {integrity: sha512-Y6vFEnz6wwd+rYpVGsPqgeXZuZP7NvQg6vjkC+N6cs1+I41LdKjfs+F+WG3daIqgt+vJBz/EB5Q0PByABkwufA==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.219': + resolution: {integrity: sha512-lizasky9Xj0ouYrz/Akst573Sm8NO3k/0iGCeCDBoQKLnONbVFwc7sHq9x426Pfcllgb4C2Xyc79/1iOALh/9g==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.218': - resolution: {integrity: sha512-e47oi8dYWnS0wx2/F6FL3YhaD2HedMd2nhI/nududP4PBzytuXeaDE/sJ0eIqtUVkl6/bn5uBhtIEHHWoj37JQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.219': + resolution: {integrity: sha512-IhfG57/XorWOQTB6BVdjEFPGH0LwOWO6HnAi+FNuCeiC+XRFl9QI9AKY43opS0gbvJWRobZO865tHX5j7Ou+ow==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.218': - resolution: {integrity: sha512-gCq/i7yRXeuoQXidGQynuiw5AeczQXSIifxH5Vpr9OFDyHCBNotS1mQ8qutuclZeFpLOPkJ2ic/nvEgHJvfXdg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.219': + resolution: {integrity: sha512-yzXhEsT5XcKa8Dc3sM4D1CQ92/6YGmsBbAhRAdw0PYNkG8x6wbHqfzYZS491Bdjry4tSWemNFtGPNkHiWVo3Hg==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.218': - resolution: {integrity: sha512-D0UKI8jOpEsWO1A+i0DlIrgjXFKEZX3RrID2l49S4pShUlLsnSLJGMTPy4nQt6CCl5MzPqWiJDE/lCpImFTDcg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.219': + resolution: {integrity: sha512-L/cAT27t3FGP4GlJ16WpXMtaeqDu9olpOlIaqqmjElQp5Lknd8alJ3NIY/SlRAtHDvm4WXug74wnfBOSlwppkg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.218': - resolution: {integrity: sha512-pgE39WnKPPSRsiRJ2pm5NETxQgtVhrquqg7UByZxne0xQw2gNS0Qy+9ZumrsNBNlfrrnQCVDU1I2ks3ZLrEa8Q==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.219': + resolution: {integrity: sha512-Fl8Rb9K64jbpC/omIF9y828LIvwRmm1zhZgO8zjalTpjZR/0C31Kl+5FwAS9KkXXUXwmdHSY+82e9dyvBrbIRw==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.218': - resolution: {integrity: sha512-lbVOmgdzjBdSUCTbxElpoTxxP/u9jY8gpG/3cr5l1jSSvSY5FrANdRcze/z6gM330xo32SHH6WPGFd4+K0fMoQ==} + '@anthropic-ai/claude-agent-sdk@0.3.219': + resolution: {integrity: sha512-dJjzKxoyCQSEEEo9a0g3wBQprRMCpsqjmsNOZaGOWw+W+JHZsolDBhoYfoZ9BA8EdwgaJmbQUvhkAZjROUsBnQ==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -7366,44 +7366,44 @@ snapshots: package-manager-detector: 1.7.0 tinyexec: 1.2.4 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.218': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.218': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.218': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.218': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.218': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.218': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.218': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.218': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.219': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.218(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.219(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.218 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.218 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.218 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.218 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.218 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.218 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.218 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.218 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.219 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.219 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.219 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.219 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.219 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.219 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.219 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.219 '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 52097d9c0..52179ac6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,6 +52,16 @@ nodeLinker: hoisted minimumReleaseAgeExclude: # Claude steering relies on the current official SDK lifecycle controls. + - "@anthropic-ai/claude-agent-sdk@0.3.219" + - "@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219" + - "@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219" + - "@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.219" + - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.219" + - "@anthropic-ai/claude-agent-sdk-linux-x64@0.3.219" + - "@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.219" + - "@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.219" + - "@anthropic-ai/claude-agent-sdk-win32-x64@0.3.219" + # Retain the previous release while pnpm validates the pre-update lockfile. - "@anthropic-ai/claude-agent-sdk@0.3.218" - "@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.218" - "@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.218" diff --git a/src/renderer/components/providers/claude/index.test.tsx b/src/renderer/components/providers/claude/index.test.tsx index 4f3f494e4..5d305e94f 100644 --- a/src/renderer/components/providers/claude/index.test.tsx +++ b/src/renderer/components/providers/claude/index.test.tsx @@ -8,12 +8,14 @@ import "./index"; const capabilities = { models: [ + { id: "claude-opus-5", label: "Opus 5" }, { id: "claude-fable-5", label: "Fable 5" }, { id: "haiku", label: "Haiku" }, ], efforts: ["low", "medium", "high", "xHigh", "max", "ultracode"], defaultEffort: "high", modelEfforts: { + "claude-opus-5": ["low", "medium", "high", "xHigh", "max", "ultracode"], "claude-fable-5": ["low", "medium", "high", "xHigh", "max", "ultracode"], haiku: [], }, @@ -38,6 +40,21 @@ function isPermissionControl( } describe("Claude composer controls", () => { + it("allows auto permissions for Opus 5", () => { + const controls = getComposerControls("claude")?.({ + capabilities, + config: { model: "claude-opus-5" }, + isDisabled: false, + onConfigChange: () => undefined, + }); + + const permission = controls?.find(isPermissionControl); + expect(permission && "options" in permission ? permission.options : []).toContainEqual({ + id: "auto", + label: "Auto mode", + }); + }); + it("allows auto permissions for Fable 5", () => { const controls = getComposerControls("claude")?.({ capabilities, diff --git a/src/renderer/components/providers/claude/index.tsx b/src/renderer/components/providers/claude/index.tsx index cb336ed36..20c5e2e78 100644 --- a/src/renderer/components/providers/claude/index.tsx +++ b/src/renderer/components/providers/claude/index.tsx @@ -42,6 +42,7 @@ registerConflictResolverDefaults(PROVIDER_KIND, { // Filter it out for Haiku and other models that don't support it. const AUTO_CAPABLE_MODELS = new Set([ "sonnet", + "claude-opus-5", "claude-fable-5", "claude-opus-4-6", "claude-opus-4-7", diff --git a/src/supervisor/agents/claude/argv.test.ts b/src/supervisor/agents/claude/argv.test.ts index 742099ffc..0ae68fd65 100644 --- a/src/supervisor/agents/claude/argv.test.ts +++ b/src/supervisor/agents/claude/argv.test.ts @@ -3,6 +3,7 @@ import { applyClaudeContextSuffix } from "./argv"; describe("applyClaudeContextSuffix", () => { it("derives the suffix from contextSize for built-in models", () => { + expect(applyClaudeContextSuffix("claude-opus-5", "1m")).toBe("claude-opus-5[1m]"); expect(applyClaudeContextSuffix("claude-opus-4-8", "1m")).toBe("claude-opus-4-8[1m]"); expect(applyClaudeContextSuffix("claude-opus-4-8", "200k")).toBe("claude-opus-4-8"); expect(applyClaudeContextSuffix("claude-opus-4-8")).toBe("claude-opus-4-8"); diff --git a/src/supervisor/agents/claude/claude.test.ts b/src/supervisor/agents/claude/claude.test.ts index 435ec72bb..26782727b 100644 --- a/src/supervisor/agents/claude/claude.test.ts +++ b/src/supervisor/agents/claude/claude.test.ts @@ -162,8 +162,19 @@ describe("claudeCapabilities", () => { expect(claudeCapabilities.fastModels).not.toContain("claude-fable-5"); }); - it("lists Fable 5 first so it is the default for new threads", () => { - expect(claudeCapabilities.models[0]).toEqual({ id: "claude-fable-5", label: "Fable 5" }); + it("lists Opus 5 first at high effort so it is the default for new threads", () => { + expect(claudeCapabilities.models[0]).toEqual({ id: "claude-opus-5", label: "Opus 5" }); + expect(claudeCapabilities.defaultEffort).toBe("high"); + expect(claudeCapabilities.modelEfforts["claude-opus-5"]).toEqual([ + "low", + "medium", + "high", + "xHigh", + "max", + "ultracode", + ]); + expect(claudeCapabilities.modelContextSizes?.["claude-opus-5"]).toEqual(["1m"]); + expect(claudeCapabilities.fastModels).toContain("claude-opus-5"); }); it("surfaces Sonnet 5 with frontier effort tiers", () => { diff --git a/src/supervisor/agents/claude/detection.ts b/src/supervisor/agents/claude/detection.ts index 962a31636..98fb90214 100644 --- a/src/supervisor/agents/claude/detection.ts +++ b/src/supervisor/agents/claude/detection.ts @@ -1,7 +1,13 @@ import { compactAgentProviderMetadata, type AgentCapability } from "@/shared/contracts"; -import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import { readAgentCommandOutput, type DetectionSpec, type StatusProbeResult } from "../base"; import { getAgentProbeCwd } from "../probeCwd"; +import { + CLAUDE_BUILTIN_FAST_MODELS, + CLAUDE_BUILTIN_MODEL_CONTEXT_SIZES, + CLAUDE_BUILTIN_MODEL_EFFORTS, + CLAUDE_BUILTIN_MODELS, + CLAUDE_PREMIUM_EFFORT_TIERS, +} from "./models"; import { probeClaudeCapabilities } from "./probe"; /** Default `--permission-mode` when `ThreadConfig.approvalPolicy` is omitted. */ @@ -21,55 +27,20 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentCapability["slashCommands"] = [ }, ]; -/** Effort tiers shared by the frontier models (Opus 4.7/4.8 and Fable 5). */ -const PREMIUM_EFFORT_TIERS: string[] = [...CLAUDE_EFFORT_TIERS]; - -/** - * Master switch for the Fable 5 model. Flip to `false` to hide it from the - * model pickers while keeping its effort/context/auto metadata ready below. - */ -const FABLE_5_ENABLED = true; -const SONNET_5_MODEL_ID = "claude-sonnet-5"; - export const claudeCapabilities: AgentCapability = { - models: [ - ...(FABLE_5_ENABLED ? [{ id: "claude-fable-5", label: "Fable 5" }] : []), - { id: "claude-opus-4-8", label: "Opus 4.8" }, - { id: "claude-opus-4-7", label: "Opus 4.7" }, - { id: "claude-opus-4-6", label: "Opus 4.6" }, - { id: SONNET_5_MODEL_ID, label: "Sonnet 5" }, - { id: "haiku", label: "Haiku" }, - ], - efforts: PREMIUM_EFFORT_TIERS, + models: CLAUDE_BUILTIN_MODELS, + efforts: CLAUDE_PREMIUM_EFFORT_TIERS, defaultEffort: "high", - modelEfforts: { - "claude-fable-5": PREMIUM_EFFORT_TIERS, - "claude-opus-4-8": PREMIUM_EFFORT_TIERS, - "claude-opus-4-7": PREMIUM_EFFORT_TIERS, - "claude-opus-4-6": ["low", "medium", "high", "max"], - [SONNET_5_MODEL_ID]: PREMIUM_EFFORT_TIERS, - haiku: [], - }, + modelEfforts: CLAUDE_BUILTIN_MODEL_EFFORTS, contextSizes: [ { id: "200k", label: "200k" }, { id: "1m", label: "1M" }, ], // Order matters: the first entry is the per-model default. Frontier models // default to 1M (the long-context build users select these for). - modelContextSizes: { - "claude-fable-5": ["1m"], - "claude-opus-4-8": ["1m", "200k"], - "claude-opus-4-7": ["1m", "200k"], - "claude-opus-4-6": ["1m", "200k"], - [SONNET_5_MODEL_ID]: ["1m"], - // Legacy `sonnet` alias — dropped from the picker (replaced by Sonnet 5) but - // still used by commit-gen defaults and pre-rename threads, so keep it - // context-managed (200k default) for backward compatibility. Its presence - // here is what keeps it in CLAUDE_CONTEXT_MANAGED_MODEL_IDS below. - sonnet: ["200k", "1m"], - }, + modelContextSizes: CLAUDE_BUILTIN_MODEL_CONTEXT_SIZES, defaultContextSize: "200k", - fastModels: ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"], + fastModels: CLAUDE_BUILTIN_FAST_MODELS, modes: ["agent", "plan"], approvalPolicies: [ { id: "default", label: "Default" }, diff --git a/src/supervisor/agents/claude/models.ts b/src/supervisor/agents/claude/models.ts new file mode 100644 index 000000000..68008fca7 --- /dev/null +++ b/src/supervisor/agents/claude/models.ts @@ -0,0 +1,165 @@ +import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; +import type { AgentCapability } from "@/shared/contracts"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; + +const MIN_CLAUDE_OPUS_47_CLI = [2, 1, 111] as const; +const MIN_CLAUDE_OPUS_48_CLI = [2, 1, 154] as const; +const MIN_CLAUDE_FABLE_5_CLI = [2, 1, 170] as const; +const MIN_CLAUDE_SONNET_5_CLI = [2, 1, 197] as const; +const MIN_CLAUDE_OPUS_5_CLI = [2, 1, 219] as const; + +export const CLAUDE_OPUS_5_MODEL_ID = "claude-opus-5"; +export const CLAUDE_FABLE_5_MODEL_ID = "claude-fable-5"; +export const CLAUDE_OPUS_48_MODEL_ID = "claude-opus-4-8"; +export const CLAUDE_OPUS_47_MODEL_ID = "claude-opus-4-7"; +export const CLAUDE_SONNET_5_MODEL_ID = "claude-sonnet-5"; + +const CLAUDE_SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/; + +/** Effort choices Poracode exposes for Claude's current frontier models. */ +export const CLAUDE_PREMIUM_EFFORT_TIERS: string[] = [...CLAUDE_EFFORT_TIERS]; + +/** + * Built-in catalog of explicit Claude Code model ids. + * + * Order is significant: the first model is Poracode's default for new Claude + * threads and delegated runs. + */ +export const CLAUDE_BUILTIN_MODELS: AgentCapability["models"] = [ + { id: CLAUDE_OPUS_5_MODEL_ID, label: "Opus 5" }, + { id: CLAUDE_FABLE_5_MODEL_ID, label: "Fable 5" }, + { id: CLAUDE_OPUS_48_MODEL_ID, label: "Opus 4.8" }, + { id: CLAUDE_OPUS_47_MODEL_ID, label: "Opus 4.7" }, + { id: "claude-opus-4-6", label: "Opus 4.6" }, + { id: CLAUDE_SONNET_5_MODEL_ID, label: "Sonnet 5" }, + { id: "haiku", label: "Haiku" }, +]; + +export const CLAUDE_BUILTIN_MODEL_EFFORTS: AgentCapability["modelEfforts"] = { + [CLAUDE_OPUS_5_MODEL_ID]: CLAUDE_PREMIUM_EFFORT_TIERS, + [CLAUDE_FABLE_5_MODEL_ID]: CLAUDE_PREMIUM_EFFORT_TIERS, + [CLAUDE_OPUS_48_MODEL_ID]: CLAUDE_PREMIUM_EFFORT_TIERS, + [CLAUDE_OPUS_47_MODEL_ID]: CLAUDE_PREMIUM_EFFORT_TIERS, + "claude-opus-4-6": ["low", "medium", "high", "max"], + [CLAUDE_SONNET_5_MODEL_ID]: CLAUDE_PREMIUM_EFFORT_TIERS, + haiku: [], +}; + +export const CLAUDE_BUILTIN_MODEL_CONTEXT_SIZES: NonNullable = + { + [CLAUDE_OPUS_5_MODEL_ID]: ["1m"], + [CLAUDE_FABLE_5_MODEL_ID]: ["1m"], + [CLAUDE_OPUS_48_MODEL_ID]: ["1m", "200k"], + [CLAUDE_OPUS_47_MODEL_ID]: ["1m", "200k"], + "claude-opus-4-6": ["1m", "200k"], + [CLAUDE_SONNET_5_MODEL_ID]: ["1m"], + // Legacy `sonnet` alias retained for backward compatibility. + sonnet: ["200k", "1m"], + }; + +export const CLAUDE_BUILTIN_FAST_MODELS: NonNullable = [ + CLAUDE_OPUS_5_MODEL_ID, + CLAUDE_OPUS_48_MODEL_ID, + CLAUDE_OPUS_47_MODEL_ID, + "claude-opus-4-6", +]; + +function parseSemverTriplet(version: string): [number, number, number] | null { + const match = CLAUDE_SEMVER_RE.exec(version.trim()); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function semverGte( + actual: [number, number, number], + minimum: readonly [number, number, number], +): boolean { + if (actual[0] !== minimum[0]) return actual[0] > minimum[0]; + if (actual[1] !== minimum[1]) return actual[1] > minimum[1]; + return actual[2] >= minimum[2]; +} + +/** Hide model releases when the installed Claude Code predates their first catalog entry. */ +export function claudeCapabilitiesFromCliVersion( + version: string | undefined, +): Partial | undefined { + if (!version) return undefined; + const triplet = parseSemverTriplet(version); + if (!triplet) return undefined; + + const hiddenModelIds = new Set(); + if (!semverGte(triplet, MIN_CLAUDE_OPUS_5_CLI)) { + hiddenModelIds.add(CLAUDE_OPUS_5_MODEL_ID); + } + if (!semverGte(triplet, MIN_CLAUDE_FABLE_5_CLI)) { + hiddenModelIds.add(CLAUDE_FABLE_5_MODEL_ID); + } + if (!semverGte(triplet, MIN_CLAUDE_SONNET_5_CLI)) { + hiddenModelIds.add(CLAUDE_SONNET_5_MODEL_ID); + } + if (!semverGte(triplet, MIN_CLAUDE_OPUS_48_CLI)) { + hiddenModelIds.add(CLAUDE_OPUS_48_MODEL_ID); + } + if (!semverGte(triplet, MIN_CLAUDE_OPUS_47_CLI)) { + hiddenModelIds.add(CLAUDE_OPUS_47_MODEL_ID); + } + if (hiddenModelIds.size === 0) return undefined; + + const models = CLAUDE_BUILTIN_MODELS.filter((model) => !hiddenModelIds.has(model.id)); + const modelEfforts = { ...CLAUDE_BUILTIN_MODEL_EFFORTS }; + const modelContextSizes = { ...CLAUDE_BUILTIN_MODEL_CONTEXT_SIZES }; + for (const modelId of hiddenModelIds) { + delete modelEfforts[modelId]; + delete modelContextSizes[modelId]; + } + const fastModels = CLAUDE_BUILTIN_FAST_MODELS.filter((modelId) => !hiddenModelIds.has(modelId)); + return { models, modelEfforts, modelContextSizes, fastModels }; +} + +function poracodeEffortId(effort: string): string { + return effort === "xhigh" ? "xHigh" : effort; +} + +/** + * Overlay model-specific effort and Fast metadata reported by Claude Code. + * + * The CLI catalog intentionally shows only current aliases, while Poracode also + * keeps explicit prior model versions selectable. Start from the built-in map + * and update only entries the SDK actually reports so probing never erases the + * historical catalog. + */ +export function claudeCapabilitiesFromSdkModels( + sdkModels: readonly ModelInfo[] | undefined, +): Pick | undefined { + if (!sdkModels?.length) return undefined; + const knownModelIds = new Set(CLAUDE_BUILTIN_MODELS.map((model) => model.id)); + const modelEfforts = { ...CLAUDE_BUILTIN_MODEL_EFFORTS }; + const fastModels = new Set(CLAUDE_BUILTIN_FAST_MODELS); + let matched = false; + + for (const sdkModel of sdkModels) { + const modelId = [sdkModel.resolvedModel, sdkModel.value] + .filter((value): value is string => Boolean(value)) + .map((value) => value.replace(/\[[0-9]+[mk]\]$/i, "")) + .find((value) => knownModelIds.has(value)); + if (!modelId) continue; + matched = true; + + if (sdkModel.supportsEffort === false) { + modelEfforts[modelId] = []; + } else if (sdkModel.supportedEffortLevels?.length) { + const efforts = sdkModel.supportedEffortLevels.map(poracodeEffortId); + // Ultracode is Claude Code's xhigh + dynamic-workflow session preset. + if (efforts.includes("xHigh")) efforts.push("ultracode"); + modelEfforts[modelId] = efforts; + } + + if (sdkModel.supportsFastMode === true) { + fastModels.add(modelId); + } else if (sdkModel.supportsFastMode === false) { + fastModels.delete(modelId); + } + } + + return matched ? { modelEfforts, fastModels: [...fastModels] } : undefined; +} diff --git a/src/supervisor/agents/claude/probe.test.ts b/src/supervisor/agents/claude/probe.test.ts index 604ca308e..736c0d19e 100644 --- a/src/supervisor/agents/claude/probe.test.ts +++ b/src/supervisor/agents/claude/probe.test.ts @@ -36,6 +36,7 @@ import { probeClaudeCapabilities, win32PathToWslMount, } from "./probe"; +import { claudeCapabilitiesFromSdkModels } from "./models"; import { spawnClaudeProbeProcess } from "./sdkProbeProcess"; function epipeError(): NodeJS.ErrnoException { @@ -110,8 +111,9 @@ afterEach(() => { }); describe("claudeCapabilitiesFromCliVersion", () => { - it("hides Fable 5, Opus 4.7, Opus 4.8, and Sonnet 5 when CLI is below 2.1.111", () => { + it("hides Opus 5, Fable 5, Opus 4.7, Opus 4.8, and Sonnet 5 below 2.1.111", () => { const p = claudeCapabilitiesFromCliVersion("2.1.110"); + expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-5"); expect(p?.models?.map((m) => m.id)).not.toContain("claude-fable-5"); expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-4-7"); expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-4-8"); @@ -121,8 +123,9 @@ describe("claudeCapabilitiesFromCliVersion", () => { expect(p?.models?.map((m) => m.id)).toContain("claude-opus-4-6"); }); - it("hides Fable 5, Opus 4.8, and Sonnet 5 when CLI supports Opus 4.7 but not Opus 4.8", () => { + it("hides Opus 5, Fable 5, Opus 4.8, and Sonnet 5 at the Opus 4.7 boundary", () => { const p = claudeCapabilitiesFromCliVersion("2.1.153"); + expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-5"); expect(p?.models?.map((m) => m.id)).toContain("claude-opus-4-7"); expect(p?.models?.map((m) => m.id)).toContain("claude-opus-4-6"); expect(p?.models?.map((m) => m.id)).not.toContain("claude-fable-5"); @@ -130,8 +133,9 @@ describe("claudeCapabilitiesFromCliVersion", () => { expect(p?.models?.map((m) => m.id)).not.toContain("claude-sonnet-5"); }); - it("hides Fable 5 and Sonnet 5 when CLI supports Opus 4.8 but not Fable 5", () => { + it("hides Opus 5, Fable 5, and Sonnet 5 at the Opus 4.8 boundary", () => { const p = claudeCapabilitiesFromCliVersion("2.1.169"); + expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-5"); expect(p?.models?.map((m) => m.id)).toContain("claude-opus-4-8"); expect(p?.models?.map((m) => m.id)).toContain("claude-opus-4-7"); expect(p?.models?.map((m) => m.id)).not.toContain("claude-fable-5"); @@ -140,16 +144,27 @@ describe("claudeCapabilitiesFromCliVersion", () => { expect(p?.modelContextSizes && "claude-fable-5" in p.modelContextSizes).toBe(false); }); - it("hides only Sonnet 5 when CLI supports Fable 5 but not Sonnet 5", () => { + it("hides Opus 5 and Sonnet 5 when CLI supports Fable 5 but not Sonnet 5", () => { const p = claudeCapabilitiesFromCliVersion("2.1.196"); + expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-5"); expect(p?.models?.map((m) => m.id)).toContain("claude-fable-5"); expect(p?.models?.map((m) => m.id)).not.toContain("claude-sonnet-5"); expect(p?.modelEfforts && "claude-sonnet-5" in p.modelEfforts).toBe(false); expect(p?.modelContextSizes && "claude-sonnet-5" in p.modelContextSizes).toBe(false); }); - it("returns undefined when CLI supports Sonnet 5", () => { - expect(claudeCapabilitiesFromCliVersion("2.1.197")).toBeUndefined(); + it("hides only Opus 5 after Sonnet 5 and before Claude Code 2.1.219", () => { + const p = claudeCapabilitiesFromCliVersion("2.1.218"); + expect(p?.models?.map((m) => m.id)).not.toContain("claude-opus-5"); + expect(p?.models?.map((m) => m.id)).toContain("claude-fable-5"); + expect(p?.models?.map((m) => m.id)).toContain("claude-sonnet-5"); + expect(p?.modelEfforts && "claude-opus-5" in p.modelEfforts).toBe(false); + expect(p?.modelContextSizes && "claude-opus-5" in p.modelContextSizes).toBe(false); + expect(p?.fastModels).not.toContain("claude-opus-5"); + }); + + it("returns undefined when CLI supports Opus 5", () => { + expect(claudeCapabilitiesFromCliVersion("2.1.219")).toBeUndefined(); expect(claudeCapabilitiesFromCliVersion("3.0.0")).toBeUndefined(); }); @@ -160,6 +175,48 @@ describe("claudeCapabilitiesFromCliVersion", () => { }); }); +describe("claudeCapabilitiesFromSdkModels", () => { + it("maps the Claude Code 2.1.219 Opus 5 capability payload", () => { + const capabilities = claudeCapabilitiesFromSdkModels([ + { + value: "default", + resolvedModel: "claude-opus-5[1m]", + displayName: "Default (recommended)", + description: "Opus 5 with 1M context", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + supportsAutoMode: true, + }, + ]); + + expect(capabilities?.modelEfforts["claude-opus-5"]).toEqual([ + "low", + "medium", + "high", + "xHigh", + "max", + "ultracode", + ]); + expect(capabilities?.fastModels).toContain("claude-opus-5"); + }); + + it("does not erase explicit historical models absent from the current SDK catalog", () => { + const capabilities = claudeCapabilitiesFromSdkModels([ + { + value: "haiku", + resolvedModel: "claude-haiku-4-5-20251001", + displayName: "Haiku", + description: "Fastest for quick answers", + }, + ]); + + expect(capabilities?.modelEfforts["claude-opus-4-8"]).toContain("high"); + expect(capabilities?.fastModels).toContain("claude-opus-4-8"); + }); +}); + describe("win32PathToWslMount", () => { it("maps drive letters to /mnt", () => { expect(win32PathToWslMount("C:\\Users\\x\\app\\worker.mjs")).toBe( diff --git a/src/supervisor/agents/claude/probe.ts b/src/supervisor/agents/claude/probe.ts index 1ab3dc8a8..7fa1da955 100644 --- a/src/supervisor/agents/claude/probe.ts +++ b/src/supervisor/agents/claude/probe.ts @@ -1,6 +1,5 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import type { AgentCapability, AgentTerminalAuthMethod } from "@/shared/contracts"; import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; import { @@ -11,9 +10,12 @@ import { import { CLAUDE_FAST_MODE_DISABLED_MESSAGE } from "./detection"; import { resolveFastModeCachePath } from "./fastModeCache"; import { resolveFastAvailability } from "./fastModeProbe"; +import { claudeCapabilitiesFromCliVersion, claudeCapabilitiesFromSdkModels } from "./models"; import { AsyncPromptQueue } from "./promptQueue"; import { spawnClaudeProbeProcess } from "./sdkProbeProcess"; +export { claudeCapabilitiesFromCliVersion } from "./models"; + const CLAUDE_TERMINAL_AUTH_METHOD: AgentTerminalAuthMethod = { type: "terminal", id: "claude-login", @@ -25,93 +27,6 @@ export function claudeTerminalAuthMethod(env?: Record): AgentTer return env ? { ...CLAUDE_TERMINAL_AUTH_METHOD, env } : CLAUDE_TERMINAL_AUTH_METHOD; } -const MIN_CLAUDE_OPUS_47_CLI = [2, 1, 111] as const; -const MIN_CLAUDE_OPUS_48_CLI = [2, 1, 154] as const; -const MIN_CLAUDE_FABLE_5_CLI = [2, 1, 170] as const; -const MIN_CLAUDE_SONNET_5_CLI = [2, 1, 197] as const; -const OPUS_48_MODEL_ID = "claude-opus-4-8"; -const OPUS_47_MODEL_ID = "claude-opus-4-7"; -const FABLE_5_MODEL_ID = "claude-fable-5"; -const SONNET_5_MODEL_ID = "claude-sonnet-5"; - -const CLAUDE_SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/; - -/** Built-in catalog (CLI `--model` ids) merged with semver gate + SDK slash commands. */ -const BUILTIN_MODELS: AgentCapability["models"] = [ - { id: FABLE_5_MODEL_ID, label: "Fable 5" }, - { id: OPUS_48_MODEL_ID, label: "Opus 4.8" }, - { id: OPUS_47_MODEL_ID, label: "Opus 4.7" }, - { id: "claude-opus-4-6", label: "Opus 4.6" }, - { id: SONNET_5_MODEL_ID, label: "Sonnet 5" }, - { id: "haiku", label: "Haiku" }, -]; - -/** Effort tiers shared by the frontier models (Opus 4.7/4.8 and Fable 5). */ -const PREMIUM_EFFORT_TIERS: string[] = [...CLAUDE_EFFORT_TIERS]; - -const BUILTIN_MODEL_EFFORTS: AgentCapability["modelEfforts"] = { - [FABLE_5_MODEL_ID]: PREMIUM_EFFORT_TIERS, - [OPUS_48_MODEL_ID]: PREMIUM_EFFORT_TIERS, - [OPUS_47_MODEL_ID]: PREMIUM_EFFORT_TIERS, - "claude-opus-4-6": ["low", "medium", "high", "max"], - [SONNET_5_MODEL_ID]: PREMIUM_EFFORT_TIERS, - haiku: [], -}; - -const BUILTIN_MODEL_CONTEXT_SIZES: NonNullable = { - [FABLE_5_MODEL_ID]: ["1m"], - [OPUS_48_MODEL_ID]: ["1m", "200k"], - [OPUS_47_MODEL_ID]: ["1m", "200k"], - "claude-opus-4-6": ["1m", "200k"], - [SONNET_5_MODEL_ID]: ["1m"], - // Legacy `sonnet` alias retained for backward compatibility — see detection.ts. - sonnet: ["200k", "1m"], -}; - -const BUILTIN_FAST_MODELS: NonNullable = [ - OPUS_48_MODEL_ID, - OPUS_47_MODEL_ID, - "claude-opus-4-6", -]; - -function parseSemverTriplet(version: string): [number, number, number] | null { - const m = CLAUDE_SEMVER_RE.exec(version.trim()); - if (!m) return null; - return [Number(m[1]), Number(m[2]), Number(m[3])]; -} - -function semverGte(a: [number, number, number], b: readonly [number, number, number]): boolean { - if (a[0] !== b[0]) return a[0] > b[0]; - if (a[1] !== b[1]) return a[1] > b[1]; - return a[2] >= b[2]; -} - -/** Hide Opus releases when the installed CLI is older than Anthropic's minimum for that model. */ -export function claudeCapabilitiesFromCliVersion( - version: string | undefined, -): Partial | undefined { - if (!version) return undefined; - const triplet = parseSemverTriplet(version); - if (!triplet) return undefined; - - const hiddenModelIds = new Set(); - if (!semverGte(triplet, MIN_CLAUDE_FABLE_5_CLI)) hiddenModelIds.add(FABLE_5_MODEL_ID); - if (!semverGte(triplet, MIN_CLAUDE_SONNET_5_CLI)) hiddenModelIds.add(SONNET_5_MODEL_ID); - if (!semverGte(triplet, MIN_CLAUDE_OPUS_48_CLI)) hiddenModelIds.add(OPUS_48_MODEL_ID); - if (!semverGte(triplet, MIN_CLAUDE_OPUS_47_CLI)) hiddenModelIds.add(OPUS_47_MODEL_ID); - if (hiddenModelIds.size === 0) return undefined; - - const models = BUILTIN_MODELS.filter((m) => !hiddenModelIds.has(m.id)); - const modelEfforts = { ...BUILTIN_MODEL_EFFORTS }; - const modelContextSizes = { ...BUILTIN_MODEL_CONTEXT_SIZES }; - for (const modelId of hiddenModelIds) { - delete modelEfforts[modelId]; - delete modelContextSizes[modelId]; - } - const fastModels = BUILTIN_FAST_MODELS.filter((modelId) => !hiddenModelIds.has(modelId)); - return { models, modelEfforts, modelContextSizes, fastModels }; -} - export function mapClaudeSlashCommands( commands: readonly SlashCommand[], ): NonNullable { @@ -181,6 +96,7 @@ async function probeClaudeSdkPartialNative( }); const init = await q.initializationResult(); const slashCommands = mapClaudeSlashCommands(init.commands); + const modelCapabilities = claudeCapabilitiesFromSdkModels(init.models); const fastAvailable = await resolveFastAvailability( q, queue, @@ -196,8 +112,9 @@ async function probeClaudeSdkPartialNative( // ignore } abort.abort(); - if (slashCommands.length === 0 && !fastDisabledReason) return undefined; + if (slashCommands.length === 0 && !fastDisabledReason && !modelCapabilities) return undefined; return { + ...(modelCapabilities ?? {}), ...(slashCommands.length > 0 ? { slashCommands } : {}), ...(fastDisabledReason ? { fastDisabledReason } : {}), }; @@ -252,6 +169,8 @@ async function probeClaudeSdkPartialWsl( try { const parsed = JSON.parse(result.stdout) as { slashCommands?: AgentCapability["slashCommands"]; + modelEfforts?: AgentCapability["modelEfforts"]; + fastModels?: AgentCapability["fastModels"]; fastAvailable?: boolean; error?: string; }; @@ -261,8 +180,14 @@ async function probeClaudeSdkPartialWsl( } const fastDisabledReason = parsed.fastAvailable === false ? CLAUDE_FAST_MODE_DISABLED_MESSAGE : undefined; - if (!parsed.slashCommands?.length && !fastDisabledReason) return undefined; + const hasModelCapabilities = + parsed.modelEfforts !== undefined || parsed.fastModels !== undefined; + if (!parsed.slashCommands?.length && !fastDisabledReason && !hasModelCapabilities) { + return undefined; + } return { + ...(parsed.modelEfforts ? { modelEfforts: parsed.modelEfforts } : {}), + ...(parsed.fastModels ? { fastModels: parsed.fastModels } : {}), ...(parsed.slashCommands?.length ? { slashCommands: parsed.slashCommands } : {}), ...(fastDisabledReason ? { fastDisabledReason } : {}), }; diff --git a/src/supervisor/agents/claude/sdkProbeWorker.ts b/src/supervisor/agents/claude/sdkProbeWorker.ts index ca2e9a5ee..def05296d 100644 --- a/src/supervisor/agents/claude/sdkProbeWorker.ts +++ b/src/supervisor/agents/claude/sdkProbeWorker.ts @@ -12,6 +12,7 @@ import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; import { AsyncPromptQueue } from "./promptQueue"; import { resolveFastAvailability } from "./fastModeProbe"; import { spawnClaudeProbeProcess } from "./sdkProbeProcess"; +import { claudeCapabilitiesFromSdkModels } from "./models"; function mapCommands(commands: SlashCommand[]) { return commands.map((c) => ({ @@ -54,11 +55,13 @@ async function main() { const init = await q.initializationResult(); const slashCommands = mapCommands(init.commands); + const modelCapabilities = claudeCapabilitiesFromSdkModels(init.models); const fastAvailable = cachePath ? await resolveFastAvailability(q, queue, init.account?.email, cachePath) : undefined; const payload = { + ...(modelCapabilities ?? {}), slashCommands, ...(fastAvailable !== undefined ? { fastAvailable } : {}), };