fix(claude-sync): propagate session renames between Claude CLI and UI - #1068
fix(claude-sync): propagate session renames between Claude CLI and UI#1068thevinchi wants to merge 1 commit into
Conversation
Session names were frozen after first sync: the synchronizer returned the stored custom_name whenever one existed, and the history.jsonl lookup (first prompt text, first-entry-wins) shadowed the transcript title scan, so the custom-title events Claude Code writes on /rename never took effect. - Resolve titles as custom-title > ai-title > stored name > history first prompt > last-prompt, scanning the transcript from the end so the newest event of each type wins (matching Claude's own last-wins merge policy). - Mirror UI renames into the transcript as a custom-title event (best effort, never creates the file) so Claude Code's own session picker shows the new name and both sides converge on the latest rename. - Add synchronizer + rename write-back test coverage.
📝 WalkthroughWalkthroughClaude session synchronization now resolves titles from transcript events, history, and stored names. Session renames await persistence and append best-effort ChangesClaude title synchronization
Sequence Diagram(s)sequenceDiagram
participant Client
participant ProviderRoutes
participant SessionsService
participant SessionsDatabase
participant ClaudeTranscript
Client->>ProviderRoutes: PUT session rename
ProviderRoutes->>SessionsService: await renameSessionById
SessionsService->>SessionsDatabase: update custom_name
SessionsService->>ClaudeTranscript: append custom-title event
SessionsService-->>ProviderRoutes: return sessionId and summary
ProviderRoutes-->>Client: return resolved response
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@server/modules/providers/list/claude/claude-session-synchronizer.provider.ts`:
- Around line 146-160: Update the session-name resolution around
extractSessionTitlesFromEnd and existingSessionName to preserve provenance for
names created by explicit custom-title renames. When an existing session is
marked as explicitly renamed, ignore titles.aiTitle and retain
existingSessionName if no newer titles.customTitle is available; keep aiTitle
fallback for sessions without that explicit-rename marker, followed by the
existing nameMap and lastPrompt fallbacks.
In `@server/modules/providers/services/sessions.service.ts`:
- Around line 58-62: Update the transcript append flow to remove the fsp.access
pre-check and open session.jsonl_path directly with a non-creating mode such as
r+. Write the event through the returned FileHandle, close it reliably, and
treat ENOENT/EACCES as non-fatal best-effort rename outcomes while preserving
the existing handling for other errors.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1587a475-bb7c-46f2-81e4-4074e4e2199b
📒 Files selected for processing (4)
server/modules/providers/list/claude/claude-session-synchronizer.provider.tsserver/modules/providers/provider.routes.tsserver/modules/providers/services/sessions.service.tsserver/modules/providers/tests/claude-sessions.test.ts
| const existingSessionName = existingSession?.custom_name !== 'Untitled Claude Session' | ||
| ? existingSession?.custom_name ?? undefined | ||
| : undefined; | ||
|
|
||
| // The transcript is the source of truth for explicit names: `/rename` in the | ||
| // Claude CLI and a rename in this UI both land as a `custom-title` event, and | ||
| // the reverse scan below returns the newest one. The stored name only ranks | ||
| // above the history.jsonl first prompt so a UI rename whose transcript | ||
| // write-back failed is not silently reverted to that prompt. | ||
| const titles = await this.extractSessionTitlesFromEnd(filePath, parsed.sessionId); | ||
| const sessionName = titles.customTitle | ||
| ?? titles.aiTitle | ||
| ?? existingSessionName | ||
| ?? nameMap.get(parsed.sessionId) | ||
| ?? titles.lastPrompt; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== repo files matching claude session synchronizer =="
fd -a 'claude-session-synchronizer.provider.ts' . || true
file="$(fd 'claude-session-synchronizer.provider.ts' . | head -n 1)"
if [ -n "${file:-}" ]; then
echo "== file: $file =="
wc -l "$file"
echo "== outline =="
ast-grep outline "$file" --view expanded || true
echo "== relevant lines 120-180 =="
sed -n '120,180p' "$file" | nl -ba -v120
echo "== related title extraction definitions/usages =="
rg -n "extractSessionTitlesFromEnd|aiTitle|customTitle|lastPrompt|appendClaudeCustomTitle|custom_name|Untitled Claude Session" .
fi
echo "== changed files summary =="
git diff --stat || trueRepository: siteboon/claudecodeui
Length of output: 1203
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="$(fd 'claude-session-synchronizer.provider.ts' . | head -n 1)"
echo "== full relevant file ==" >&2
sed -n '1,260p' "$file" | nl -ba -v1
echo "== sessions.service relevant section =="
svc="$(fd 'sessions.service.ts' . | head -n 1)"
if [ -n "${svc:-}" ]; then
wc -l "$svc"
rg -n "appendClaudeCustomTitle|custom_name|create.*session|set.*session" "$svc"
rg -n -C 6 "appendClaudeCustomTitle|custom_name|create.*session|set.*session" "$svc"
fiRepository: siteboon/claudecodeui
Length of output: 224
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file="$(fd 'claude-session-synchronizer.provider.ts' . | head -n 1)"
echo "== file: $file =="
echo "== lines 1-80 =="
sed -n '1,80p' "$file"
echo "== lines 81-190 =="
sed -n '81,190p' "$file"
echo "== lines 191-235 =="
sed -n '191,235p' "$file"
svc="$(fd 'sessions.service.ts' . | head -n 1)"
if [ -n "${svc:-}" ]; then
echo "== sessions.service.ts: $svc =="
rg -n -C 8 "appendClaudeCustomTitle|custom_name|create.*custom|set.*custom|update.*custom" "$svc"
fi
echo "== tests mentioning title/event/write-back =="
rg -n -C 4 "ai-title|custom-title|Untitled Claude Session|custom_name|appendClaude|write-back|rename" server -S || trueRepository: siteboon/claudecodeui
Length of output: 50377
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== claude-sessions.test.ts around rename tests =="
file="$(fd 'claude-sessions.test.ts' . | head -n 1)"
sed -n '1,250p' "$file"
echo "== deterministic title lookup simulation from source =="
python3 - <<'PY'
titles_cases = [
{"aiTitle": "Generated title", "existingSessionName": "Renamed in the UI", "nameMap": "History prompt", "lastPrompt": "Ignored last prompt"},
{"customTitle": "New rename", "aiTitle": "Generated title", "existingSessionName": "Renamed in the UI", "lastPrompt": "Ignored current"},
{"lastPrompt": "History prompt", "existingSessionName": "Renamed in the UI", "lastPrompt2": "Ignored last prompt"},
]
for i, titles in enumerate(titles_cases, 1):
existingSessionName = titles["existingSessionName"] if titles.get("existingSessionName") not in ("", None, "Untitled Claude Session") else None
sessionName = titles.get("customTitle") or titles.get("aiTitle") or existingSessionName or titles.get("nameMap") or titles.get("lastPrompt")
print(f"case {i}: {sessionName!r}")
PYRepository: siteboon/claudecodeui
Length of output: 10554
Protect failed rename write-backs from stale ai-title events.
appendClaudeCustomTitle() writes the renamed value and is documented as best-effort so the persisted rename is not reverted, but the synchronizer precedence is customTitle ?? aiTitle ?? existingSessionName. If a previous sync already seeded ai-title and the append fails, the next sync overwrites Renamed in the UI with that stale generated title. Track which persisted names came from explicit custom-title renames so generated ai-title events are ignored for already-renamed sessions, otherwise explicit renames can be silently reverted.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@server/modules/providers/list/claude/claude-session-synchronizer.provider.ts`
around lines 146 - 160, Update the session-name resolution around
extractSessionTitlesFromEnd and existingSessionName to preserve provenance for
names created by explicit custom-title renames. When an existing session is
marked as explicitly renamed, ignore titles.aiTitle and retain
existingSessionName if no newer titles.customTitle is available; keep aiTitle
fallback for sessions without that explicit-rename marker, followed by the
existing nameMap and lastPrompt fallbacks.
| try { | ||
| // Never create the transcript: appendFile would happily materialize a file | ||
| // that Claude never wrote, leaving an orphan in ~/.claude/projects. | ||
| await fsp.access(session.jsonl_path); | ||
| await fsp.appendFile(session.jsonl_path, `${event}\n`, 'utf8'); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate file =="
git ls-files | rg '(^|/)sessions\.service\.ts$|package\.json$|tsconfig.*\.json$' || true
echo "== relevant code =="
if [ -f server/modules/providers/services/sessions.service.ts ]; then
sed -n '1,110p' server/modules/providers/services/sessions.service.ts | cat -n
fi
echo "== package/tooling excerpt =="
if [ -f package.json ]; then
node -e 'const p=require("./package.json"); console.log(JSON.stringify({dependencies:p.dependencies, devDependencies:p.devDependencies}, null, 2))'
fi
echo "== node fs appendFile behavior probe (readonly temp files) =="
node - <<'JS'
const os = require('os');
const fs = require('fs');
const path = require('path');
async function probe() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'toc-tou-probe-'));
const missing = path.join(dir, 'missing.jsonl');
const present = path.join(dir, 'present.jsonl');
fs.writeFileSync(present, 'old\n');
fs.chmodSync(present, 0o444); // read-only
let thrown = false;
let created = false;
try {
await fs.promises.access(present);
await fs.promises.appendFile(present, 'new\n', 'utf8');
} catch (err) {
thrown = true;
console.log('read-only error:', err.code, err.message.split('\n')[0]);
}
await fs.promises.access(missing);
try {
await fs.promises.appendFile(missing, 'new\n', 'utf8');
created = true;
console.log('missing file missing.stat.mode before delete:', fs.statSync(missing).mode.toString(8).slice(-4));
} catch (err) {
thrown = true;
console.log('missing error:', err.code, err.message.split('\n')[0]);
}
if (created) fs.unlinkSync(missing);
console.log('appendFile created missing:', created, 'thrown on existing read-only:', thrown);
fs.rmSync(dir, { recursive: true, force: true });
}
probe().catch(err => { console.error('probe failed:', err); process.exit(1); });
JSRepository: siteboon/claudecodeui
Length of output: 8386
🌐 Web query:
Node.js fsPromises.access() open read/write race condition access before open not recommended
💡 Result:
Using fsPromises.access to check for the accessibility of a file before performing an operation like open, readFile, or writeFile is not recommended [1][2]. This practice introduces a Time-of-Check to Time-of-Use (TOCTOU) race condition [3]. The core issue is that the file system state can change between the moment access confirms accessibility and the moment the subsequent operation executes [1][3][4]. For example, another process could delete, rename, or change the permissions of the file in the interim, rendering the initial check obsolete [1][3]. The recommended approach is to perform the file operation directly (e.g., calling fsPromises.open or fsPromises.readFile immediately) and handle any resulting errors [1][2][4]. This ensures that the check and the use of the file are part of a single, atomic operation relative to the operating system's handling of the file descriptor [3]. You should generally only use fsPromises.access when you do not intend to perform an immediate operation on the file, such as for [1][5]: - Performing one-time startup diagnostics to verify environment readiness. - Choosing among candidate paths. - Checking for the presence of an optional file to influence user experience (where a missing file is not an error). - Detecting signals from other processes where the file's existence or accessibility itself is the intended data point [1][5].
Citations:
- 1: https://nodejs.org/docs/latest-v24.x/api/fs.html
- 2: https://nodejs.org/api/fs.html
- 3: https://codeql.github.com/codeql-query-help/javascript/js-file-system-race/
- 4: https://thelinuxcode.com/nodejs-fsaccess-method-practical-patterns-pitfalls-and-production-ready-usage/
- 5: nodejs/node@763fa85ccf
Don’t pre-check transcript availability with access() before appending.
access() followed by appendFile() is racy: the transcript can disappear between the two calls, and appendFile()’s default append mode can create a new file — defeating the “never create the transcript” invariant. Open the file directly with a non-create flag (for example r+) and write via the returned FileHandle, handling ENOENT/EACCES as a non-fatal best-effort rename outcome.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@server/modules/providers/services/sessions.service.ts` around lines 58 - 62,
Update the transcript append flow to remove the fsp.access pre-check and open
session.jsonl_path directly with a non-creating mode such as r+. Write the event
through the returned FileHandle, close it reliably, and treat ENOENT/EACCES as
non-fatal best-effort rename outcomes while preserving the existing handling for
other errors.
What
Session renames never take effect:
/renamein the Claude CLI doesn't reach the sidebar, and a name is frozen after the first sync. Two guards inprocessSessionFilecause it:custom_namewhenever it isn't the literal'Untitled Claude Session'— so once named, a session can never be renamed.history.jsonlmap (first-entry-wins) before scanning the transcript, so every session with a history entry is named after its first prompt, and the existingcustom-titlebranch is unreachable in practice.The
custom-titleparsing is already written — it just never runs.How
custom-title→ai-title→ stored name → history first prompt →last-prompt, scanning from the end so the newest event of each type wins (matching Claude Code's own last-wins policy).custom-titleevent Claude Code writes on/renameand reads back for its session picker, so renames propagate both directions and converge on the most recent. Best-effort — never creates a missing file, and a failure is logged rather than failing the already-persisted rename.Keeping the stored name in the chain (below both transcript titles) means a write-back that failed isn't reverted to the first prompt on next sync. It's non-regressive for existing names, since those are the history first prompt.
Relationship to #982
@swu45's #982 covers the same title-priority reorder and I'd rather not duplicate that work. The difference:
Happy either way: land #982 first and I'll rebase this down to the guard removal plus write-back, or take this as the superset and close #982 with credit. Both add
server/modules/providers/tests/claude-sessions.test.ts, so they'll conflict on that path whichever lands first.Testing
New
claude-sessions.test.tscoverscustom-titlebeating the history prompt, a latercustom-titlereplacing a stored name, the fallback order, subagent transcripts still skipped, the rename append, and rename surviving a missing transcript.Lint, typecheck, and
npm run buildpass.Summary by CodeRabbit
New Features
Bug Fixes