-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(claude-sync): propagate session renames between Claude CLI and UI #1068
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,40 @@ type ArchivedSessionListItem = { | |
| isProjectArchived: boolean; | ||
| }; | ||
|
|
||
| /** | ||
| * Mirrors a rename into the Claude transcript as a `custom-title` event. | ||
| * | ||
| * Claude Code appends the same event on `/rename` and reads the last one back | ||
| * for its own session list, so writing it keeps both sides on the newest name | ||
| * and lets the session synchronizer re-derive the stored name from disk. | ||
| * Best effort by design: a missing or unwritable transcript must not fail the | ||
| * rename, which has already been persisted. | ||
| */ | ||
| async function appendClaudeCustomTitle( | ||
| session: { session_id: string; provider: string; provider_session_id: string | null; jsonl_path: string | null }, | ||
| customTitle: string | ||
| ): Promise<void> { | ||
| if (session.provider !== 'claude' || !session.jsonl_path) { | ||
| return; | ||
| } | ||
|
|
||
| const event = JSON.stringify({ | ||
| type: 'custom-title', | ||
| customTitle, | ||
| sessionId: session.provider_session_id ?? session.session_id, | ||
| }); | ||
|
|
||
| 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'); | ||
|
Comment on lines
+58
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 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:
💡 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:
Don’t pre-check transcript availability with
🤖 Prompt for AI Agents |
||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.warn(`Failed to record rename in Claude transcript for session ${session.session_id}:`, message); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Removes one file if it exists. | ||
| */ | ||
|
|
@@ -293,7 +327,7 @@ export const sessionsService = { | |
| /** | ||
| * Renames one session by id without requiring the caller to pass provider. | ||
| */ | ||
| renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } { | ||
| async renameSessionById(sessionId: string, summary: string): Promise<{ sessionId: string; summary: string }> { | ||
| const session = sessionsDb.getSessionById(sessionId); | ||
| if (!session) { | ||
| throw new AppError(`Session "${sessionId}" was not found.`, { | ||
|
|
@@ -303,6 +337,7 @@ export const sessionsService = { | |
| } | ||
|
|
||
| sessionsDb.updateSessionCustomName(sessionId, summary); | ||
| await appendClaudeCustomTitle(session, summary); | ||
| return { sessionId, summary }; | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: siteboon/claudecodeui
Length of output: 1203
🏁 Script executed:
Repository: siteboon/claudecodeui
Length of output: 224
🏁 Script executed:
Repository: siteboon/claudecodeui
Length of output: 50377
🏁 Script executed:
Repository: siteboon/claudecodeui
Length of output: 10554
Protect failed rename write-backs from stale
ai-titleevents.appendClaudeCustomTitle()writes the renamed value and is documented as best-effort so the persisted rename is not reverted, but the synchronizer precedence iscustomTitle ?? aiTitle ?? existingSessionName. If a previous sync already seededai-titleand the append fails, the next sync overwritesRenamed in the UIwith that stale generated title. Track which persisted names came from explicitcustom-titlerenames so generatedai-titleevents are ignored for already-renamed sessions, otherwise explicit renames can be silently reverted.🤖 Prompt for AI Agents