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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ type ParsedSession = {
sessionName?: string;
};

type TranscriptTitles = {
customTitle?: string;
aiTitle?: string;
lastPrompt?: string;
};

/**
* Session indexer for Claude transcript artifacts.
*/
Expand Down Expand Up @@ -137,29 +143,41 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
// ids must be resolved through the provider-id mapping first.
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
?? sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {
...parsed,
sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'),
};
}

let sessionName = nameMap.get(parsed.sessionId);
if (!sessionName) {
sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId);
}
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;
Comment on lines +146 to +160

Copy link
Copy Markdown
Contributor

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:

#!/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 || true

Repository: 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"
fi

Repository: 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 || true

Repository: 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}")
PY

Repository: 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.


return {
...parsed,
sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'),
};
}

private async extractSessionAiTitleFromEnd(
/**
* Collects the newest title-bearing transcript events for one session.
*
* Scans backwards so the last event of each type wins, matching how Claude
* itself resolves these `last-wins` records; the scan stops as soon as a
* `custom-title` is found because nothing outranks it.
*/
private async extractSessionTitlesFromEnd(
filePath: string,
sessionId: string
): Promise<string | undefined> {
): Promise<TranscriptTitles> {
const titles: TranscriptTitles = {};

try {
const content = await readFile(filePath, 'utf8');
const lines = content.split(/\r?\n/);
Expand All @@ -180,22 +198,29 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
const data = parsed as Record<string, unknown>;
const eventType = typeof data.type === 'string' ? data.type : undefined;
const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
if (!eventType || eventSessionId !== sessionId) {
continue;
}

const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined;

if (
(eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) ||
(eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) ||
(eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim())
) {
return aiTitle || lastPrompt || claudeRenamedTitle;
const customTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined;

if (eventType === 'custom-title' && customTitle?.trim()) {
titles.customTitle = customTitle;
break;
}
if (eventType === 'ai-title' && aiTitle?.trim()) {
titles.aiTitle ??= aiTitle;
}
if (eventType === 'last-prompt' && lastPrompt?.trim()) {
titles.lastPrompt ??= lastPrompt;
}
}
} catch {
// Ignore missing/unreadable files so sync can continue.
}

return undefined;
return titles;
}
}
2 changes: 1 addition & 1 deletion server/modules/providers/provider.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ router.put(
asyncHandler(async (req: Request, res: Response) => {
const sessionId = parseSessionId(req.params.sessionId);
const summary = parseSessionRenameSummary(req.body);
const result = sessionsService.renameSessionById(sessionId, summary);
const result = await sessionsService.renameSessionById(sessionId, summary);
res.json(createApiSuccessResponse(result));
}),
);
Expand Down
37 changes: 36 additions & 1 deletion server/modules/providers/services/sessions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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); });
JS

Repository: 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:


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.

} 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.
*/
Expand Down Expand Up @@ -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.`, {
Expand All @@ -303,6 +337,7 @@ export const sessionsService = {
}

sessionsDb.updateSessionCustomName(sessionId, summary);
await appendClaudeCustomTitle(session, summary);
return { sessionId, summary };
},
};
Loading