diff --git a/client/app.js b/client/app.js index b80f76fc..5ad3c17c 100644 --- a/client/app.js +++ b/client/app.js @@ -790,8 +790,8 @@ class ClaudeOrchestrator { const modelLabel = String(config.model || '').replace(/^claude-/i, ''); const effortLevel = String(config.effortLevel || '').trim().toLowerCase(); - // Effort first: it stays readable even when a narrow header truncates the chip. - const text = [effortLevel.toUpperCase(), modelLabel].filter(Boolean).join(' · '); + // Model first — it's the fact you scan for; effort is the qualifier. + const text = [modelLabel, effortLevel.toUpperCase()].filter(Boolean).join(' · '); const tooltipLines = ['Model & effort agent launches in this worktree will use (settings files + env overrides).']; const describeSource = (source) => { @@ -804,7 +804,7 @@ class ClaudeOrchestrator { if (effortLevel) { tooltipLines.push(`Effort: ${effortLevel} — from ${describeSource(config.effortSource)}`); } - tooltipLines.push('Note: a /model pick for "this session only" is not written to disk and won\'t show here.'); + tooltipLines.push('Note: while a Claude session is running here, Model reflects what it is actually using (including "/model" switches); otherwise the configured launch default is shown.'); return { text, tooltip: tooltipLines.join('\n'), effortLevel }; } diff --git a/client/styles.css b/client/styles.css index 34dc342f..a043a370 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1796,15 +1796,15 @@ header { cursor: help; } -/* Expensive effort levels stand out so token burn is visible at a glance. */ +/* Expensive effort levels get only a faintly tinted border — a whisper, not a + shout. With xhigh as a daily-driver default, the old bright orange chip lit + up every terminal header and became pure noise. */ .terminal-model-badge[data-effort="xhigh"], .terminal-model-badge[data-effort="ultra"], .terminal-model-badge[data-effort="ultra-code"], .terminal-model-badge[data-effort="ultracode"], .terminal-model-badge[data-effort="max"] { - color: var(--accent-warning); - border-color: color-mix(in srgb, var(--accent-warning) 45%, transparent); - background: color-mix(in srgb, var(--accent-warning) 10%, transparent); + border-color: color-mix(in srgb, var(--accent-warning) 28%, transparent); } .terminal-model-badge[data-effort="low"], @@ -1814,8 +1814,8 @@ header { background: color-mix(in srgb, var(--accent-success) 8%, transparent); } -/* Narrow viewports: keep the (effort-first) chip from crowding out the - terminal title/branch in the header flex row. */ +/* Narrow viewports: keep the chip from crowding out the terminal + title/branch in the header flex row. */ @media (max-width: 640px) { .terminal-model-badge { max-width: 6.5rem; diff --git a/server/agentModelConfigService.js b/server/agentModelConfigService.js index 17386323..90f832b1 100644 --- a/server/agentModelConfigService.js +++ b/server/agentModelConfigService.js @@ -1,12 +1,17 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { claudeProjectFolderName } = require('./utils/pathUtils'); // Claude Code re-reads these files on every launch, so a short cache keeps // badge refreshes cheap (and dedupes the shared user-global file across sessions // within one request) without showing stale values for long. const FILE_CACHE_TTL_MS = 1500; +// The live model is read from the tail of the session transcript; 64 KB comfortably +// contains the most recent assistant turn even for very large (multi-MB) transcripts. +const TRANSCRIPT_TAIL_BYTES = 64 * 1024; + // Claude Code settings precedence (highest first) for the keys we care about: // CLI args > .claude/settings.local.json > .claude/settings.json > ~/.claude/settings.json // https://code.claude.com/docs/en/settings @@ -37,6 +42,7 @@ class AgentModelConfigService { this.fs = fsImpl; this.processEnv = processEnv; this.fileCache = new Map(); + this.liveModelCache = new Map(); } static getInstance(options = {}) { @@ -46,7 +52,7 @@ class AgentModelConfigService { return AgentModelConfigService.instance; } - resolveClaudeConfig(directory) { + resolveClaudeConfig(directory, { agentRunning = false } = {}) { const resolved = { agent: 'claude', model: null, @@ -101,9 +107,114 @@ class AgentModelConfigService { } } + // Prefer the model the running session is ACTUALLY using, read from its Claude Code + // transcript. The user can switch models mid-session with /model, which never touches + // the settings files above — so the config-derived model can be stale/wrong (the + // "badge says Fable but I'm actually on Opus" trap). Only consulted while a Claude + // agent is actually running in the terminal: a closed/finished chat leaves its + // transcript behind, and showing that as if it were live state is the same trap + // in the other direction. Falls back to config when no transcript resolves. + if (agentRunning) { + const liveModel = this.resolveLiveClaudeModel(directory); + if (liveModel) { + resolved.model = liveModel; + resolved.modelSource = { label: 'live session (transcript)', file: null }; + } + } + return resolved; } + // Read the model the running Claude session is actually using, from its transcript + // (~/.claude/projects//.jsonl). Claude Code records the model on + // every assistant turn, so the newest one reflects a mid-session /model switch the + // settings files never see. Returns null (caller keeps the config model) when no + // transcript can be resolved. Results are cached per directory with the same TTL as + // file reads — the client polls this endpoint per session, and each uncached lookup + // costs a readdir + a stat per historical transcript. + resolveLiveClaudeModel(directory) { + try { + if (!this.isNonEmptyString(directory)) return null; + const cwd = path.resolve(directory); + const now = Date.now(); + const cached = this.liveModelCache.get(cwd); + if (cached && now - cached.readAt < FILE_CACHE_TTL_MS) { + return cached.model; + } + const model = this.readLiveClaudeModel(cwd); + this.liveModelCache.set(cwd, { readAt: now, model }); + return model; + } catch { + return null; + } + } + + readLiveClaudeModel(cwd) { + const projectDir = path.join(this.homeDir, '.claude', 'projects', this.encodeClaudeProjectDir(cwd)); + let names; + try { + names = this.fs.readdirSync(projectDir); + } catch { + return null; + } + const newest = names + .filter((name) => name.endsWith('.jsonl')) + .map((name) => { + const file = path.join(projectDir, name); + let mtimeMs = 0; + try { mtimeMs = this.fs.statSync(file).mtimeMs; } catch { /* skip unreadable */ } + return { file, mtimeMs }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + if (!newest) return null; + return this.readLastModelFromTranscript(newest.file); + } + + // Claude Code names each project's transcript folder by sanitizing the absolute cwd; + // delegates to the shared implementation used by session recovery. + encodeClaudeProjectDir(cwd) { + return claudeProjectFolderName(cwd); + } + + // Return the most recent real model id from a transcript, reading only its tail so + // multi-MB files stay cheap. Parses whole JSONL lines and only accepts the top-level + // message.model of assistant turns — a substring scan would also match "model" keys + // inside message CONTENT (tool params, quoted JSON in code discussions) and show a + // wrong-but-authoritative-looking model. Skips synthetic placeholders (""). + readLastModelFromTranscript(file) { + let fd; + try { + fd = this.fs.openSync(file, 'r'); + const size = this.fs.fstatSync(fd).size; + const length = Math.min(size, TRANSCRIPT_TAIL_BYTES); + if (length <= 0) return null; + const buffer = Buffer.alloc(length); + this.fs.readSync(fd, buffer, 0, length, size - length); + const lines = buffer.toString('utf8').split('\n'); + // Walk newest-first; a truncated first line of the tail simply fails to parse. + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || parsed.type !== 'assistant') continue; + const model = typeof parsed.message?.model === 'string' ? parsed.message.model.trim() : ''; + if (model && !model.startsWith('<')) return model; + } + return null; + } catch { + return null; + } finally { + if (fd !== undefined) { + try { this.fs.closeSync(fd); } catch { /* ignore */ } + } + } + } + // Look an env var up the way a launched agent would see it: settings-layer // `env` blocks (local > project > user), then ~/.claude/.env (sourced by the // user's shell profile), then the server environment the PTY inherits. diff --git a/server/index.js b/server/index.js index 347b7a62..84ca3682 100644 --- a/server/index.js +++ b/server/index.js @@ -2843,9 +2843,14 @@ app.get('/api/sessions/model-config', (req, res) => { const type = String(session?.type || '').trim().toLowerCase(); if (type !== 'claude' && type !== 'codex') continue; const cwd = sessionManager.getSessionCwd(session); + // Live transcript detection only applies while a Claude agent is actually + // running in this terminal; otherwise the badge shows the launch config. + const workspaceId = String(session?.workspace || '').trim(); + const recovery = workspaceId ? sessionRecoveryService.getSession(workspaceId, sessionId) : null; + const claudeAgentRunning = recovery?.lastAgent === 'claude' && recovery?.lastAgentActive !== false; sessions[sessionId] = { cwd, - claude: agentModelConfigService.resolveClaudeConfig(cwd) + claude: agentModelConfigService.resolveClaudeConfig(cwd, { agentRunning: claudeAgentRunning }) }; } return res.json({ diff --git a/server/sessionRecoveryService.js b/server/sessionRecoveryService.js index cd97c816..6fb0bc55 100644 --- a/server/sessionRecoveryService.js +++ b/server/sessionRecoveryService.js @@ -9,7 +9,7 @@ const fs = require('fs').promises; const os = require('os'); const path = require('path'); const winston = require('winston'); -const { getAgentWorkspaceDir } = require('./utils/pathUtils'); +const { getAgentWorkspaceDir, claudeProjectFolderName } = require('./utils/pathUtils'); const HOME_DIR = process.env.HOME || os.homedir(); const RECOVERY_DIR = path.join(getAgentWorkspaceDir(), 'session-recovery'); @@ -98,16 +98,10 @@ class SessionRecoveryService { return path.join(RECOVERY_DIR, `${safeId}.json`); } - /** - * Claude Code stores conversations under ~/.claude/projects/, - * sanitizing EVERY character outside [a-zA-Z0-9-] to '-' - * ('C:\Users\x\.app' -> 'C--Users-x--app', '/home/x/.app' -> '-home-x--app'). - * Replacing only slashes leaves ':' and '.' behind, so conversation lookups - * never matched and recovery silently downgraded "resume conversation" to - * "start fresh". - */ + // Delegates to the shared implementation in utils/pathUtils (see the scar-tissue + // comment there: replacing only slashes silently broke conversation lookups once). claudeProjectFolderName(targetPath) { - return String(targetPath || '').replace(/[^a-zA-Z0-9-]/g, '-'); + return claudeProjectFolderName(targetPath); } /** diff --git a/server/utils/pathUtils.js b/server/utils/pathUtils.js index e70e381d..14a37dbd 100644 --- a/server/utils/pathUtils.js +++ b/server/utils/pathUtils.js @@ -484,11 +484,23 @@ function getTrailingPathLabel(value, count = 2) { return splitPathSegments(value).slice(-safeCount).join('/'); } +/** + * Claude Code stores conversations under ~/.claude/projects/, + * sanitizing EVERY character outside [a-zA-Z0-9-] to '-' + * ('C:\Users\x\.app' -> 'C--Users-x--app', '/home/x/.app' -> '-home-x--app'). + * Replacing only slashes leaves ':' and '.' behind, so lookups silently miss. + * Single shared implementation — this exact regex has been gotten wrong before. + */ +function claudeProjectFolderName(targetPath) { + return String(targetPath || '').replace(/[^a-zA-Z0-9-]/g, '-'); +} + module.exports = { normalizePathSlashes, splitPathSegments, getPathBasename, getTrailingPathLabel, + claudeProjectFolderName, getAgentWorkspaceDir, getDefaultAgentWorkspaceDir, getLegacyAgentWorkspaceDir, diff --git a/tests/unit/agentModelConfigService.test.js b/tests/unit/agentModelConfigService.test.js index 128817cb..902fa444 100644 --- a/tests/unit/agentModelConfigService.test.js +++ b/tests/unit/agentModelConfigService.test.js @@ -255,4 +255,99 @@ describe('AgentModelConfigService', () => { expect(resolved.effortSource.label).toBe('server environment'); expect(resolved.effortSource.file).toBeNull(); }); + + const writeTranscript = (svc, cwd, jsonlLines) => { + const encoded = svc.encodeClaudeProjectDir(path.resolve(cwd)); + const dir = path.join(homeDir, '.claude', 'projects', encoded); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'session-abc.jsonl'); + fs.writeFileSync(file, jsonlLines.join('\n') + '\n'); + return file; + }; + + test('encodeClaudeProjectDir replaces non-alphanumeric chars with dashes', () => { + const svc = createService(); + expect(svc.encodeClaudeProjectDir('C:\\Users\\cuppy\\.agent-workspace\\work1')) + .toBe('C--Users-cuppy--agent-workspace-work1'); + }); + + test('reads the most recent real model from the transcript tail, skipping synthetic', () => { + const svc = createService(); + const file = writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-fable-5' } }), + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }), + JSON.stringify({ type: 'assistant', message: { model: '' } }) + ]); + expect(svc.readLastModelFromTranscript(file)).toBe('claude-opus-4-8'); + }); + + test('resolveClaudeConfig prefers the live transcript model while an agent is running', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + const svc = createService(); + writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) + ]); + + const resolved = svc.resolveClaudeConfig(worktreeDir, { agentRunning: true }); + expect(resolved.model).toBe('claude-opus-4-8'); // live session wins + expect(resolved.modelSource.label).toBe('live session (transcript)'); + expect(resolved.effortLevel).toBe('high'); // effort still comes from config + }); + + test('resolveClaudeConfig ignores leftover transcripts when no agent is running', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + const svc = createService(); + // A finished/closed chat leaves its transcript behind — with no agent + // running, the badge must show the configured launch default instead. + writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) + ]); + + const resolved = svc.resolveClaudeConfig(worktreeDir); + expect(resolved.model).toBe('claude-fable-5[1m]'); + expect(resolved.modelSource.label).toBe('user settings (global)'); + }); + + test('resolveClaudeConfig keeps the configured model when there is no transcript', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + + const resolved = createService().resolveClaudeConfig(worktreeDir); + expect(resolved.model).toBe('claude-fable-5[1m]'); + expect(resolved.modelSource.label).toBe('user settings (global)'); + }); + + test('ignores "model" strings inside message content — only assistant message.model counts', () => { + const svc = createService(); + const file = writeTranscript(svc, worktreeDir, [ + // Real per-turn model, followed in the SAME line by a sub-agent spawn param and + // quoted JSON in the assistant's text — both must not win over message.model. + JSON.stringify({ + type: 'assistant', + message: { + model: 'claude-fable-5', + content: [ + { type: 'text', text: 'set {"model": "gpt-4"} in your config' }, + { type: 'tool_use', name: 'Agent', input: { model: 'sonnet', prompt: 'scout' } } + ] + } + }), + // Non-assistant lines with model-shaped strings must be skipped entirely. + JSON.stringify({ type: 'user', message: { content: 'try "model": "haiku" maybe?' } }) + ]); + expect(svc.readLastModelFromTranscript(file)).toBe('claude-fable-5'); + }); + + test('caches the live-model lookup so per-session polling stays cheap', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + const svc = createService(); + const file = writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) + ]); + + expect(svc.resolveClaudeConfig(worktreeDir, { agentRunning: true }).model).toBe('claude-opus-4-8'); + + // Within the cache TTL the transcript is not re-read, even if it changed on disk. + fs.writeFileSync(file, JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-5' } }) + '\n'); + expect(svc.resolveClaudeConfig(worktreeDir, { agentRunning: true }).model).toBe('claude-opus-4-8'); + }); });