Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 };
}

Expand Down
12 changes: 6 additions & 6 deletions client/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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;
Expand Down
113 changes: 112 additions & 1 deletion server/agentModelConfigService.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -37,6 +42,7 @@ class AgentModelConfigService {
this.fs = fsImpl;
this.processEnv = processEnv;
this.fileCache = new Map();
this.liveModelCache = new Map();
}

static getInstance(options = {}) {
Expand All @@ -46,7 +52,7 @@ class AgentModelConfigService {
return AgentModelConfigService.instance;
}

resolveClaudeConfig(directory) {
resolveClaudeConfig(directory, { agentRunning = false } = {}) {
const resolved = {
agent: 'claude',
model: null,
Expand Down Expand Up @@ -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/<encoded-cwd>/<session>.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 ("<synthetic>").
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.
Expand Down
7 changes: 6 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 4 additions & 10 deletions server/sessionRecoveryService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -98,16 +98,10 @@ class SessionRecoveryService {
return path.join(RECOVERY_DIR, `${safeId}.json`);
}

/**
* Claude Code stores conversations under ~/.claude/projects/<sanitized-cwd>,
* 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);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions server/utils/pathUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,11 +484,23 @@ function getTrailingPathLabel(value, count = 2) {
return splitPathSegments(value).slice(-safeCount).join('/');
}

/**
* Claude Code stores conversations under ~/.claude/projects/<sanitized-cwd>,
* 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,
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/agentModelConfigService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<synthetic>' } })
]);
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');
});
});
Loading