From c614a0e151df32146cf09e12b9d9c84482b18024 Mon Sep 17 00:00:00 2001 From: "charlie.goldstraw" Date: Tue, 27 Oct 2026 16:09:19 +0000 Subject: [PATCH] Show what Auto effort resolves to in the agents frame A launch with effort "Auto" passes no `--effort` or `model_reasoning_effort`, so the agent picks its own level. The agents frame did not say which level that is, so an extension's model picker could not place Auto anywhere. - Each model in the `agents` frame, and in the client `api.agents()`, has an optional `autoEffort`: the level the agent runs that model at when no effort is passed. It is left out when unknown. - Claude: resolved in Claude Code's documented order using static reads only. - `CLAUDE_CODE_EFFORT_LEVEL` from a settings `env` block. Launches strip it from the shell environment, so a shell export does not count. - Managed settings: server-managed `remote-settings.json`, the MDM plist, then `managed-settings.json` with its `managed-settings.d/` drop-ins. The first source with a policy key wins, unless it opts into `merge`. - User settings. A `modelSettings` entry beats the top-level `effortLevel`. The top-level key in user settings only reaches models older than Opus 5.5. - The model's documented default: medium for Opus, Sonnet and Haiku 5.5, xhigh for Opus 4.7, high otherwise. - The lowest `maxEffortLevel` cap from any source then applies. - Codex: the top-level `model_reasoning_effort` in `config.toml`, then the model's `default_reasoning_level` from `codex debug models`. That level is now kept in the catalog and the bundled snapshot. - The frame is re-sent when a source file changes (a stat poll every 15s; files are re-read only when their mtime or size moves) and on a catalog refresh. - Host API 1.49.0 documents `autoEffort`. Validation: - New `server/agents/auto-effort.test.js` and a `codex-catalog.test.js` case. Full `npm test`: 4,659 passed. `npm run docs:check` passed. - On an isolated dev board, the `agents` frame showed opus `medium`, fable `high`, and each Codex model at `config.toml`'s level. That matches the effort recorded in 478 earlier Auto-effort Opus transcripts. Made with Agent Wrangler and the PortSwigger/wrangler-squared extension --- docs/extensions.md | 11 +- public/slots.js | 4 +- server/agents/auto-effort.js | 252 +++++++++++++++++++++++ server/agents/auto-effort.test.js | 131 ++++++++++++ server/agents/claude.js | 11 +- server/agents/codex-catalog.js | 7 + server/agents/codex-catalog.test.js | 13 +- server/agents/codex-models.snapshot.json | 11 + server/host-api/version.js | 8 +- server/host-api/version.test.js | 8 +- server/index.js | 7 +- server/price-catalog.js | 17 +- 12 files changed, 464 insertions(+), 16 deletions(-) create mode 100644 server/agents/auto-effort.js create mode 100644 server/agents/auto-effort.test.js diff --git a/docs/extensions.md b/docs/extensions.md index 24b3b4a3..12c3e49e 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -1183,11 +1183,16 @@ The remainder is the maintainer reference. Read it before changing `server/exten the panel never opened (a refused restore) or a later `openSession`/`openDiff` replaced the request first. - **`api.agents()`** (1.25.0) returns the dispatch dialog's agent list — - `[{ id, label, models: [{ value, label, default }], efforts: [{ value, label }] }]`, + `[{ id, label, models: [{ value, label, default, autoEffort }], efforts: [{ value, label }] }]`, the same list core's model select reads, sourced from the live model and - price catalogs — as a fresh deep copy per call. **`api.agents.onChange(fn)`** + price catalogs — as a fresh deep copy per call. A model's `autoEffort` + (1.49.0) is the level the agent runs it at when a launch passes no effort + ("Auto"), read from the user's and organization's settings (Claude: managed + settings, a settings `env` block's `CLAUDE_CODE_EFFORT_LEVEL`, user settings, + then the model's documented default; Codex: `config.toml`, then the model's + catalog default), and is absent when unknown. **`api.agents.onChange(fn)`** fires (with its own copy) whenever the server re-sends the `agents` frame: on - connect, and when a catalog refresh changes it. It returns the unsubscribe + connect, and when a catalog refresh or one of those settings files changes it. It returns the unsubscribe function; subscriptions die with the extension, and a throwing listener is reported and kept. An extension with its own agent/model picker reads this instead of mirroring the vocabulary, which would drift on every model release. diff --git a/public/slots.js b/public/slots.js index a64f316a..bda47c05 100644 --- a/public/slots.js +++ b/public/slots.js @@ -431,7 +431,9 @@ export function createSlots({ document, storage, onError = (...a) => console.err // prefill from what Settings holds right now. Unset keys are // absent, exactly as `host.settings` reads them server-side. // agents — the dispatch dialog's agent list (1.25.0): `[{ id, label, - // models: [{ value, label, default }], efforts }]`, the same + // models: [{ value, label, default, autoEffort }], efforts }]` + // (`autoEffort`, 1.49.0: the level the agent runs that model at + // when no effort is passed, absent when unknown), the same // live-catalog list the board's own model select reads, as a // fresh deep copy per call. `agents.onChange(fn)` fires when the // server re-sends it (on connect, and on a catalog refresh), so diff --git a/server/agents/auto-effort.js b/server/agents/auto-effort.js new file mode 100644 index 00000000..c58c911d --- /dev/null +++ b/server/agents/auto-effort.js @@ -0,0 +1,252 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { claudeModelId } from './claude.js'; +import { codexDefaultEffort } from './codex-catalog.js'; +import { codexHome } from '../codex-trust.js'; + +// "Auto" effort launches pass no --effort / model_reasoning_effort, so the agent +// picks its own level from the user's and organization's config. This resolves +// that level per model from static reads alone — never by launching the agent — +// so the dispatch dialog (and api.agents()) can say what Auto means here. Null +// when unknown: an honest blank beats a guess. + +const LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; +const CLAUDE_SETTINGS_LEVELS = ['low', 'medium', 'high', 'xhigh']; +const CLAUDE_ENV_LEVELS = [...CLAUDE_SETTINGS_LEVELS, 'max']; + +// Claude Code's documented defaults (model-config docs, "Adjust effort level"), +// by full model id. `userTopLevel`: a top-level effortLevel in the USER settings +// file still applies to the model — Opus 5.5 and every model released after it +// ignore that legacy key, so an id missing here ignores it too. +const CLAUDE_BUILTIN = { + 'claude-opus-5-5': { level: 'medium' }, + 'claude-sonnet-5-5': { level: 'medium' }, + 'claude-haiku-5-5': { level: 'medium' }, + 'claude-fable-5-1': { level: 'high', userTopLevel: true }, + 'claude-fable-5': { level: 'high', userTopLevel: true }, + 'claude-opus-5': { level: 'high', userTopLevel: true }, + 'claude-sonnet-5': { level: 'high', userTopLevel: true }, + 'claude-opus-4-8': { level: 'high', userTopLevel: true }, + 'claude-opus-4-7': { level: 'xhigh', userTopLevel: true }, + 'claude-opus-4-6': { level: 'high', userTopLevel: true }, + 'claude-sonnet-4-6': { level: 'high', userTopLevel: true }, +}; + +const CONTROL_KEYS = new Set(['wslInheritsWindowsSettings', 'managedSourcesBehavior']); + +function claudeConfigDir(env = process.env) { + return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +} + +function managedDir(platform = process.platform) { + if (platform === 'darwin') return '/Library/Application Support/ClaudeCode'; + if (platform === 'linux') return '/etc/claude-code'; + return null; +} + +function mdmPlists(platform = process.platform) { + if (platform !== 'darwin') return []; + const base = '/Library/Managed Preferences'; + let user = null; + try { user = os.userInfo().username; } catch { /* no passwd entry */ } + return [user && path.join(base, user, 'com.anthropic.claudecode.plist'), path.join(base, 'com.anthropic.claudecode.plist')].filter(Boolean); +} + +// Every file a resolution reads, so a change to any of them is seen. +export function defaultSourcePaths({ env = process.env, platform = process.platform } = {}) { + const dir = managedDir(platform); + return { + remote: path.join(claudeConfigDir(env), 'remote-settings.json'), + mdm: mdmPlists(platform), + managedFile: dir && path.join(dir, 'managed-settings.json'), + managedDropIns: dir && path.join(dir, 'managed-settings.d'), + user: path.join(claudeConfigDir(env), 'settings.json'), + codexConfig: path.join(codexHome(), 'config.toml'), + }; +} + +// Parsed file contents, re-read only when the file's mtime or size moves. +const fileMemo = new Map(); + +function statKey(file) { + try { + const st = fs.statSync(file); + return `${st.mtimeMs}:${st.size}`; + } catch { + return null; + } +} + +function readMemo(file, parse) { + if (!file) return null; + const key = statKey(file); + if (key == null) return null; + const hit = fileMemo.get(file); + if (hit?.key === key) return hit.value; + let value = null; + try { value = parse(file); } catch { value = null; } + fileMemo.set(file, { key, value }); + return value; +} + +const readJson = (file) => readMemo(file, (f) => { + const v = JSON.parse(fs.readFileSync(f, 'utf8')); + return v && typeof v === 'object' && !Array.isArray(v) ? v : null; +}); + +// A binary MDM plist has no JS parser in reach; plutil ships with macOS, and the +// memo means it only runs when the profile itself changes. +const readPlist = (file) => readMemo(file, (f) => JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', f], { encoding: 'utf8', timeout: 5000 }))); + +function dropIns(dir) { + if (!dir) return []; + try { + return fs.readdirSync(dir).filter((n) => n.endsWith('.json') && !n.startsWith('.')).sort().map((n) => path.join(dir, n)); + } catch { + return []; + } +} + +// Nested blocks merge key by key; anything else, the later file wins. +function deepMerge(a, b) { + const out = { ...a }; + for (const [k, v] of Object.entries(b || {})) { + out[k] = v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k]) + ? deepMerge(out[k], v) + : v; + } + return out; +} + +// The settings documents Claude Code would read, as { admin, user }: `admin` is +// the managed sources in Claude Code's own rank order (server-managed, MDM, +// managed-settings.json merged with its drop-ins), absent ones as null. +export function readClaudeSources(paths = defaultSourcePaths()) { + const mdm = paths.mdm.map(readPlist).find(Boolean) || null; + const files = [paths.managedFile, ...dropIns(paths.managedDropIns)].map(readJson).filter(Boolean); + return { + admin: [readJson(paths.remote), mdm, files.length ? files.reduce(deepMerge, {}) : null], + user: readJson(paths.user), + }; +} + +function hasPolicyKey(doc) { + return !!doc && Object.entries(doc).some(([k, v]) => !CONTROL_KEYS.has(k) && v !== null); +} + +// A modelSettings entry is keyed by canonical id, and also matches the alias, +// a date-suffixed id and the [1m] variant. +function modelEntry(doc, id, value) { + const settings = doc?.modelSettings; + if (!settings || typeof settings !== 'object') return null; + for (const [key, entry] of Object.entries(settings)) { + const bare = key.replace(/\[1m\]$/, '').replace(/-\d{8}$/, ''); + if (key === value || bare === id) return entry; + } + return null; +} + +const pick = (v, allowed) => (allowed.includes(v) ? v : null); +const rank = (level) => LEVELS.indexOf(level); + +// Claude Code's resolution order (model-config docs): CLAUDE_CODE_EFFORT_LEVEL, +// then the highest settings file that sets a level for the model, then the +// model's default; then the lowest maxEffortLevel cap from any source. The +// variable only counts from a settings `env` block: launches strip every +// CLAUDE_CODE_* from the wrangler's own environment (withCleanClaudeEnv). +// Project settings depend on the session's directory, so they are not read. +export function resolveClaudeAutoEffort(value, { admin = [], user = null } = {}) { + const id = claudeModelId(value); + if (!id) return null; + const builtin = CLAUDE_BUILTIN[id]; + + const envDoc = [...admin, user].find((d) => pick(d?.env?.CLAUDE_CODE_EFFORT_LEVEL, CLAUDE_ENV_LEVELS)); + let level = envDoc ? envDoc.env.CLAUDE_CODE_EFFORT_LEVEL : null; + + if (!level) { + // First-wins by default: only the highest managed source with a policy key + // applies, unless that source opts every admin source in with "merge". + const present = admin.filter(hasPolicyKey); + const managed = present[0]?.managedSourcesBehavior === 'merge' ? present : present.slice(0, 1); + for (const doc of managed) { + level = pick(modelEntry(doc, id, value)?.effortLevel, CLAUDE_SETTINGS_LEVELS) || pick(doc.effortLevel, CLAUDE_SETTINGS_LEVELS); + if (level) break; + } + } + if (!level && user) { + level = pick(modelEntry(user, id, value)?.effortLevel, CLAUDE_SETTINGS_LEVELS) + || (builtin?.userTopLevel ? pick(user.effortLevel, CLAUDE_SETTINGS_LEVELS) : null); + } + level ||= builtin?.level || null; + if (!level) return null; + + // A per-model cap replaces its source's top-level one; "max" sets none. + for (const doc of [...admin, user]) { + if (!doc) continue; + const cap = pick(modelEntry(doc, id, value)?.maxEffortLevel ?? doc.maxEffortLevel, CLAUDE_ENV_LEVELS); + if (cap && cap !== 'max' && rank(cap) < rank(level)) level = cap; + } + return level; +} + +// The top-level `model_reasoning_effort` in Codex's config.toml: only lines +// before the first [table] header are top-level keys. +export function parseCodexConfigEffort(text) { + for (const line of String(text).split('\n')) { + if (/^\s*\[/.test(line)) break; + const m = /^\s*model_reasoning_effort\s*=\s*["']([^"']*)["']/.exec(line); + if (m) return m[1] || null; + } + return null; +} + +// Codex: config.toml's level applies to every model, else each model's own +// default_reasoning_level from its catalog. +export function resolveCodexAutoEffort(slug, { configEffort = null } = {}) { + return configEffort || codexDefaultEffort(slug); +} + +function readCodexConfigEffort(file) { + return readMemo(file, (f) => parseCodexConfigEffort(fs.readFileSync(f, 'utf8'))); +} + +// The agents frame's models with `autoEffort` added where it is known. +export function withAutoEffort(agentId, models, paths = defaultSourcePaths()) { + let resolve; + if (agentId === 'claude') { + const sources = readClaudeSources(paths); + resolve = (value) => resolveClaudeAutoEffort(value, sources); + } else if (agentId === 'codex') { + const configEffort = readCodexConfigEffort(paths.codexConfig); + resolve = (value) => resolveCodexAutoEffort(value, { configEffort }); + } else { + return models; + } + return models.map((m) => { + const autoEffort = resolve(m.value); + return autoEffort ? { ...m, autoEffort } : m; + }); +} + +// A cheap fingerprint of every source file (mtimes and sizes, no reads). +export function sourcesSignature(paths = defaultSourcePaths()) { + const files = [paths.remote, ...paths.mdm, paths.managedFile, ...dropIns(paths.managedDropIns), paths.user, paths.codexConfig]; + return files.filter(Boolean).map((f) => `${f}=${statKey(f)}`).join('\n'); +} + +// Calls onChange when any source file changes, so open boards re-receive the +// agents frame. Polls stats rather than fs.watch: several of these files are +// replaced by rename, and some directories may not exist yet. +export function watchAutoEffortSources(onChange, intervalMs = 15_000) { + let last = sourcesSignature(); + const t = setInterval(() => { + const next = sourcesSignature(); + if (next === last) return; + last = next; + onChange(); + }, intervalMs); + t.unref?.(); + return t; +} diff --git a/server/agents/auto-effort.test.js b/server/agents/auto-effort.test.js new file mode 100644 index 00000000..6795a460 --- /dev/null +++ b/server/agents/auto-effort.test.js @@ -0,0 +1,131 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + resolveClaudeAutoEffort, resolveCodexAutoEffort, parseCodexConfigEffort, + readClaudeSources, withAutoEffort, sourcesSignature, +} from './auto-effort.js'; +import { claudeModelId } from './claude.js'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aw-auto-effort-')); +after(() => fs.rmSync(tmp, { recursive: true, force: true })); + +// A fresh set of source paths under its own dir, each file written only if given. +let n = 0; +function sources({ remote, managed, dropIns = {}, user, codex } = {}) { + const dir = path.join(tmp, String(n++)); + fs.mkdirSync(path.join(dir, 'managed-settings.d'), { recursive: true }); + const paths = { + remote: path.join(dir, 'remote-settings.json'), + mdm: [], + managedFile: path.join(dir, 'managed-settings.json'), + managedDropIns: path.join(dir, 'managed-settings.d'), + user: path.join(dir, 'settings.json'), + codexConfig: path.join(dir, 'config.toml'), + }; + const write = (file, v) => v !== undefined && fs.writeFileSync(file, typeof v === 'string' ? v : JSON.stringify(v)); + write(paths.remote, remote); + write(paths.managedFile, managed); + for (const [name, v] of Object.entries(dropIns)) write(path.join(paths.managedDropIns, name), v); + write(paths.user, user); + write(paths.codexConfig, codex); + return paths; +} + +const claude = (value, opts) => resolveClaudeAutoEffort(value, readClaudeSources(sources(opts))); + +test('an alias resolves to its family’s newest model id', () => { + assert.equal(claudeModelId('opus'), 'claude-opus-5-5'); + assert.equal(claudeModelId('claude-opus-4-7'), 'claude-opus-4-7'); + assert.equal(claudeModelId('opusplan'), null); +}); + +test('with nothing set, a model runs at its documented default', () => { + assert.equal(claude('opus'), 'medium'); + assert.equal(claude('claude-opus-4-7'), 'xhigh'); + assert.equal(claude('claude-fable-5-1'), 'high'); +}); + +test('a managed effortLevel overrides the user’s', () => { + const user = { effortLevel: 'high', modelSettings: { 'claude-opus-5-5': { effortLevel: 'xhigh' } } }; + assert.equal(claude('opus', { user }), 'xhigh'); + assert.equal(claude('opus', { user, managed: { effortLevel: 'low' } }), 'low'); + assert.equal(claude('opus', { user, remote: { effortLevel: 'low' } }), 'low'); + assert.equal(claude('claude-fable-5-1', { user, managed: { effortLevel: 'low' } }), 'low'); +}); + +test('a user top-level effortLevel only reaches models older than Opus 5.5', () => { + assert.equal(claude('opus', { user: { effortLevel: 'high' } }), 'medium'); + assert.equal(claude('claude-fable-5-1', { user: { effortLevel: 'xhigh' } }), 'xhigh'); + // An id with no documented default is unknown, never a guess from that key. + assert.equal(claude('claude-opus-9', { user: { effortLevel: 'high' } }), null); + assert.equal(claude('claude-opus-9', { user: { modelSettings: { 'claude-opus-9': { effortLevel: 'low' } } } }), 'low'); +}); + +test('a modelSettings entry matches its alias and date-suffixed id', () => { + assert.equal(claude('opus', { user: { modelSettings: { opus: { effortLevel: 'low' } } } }), 'low'); + assert.equal(claude('opus', { user: { modelSettings: { 'claude-opus-5-5-20260101': { effortLevel: 'xhigh' } } } }), 'xhigh'); + assert.equal(claude('opus', { user: { modelSettings: { 'claude-opus-5': { effortLevel: 'xhigh' } } } }), 'medium'); +}); + +test('CLAUDE_CODE_EFFORT_LEVEL in a settings env block beats every settings file', () => { + const managed = { effortLevel: 'low', env: { CLAUDE_CODE_EFFORT_LEVEL: 'max' } }; + assert.equal(claude('opus', { managed }), 'max'); + assert.equal(claude('opus', { managed: { effortLevel: 'low' }, user: { env: { CLAUDE_CODE_EFFORT_LEVEL: 'xhigh' } } }), 'xhigh'); + assert.equal(claude('opus', { user: { env: { CLAUDE_CODE_EFFORT_LEVEL: 'auto' } } }), 'medium'); +}); + +test('only the highest managed source with a policy key applies, unless it opts into merge', () => { + assert.equal(claude('opus', { remote: { channelsEnabled: true }, managed: { effortLevel: 'low' } }), 'medium'); + assert.equal(claude('opus', { remote: { managedSourcesBehavior: 'first-wins' }, managed: { effortLevel: 'low' } }), 'low'); + assert.equal(claude('opus', { remote: { channelsEnabled: true, managedSourcesBehavior: 'merge' }, managed: { effortLevel: 'low' } }), 'low'); +}); + +test('managed-settings.d drop-ins merge over managed-settings.json in name order', () => { + const dropIns = { '20-b.json': { effortLevel: 'xhigh' }, '10-a.json': { effortLevel: 'low' }, '.hidden.json': { effortLevel: 'max' } }; + assert.equal(claude('opus', { managed: { effortLevel: 'high' }, dropIns }), 'xhigh'); +}); + +test('the lowest maxEffortLevel from any source caps the level', () => { + assert.equal(claude('opus', { managed: { effortLevel: 'xhigh' }, user: { maxEffortLevel: 'high' } }), 'high'); + assert.equal(claude('claude-opus-4-7', { remote: { maxEffortLevel: 'medium' } }), 'medium'); + // A model's own cap replaces its source's top-level one; "max" exempts it. + const managed = { effortLevel: 'xhigh', maxEffortLevel: 'low', modelSettings: { 'claude-opus-5-5': { maxEffortLevel: 'max' } } }; + assert.equal(claude('opus', { managed }), 'xhigh'); +}); + +test('unreadable or missing files leave the defaults standing', () => { + assert.equal(claude('opus', { managed: '{not json', user: '[]' }), 'medium'); +}); + +test('Codex: config.toml’s top-level level wins over the model’s catalog default', () => { + assert.equal(parseCodexConfigEffort('model = "gpt-6-sol"\nmodel_reasoning_effort = "high"\n'), 'high'); + assert.equal(parseCodexConfigEffort('model = "x"\n[profiles.fast]\nmodel_reasoning_effort = "low"\n'), null); + assert.equal(resolveCodexAutoEffort('gpt-6-astra'), 'low'); + assert.equal(resolveCodexAutoEffort('gpt-6-astra', { configEffort: 'high' }), 'high'); + assert.equal(resolveCodexAutoEffort('gpt-unknown'), null); +}); + +test('withAutoEffort adds autoEffort where known and leaves the rest as they were', () => { + const paths = sources({ user: { modelSettings: { 'claude-opus-5-5': { effortLevel: 'high' } } }, codex: 'model_reasoning_effort = "xhigh"\n' }); + const claudeModels = [{ value: 'opus', label: 'Opus', default: true }, { value: 'opusplan', label: 'Plan' }]; + assert.deepEqual(withAutoEffort('claude', claudeModels, paths), [ + { value: 'opus', label: 'Opus', default: true, autoEffort: 'high' }, + { value: 'opusplan', label: 'Plan' }, + ]); + assert.deepEqual(withAutoEffort('codex', [{ value: 'gpt-6-sol' }], paths), [{ value: 'gpt-6-sol', autoEffort: 'xhigh' }]); + assert.deepEqual(withAutoEffort('other', claudeModels, paths), claudeModels); +}); + +test('a settings change is re-read and changes the sources signature', () => { + const paths = sources({ user: { effortLevel: 'high' } }); + const before = sourcesSignature(paths); + assert.equal(withAutoEffort('claude', [{ value: 'opus' }], paths)[0].autoEffort, 'medium'); + fs.writeFileSync(paths.user, JSON.stringify({ modelSettings: { 'claude-opus-5-5': { effortLevel: 'xhigh' } } })); + assert.notEqual(sourcesSignature(paths), before); + assert.equal(withAutoEffort('claude', [{ value: 'opus' }], paths)[0].autoEffort, 'xhigh'); + fs.writeFileSync(paths.managedFile, JSON.stringify({ effortLevel: 'low' })); + assert.equal(withAutoEffort('claude', [{ value: 'opus' }], paths)[0].autoEffort, 'low'); +}); diff --git a/server/agents/claude.js b/server/agents/claude.js index d190a6d7..1c3e389e 100644 --- a/server/agents/claude.js +++ b/server/agents/claude.js @@ -7,7 +7,7 @@ import { liveState } from '../claude-paths.js'; import { worktreeGuardrailPrompt } from '../worktree.js'; import { claudeMcpConfigArg, allowedToolsArg, prAttachUrl } from '../mcp/client-config.js'; import { AGENT_SKILLS_PLUGIN_DIR, extensionSkillPluginDirs, mandatorySkillPrompt } from '../agent-skills.js'; -import { newestClaudeName, priceCatalogVersion, snapshotClaudeFamilies } from '../price-catalog.js'; +import { newestClaudeId, newestClaudeName, priceCatalogVersion, snapshotClaudeFamilies } from '../price-catalog.js'; // The autopilot issue-to-pr skill ships in-repo (skills/issue-to-pr) and is loaded // as a plugin only on workflow launches (below), so it's available no matter which @@ -181,6 +181,15 @@ export function derivedModels(families = snapshotClaudeFamilies()) { })); } +// The full model id a launch value runs today (opus → claude-opus-5-5): an alias +// resolves to the price catalog's newest model of its family, the same one its +// label names; a derived entry already is one. Null for opusplan, which runs two. +export function claudeModelId(value) { + const family = MODELS.find((m) => m.value === value)?.family; + if (family) return newestClaudeId(family); + return /^claude-/.test(value || '') ? value : null; +} + let modelsMemo = null; // { version, models } function claudeModels() { diff --git a/server/agents/codex-catalog.js b/server/agents/codex-catalog.js index 93d54525..52bbc75d 100644 --- a/server/agents/codex-catalog.js +++ b/server/agents/codex-catalog.js @@ -33,6 +33,7 @@ export function reduceCodexCatalog(raw) { visibility: m.visibility || 'list', priority: Number.isFinite(m.priority) ? m.priority : 1000, efforts: (m.supported_reasoning_levels || []).map((l) => l?.effort).filter(Boolean), + defaultEffort: typeof m.default_reasoning_level === 'string' ? m.default_reasoning_level : null, contextWindow: Number.isFinite(m.context_window) ? m.context_window : null, })) .sort((a, b) => a.priority - b.priority); @@ -101,6 +102,12 @@ export function liveCodexContextWindow(slug) { return live?.find((m) => m.slug === slug)?.contextWindow ?? null; } +// The effort Codex runs this model at when neither the launch nor config.toml +// names one (its catalog's default_reasoning_level), or null when unknown. +export function codexDefaultEffort(slug) { + return catalog().find((m) => m.slug === slug)?.defaultEffort ?? null; +} + // Bumps whenever a refresh changes the catalog — fold into caches of derived values. export function codexCatalogVersion() { return liveVersion; diff --git a/server/agents/codex-catalog.test.js b/server/agents/codex-catalog.test.js index 2ff3e742..b51a3610 100644 --- a/server/agents/codex-catalog.test.js +++ b/server/agents/codex-catalog.test.js @@ -2,7 +2,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { reduceCodexCatalog, refreshCodexCatalog, codexModels, codexEfforts, defaultCodexModel, - liveCodexContextWindow, onCodexCatalogChange, _resetCodexCatalogForTest, + liveCodexContextWindow, codexDefaultEffort, onCodexCatalogChange, _resetCodexCatalogForTest, } from './codex-catalog.js'; import { codex } from './codex.js'; @@ -24,10 +24,19 @@ test('reduceCodexCatalog keeps the fields we use, in Codex priority order', () = assert.deepEqual(out.map((m) => m.slug), ['gpt-9-astra', 'gpt-9-hidden', 'gpt-9-luna']); assert.deepEqual(out[0], { slug: 'gpt-9-astra', displayName: 'GPT-9-ASTRA', description: 'gpt-9-astra model.', visibility: 'list', - priority: 1, efforts: ['low', 'medium', 'high', 'xhigh', 'ultra'], contextWindow: 272_000, + priority: 1, efforts: ['low', 'medium', 'high', 'xhigh', 'ultra'], defaultEffort: null, contextWindow: 272_000, }); }); +test('a model’s default reasoning level is kept, and read back per model', async () => { + const raw = { models: [model('gpt-9-astra', 1, ['low', 'high'], { default_reasoning_level: 'high' })] }; + assert.equal(reduceCodexCatalog(raw)[0].defaultEffort, 'high'); + assert.equal(codexDefaultEffort('gpt-9-astra'), null); + await refreshCodexCatalog({ run: async () => raw }); + assert.equal(codexDefaultEffort('gpt-9-astra'), 'high'); + assert.equal(codexDefaultEffort('gpt-unknown'), null); +}); + test('the adapter lists only Codex-listed models once a refresh lands', async () => { assert.equal(await refreshCodexCatalog({ run: async () => RAW }), true); assert.deepEqual(codex.models.map((m) => m.value), ['gpt-9-astra', 'gpt-9-luna']); diff --git a/server/agents/codex-models.snapshot.json b/server/agents/codex-models.snapshot.json index 451fc7f5..7375d105 100644 --- a/server/agents/codex-models.snapshot.json +++ b/server/agents/codex-models.snapshot.json @@ -14,6 +14,7 @@ "max", "ultra" ], + "defaultEffort": "medium", "contextWindow": 272000 }, { @@ -30,6 +31,7 @@ "max", "ultra" ], + "defaultEffort": "low", "contextWindow": 272000 }, { @@ -46,6 +48,7 @@ "max", "ultra" ], + "defaultEffort": "medium", "contextWindow": 272000 }, { @@ -61,6 +64,7 @@ "xhigh", "max" ], + "defaultEffort": "medium", "contextWindow": 272000 }, { @@ -77,6 +81,7 @@ "max", "ultra" ], + "defaultEffort": "medium", "contextWindow": 272000 }, { @@ -93,6 +98,7 @@ "max", "ultra" ], + "defaultEffort": "medium", "contextWindow": 272000 }, { @@ -108,6 +114,7 @@ "xhigh", "max" ], + "defaultEffort": "medium", "contextWindow": 272000 }, { @@ -124,6 +131,7 @@ "max", "ultra" ], + "defaultEffort": null, "contextWindow": 272000 }, { @@ -140,6 +148,7 @@ "max", "ultra" ], + "defaultEffort": null, "contextWindow": 372000 }, { @@ -154,6 +163,7 @@ "high", "xhigh" ], + "defaultEffort": null, "contextWindow": 272000 }, { @@ -169,6 +179,7 @@ "xhigh", "max" ], + "defaultEffort": "medium", "contextWindow": 272000 } ] diff --git a/server/host-api/version.js b/server/host-api/version.js index 08379b1c..32f8aac0 100644 --- a/server/host-api/version.js +++ b/server/host-api/version.js @@ -374,7 +374,13 @@ import semver from 'semver'; // one-shot Claude runs merge the resulting env after cleaning the process env. // Extensions that depend on these launch surfaces declare `^1.48.0`. // -export const HOST_API_VERSION = '1.48.0'; +// 1.49.0 adds the optional `autoEffort` on each model of the client +// `api.agents()`: the effort level the agent uses for that model when a launch +// passes none ("Auto"), resolved from the user's and organization's settings, +// and absent when unknown. An older board never sets it, so a manifest that +// reads it declares `^1.49.0` or treats its absence as unknown. +// +export const HOST_API_VERSION = '1.49.0'; // Does this server serve `range`? A null/absent range is "no constraint" and // passes — declaring the range is optional, getting it wrong is not. diff --git a/server/host-api/version.test.js b/server/host-api/version.test.js index a8ea25ce..51f05963 100644 --- a/server/host-api/version.test.js +++ b/server/host-api/version.test.js @@ -53,14 +53,16 @@ test('HOST_API_VERSION is a real semver version', () => { // and 1.46.0 `theme` on a toggle setting def, // and 1.47.0 prepare context, launch refusal/fork parent, runtime ownLaunch and dispatch validation, // and 1.48.0 spawn extension data, hosted dispatch fields and headless launch environments, +// and 1.49.0 `autoEffort` on the client `api.agents()` models, // and a manifest declaring any of those // ranges is saying it needs that surface to exist. An additive change bumps the // minor and keeps every ^1.0.0 manifest served by the same builders. -test('the served version is 1.48.0, and every 1.x manifest range it can honour passes', () => { - assert.equal(HOST_API_VERSION, '1.48.0'); +test('the served version is 1.49.0, and every 1.x manifest range it can honour passes', () => { + assert.equal(HOST_API_VERSION, '1.49.0'); assert.equal(servesRange('^1.47.0'), true); assert.equal(servesRange('^1.48.0'), true); - assert.equal(servesRange('^1.49.0'), false); + assert.equal(servesRange('^1.49.0'), true); + assert.equal(servesRange('^1.50.0'), false); assert.equal(servesRange('^1.46.0'), true); assert.equal(servesRange('^1.45.0'), true); assert.equal(servesRange('^1.44.0'), true); diff --git a/server/index.js b/server/index.js index 265892f1..450925e1 100644 --- a/server/index.js +++ b/server/index.js @@ -72,6 +72,7 @@ import { installShutdownLog } from './shutdown-log.js'; import { restartSupported } from './control/handlers/restart.js'; import { startPriceCatalogRefresh, onPriceCatalogChange } from './price-catalog.js'; import { startCodexCatalogRefresh, onCodexCatalogChange } from './agents/codex-catalog.js'; +import { withAutoEffort, watchAutoEffortSources } from './agents/auto-effort.js'; import { readInstall, clearRollbackMarker, readRolledBack } from './self-update.js'; import { createUpdateService, updateSupport, installDriftStatus } from './update-service.js'; import { createExtensionUpdateCheck, currentExtensionUpdates } from './extensions/update-check.js'; @@ -1010,10 +1011,11 @@ function broadcastStylesIfChanged() { } // The dispatch dialog's agent/model/effort lists — sent on connect, and again to -// every board whenever a model or price catalog refresh changes them. +// every board whenever a model or price catalog refresh, or a settings file that +// decides a model's Auto effort (`autoEffort`), changes them. async function agentsMessage() { const list = await availableAgents(); - return JSON.stringify({ type: 'agents', agents: list.map((a) => ({ id: a.id, label: a.label, models: modelsWithDefault(a), efforts: a.efforts || [] })) }); + return JSON.stringify({ type: 'agents', agents: list.map((a) => ({ id: a.id, label: a.label, models: withAutoEffort(a.id, modelsWithDefault(a)), efforts: a.efforts || [] })) }); } function broadcastAgents() { @@ -1357,6 +1359,7 @@ async function main() { validateDefaultModel(); onPriceCatalogChange(broadcastAgents); onCodexCatalogChange(broadcastAgents); + watchAutoEffortSources(broadcastAgents); startPriceCatalogRefresh(); startCodexCatalogRefresh(); diff --git a/server/price-catalog.js b/server/price-catalog.js index 9271560a..579a659d 100644 --- a/server/price-catalog.js +++ b/server/price-catalog.js @@ -214,6 +214,19 @@ export function priceFor(provider, model) { // The newest first-party Claude model of a family, as a display name: // newestClaudeName('opus') → "Opus 5.5". Null when the catalog has none. export function newestClaudeName(family) { + const best = newestClaudeVersion(family); + if (!best) return null; + const name = family[0].toUpperCase() + family.slice(1); + return `${name} ${best[0]}${best[1] >= 0 ? `.${best[1]}` : ''}`; +} + +// The same newest model as its full id ("claude-opus-5-5"), or null. +export function newestClaudeId(family) { + const best = newestClaudeVersion(family); + return best ? `claude-${family}-${best[0]}${best[1] >= 0 ? `-${best[1]}` : ''}` : null; +} + +function newestClaudeVersion(family) { const re = new RegExp(`^claude-${family}-(\\d{1,2})(?:-(\\d{1,2}))?$`); let best = null; for (const id of Object.keys(tables.anthropic)) { @@ -222,9 +235,7 @@ export function newestClaudeName(family) { const v = [Number(m[1]), m[2] == null ? -1 : Number(m[2])]; if (!best || compareVersions(v, best) > 0) best = v; } - if (!best) return null; - const name = family[0].toUpperCase() + family.slice(1); - return `${name} ${best[0]}${best[1] >= 0 ? `.${best[1]}` : ''}`; + return best; } // The families of first-party Claude models in the bundled snapshot, each with its