From c1b84e47bd41a29713fd03dd012b0773d6f76532 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 23 Oct 2026 00:46:34 +0800 Subject: [PATCH 1/4] feat(sessions): list and switch to Claude Desktop Code sessions (#111) Desktop never writes history.jsonl, so its sessions take the VS Code path: listed from transcripts, running ones by their sessions/.json registration, switched by Desktop's per-session deep link with an app-focus fallback. Before, a running Desktop pid fell into the CLI branch, whose cwd guess attached it to an unrelated closed session. Also: cap the transcript head/tail reads under exec's 1 MiB buffer (an image line emptied them, giving a timestamp of 0), and fall back to grep when the entrypoint is not within a transcript's first 4 KB. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + README.md | 1 + src/claude-session-utility.ts | 148 ++++++++++++++++++++++++++++++---- src/main.ts | 5 ++ src/switcher-ui.tsx | 9 ++- 5 files changed, 146 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8727171..bba913b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - Feat: **Ghostty switches to the right tab when several sessions share a directory** ([#63](https://github.com/grimmerk/codev/issues/63)). Ghostty's tip builds expose each terminal's `tty` (ghostty-org/ghostty#11922), so switching now works the way it does in iTerm2 and Terminal.app: by tty and by `/rename` title, in the order the session's process provenance calls for, and only then by working directory. Before, a session without a custom title went to the first tab in its directory — measured on one machine with 16 sessions sharing `~/git`, every one of them landed on the same tab. Detection's fallback cross-reference uses the tty the same way. **Stable Ghostty (1.3.1 and earlier) has no `tty`**, so there CodeV behaves exactly as before — title, then directory — until a release ships it - Fix: detection's fallback cross-reference no longer gives a running process to the wrong session when one `/rename` title contains another (`Fix` inside `Fix backend`). Every terminal (iTerm2, cmux, Terminal.app, Ghostty) took the first title its tab matched, from every transcript in the directory; it now takes the longest match among the sessions actually queued for that process. One function for all four, where there used to be four copies +- Feat: **Claude Desktop's Code sessions show up in the Sessions tab** ([#111](https://github.com/grimmerk/codev/issues/111)), running and closed, with a `[DESKTOP]` badge on running ones. Selecting a running one opens it in Desktop through Desktop's own link for that session, so two sessions in one directory cannot be confused; a closed one resumes in your terminal, as a closed VS Code session does. Desktop never writes `history.jsonl`, so these are read from their transcripts, the way VS Code sessions already were +- Fix: a running Desktop session no longer lights up an unrelated session. Its id was not in `history.jsonl`, so detection guessed by directory and gave Desktop's process to a closed CLI session in the same folder whenever that folder had one (found by reading the code; Desktop's process sits under no terminal, so the title cross-reference could not catch it) +- Fix: a VS Code or Desktop session whose transcript has a pasted image near its start or end could show no prompts and a time of 0, which sorted it out of the list. The two reads exceeded a 1 MiB output limit and came back empty; they are now capped below it. A transcript whose first line hides its `entrypoint` past the first 4 KB (the same image) is now still recognised ## 1.0.93 diff --git a/README.md b/README.md index 865204a..94ca0b8 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t | Ghostty | TTY and title, ordered like iTerm2 (TTY first for an exact pid, title first for a guessed one), then cwd fallback. TTY needs a Ghostty tip build; stable 1.3.x: title → cwd | AppleScript new tab/window | On stable Ghostty, same-cwd sessions need `/rename`. **Note:** Ghostty may not support `⌘+V` (paste) and `⌘+Z` (undo) in CodeV's search bar by default — add `keybind = super+v=paste_from_clipboard` and `keybind = super+z=undo` to `~/.config/ghostty/config` ([ghostty#10749](https://github.com/ghostty-org/ghostty/issues/10749#issuecomment-4131892831)) | | cmux | Title match → TTY fallback | CLI new-workspace | Same as iTerm2 (requires cmux v0.63+); requires socket access in cmux Settings (`automation` or `allowAll`) | | VS Code | URI handler (session-level) | `open -b` + URI handler | Requires Claude Code VS Code extension v2.1.72+; `[VSCODE]` badge on active sessions; adaptive resume via IDE lock file polling (~0.5s if project already open) | +| Claude Desktop (Code tab) | Deep link to the session (`claude://claude.ai/claude-code-desktop/local_`, with Desktop's own id for it, looked up from its session records), falls back to focusing the app | — (new sessions start in a terminal) | Listed from transcripts, since Desktop does not write `history.jsonl`; `[DESKTOP]` badge on active sessions. The deep link is Desktop's own in-app route, not a documented API. A closed Desktop session resumes in your terminal, like a closed VS Code one | #### Keeping the machine responsive diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index c990848..8d80e0d 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -979,7 +979,21 @@ const parseAssistantMessageFromLines = (lines: string[]): string => { }; /** - * Read VS Code session info using head/tail (async, parallel). + * Entrypoints whose sessions never reach history.jsonl, so CodeV lists them + * from their transcripts instead: the VS Code extension, and Claude Desktop's + * Code tab (#111). A session from either must not fall into the CLI path, + * which guesses a session by cwd when history does not know the id. + */ +const TRANSCRIPT_ONLY_ENTRYPOINTS = new Set(['claude-vscode', 'claude-desktop']); +export const isTranscriptOnlyEntrypoint = (entrypoint?: string): entrypoint is string => + !!entrypoint && TRANSCRIPT_ONLY_ENTRYPOINTS.has(entrypoint); + +/** The first `entrypoint` value in a slice of transcript, if one is whole in it. */ +export const entrypointIn = (text: string): string | undefined => + /"entrypoint":\s*"([^"]+)"/.exec(text)?.[1]; + +/** + * Read VS Code / Claude Desktop session info using head/tail (async, parallel). * Single tail read extracts both last user prompt AND last assistant message, * avoiding duplicate reads with loadLastAssistantResponses(). */ @@ -988,6 +1002,7 @@ const readVSCodeSessionFromJSONL = async ( cwdOrJsonlPath: string, execPromise: (cmd: string) => Promise, isJsonlPath = false, + projectsDir = getProjectsDir(), ): Promise<{ firstUserMessage: string; lastUserMessage: string; @@ -1002,16 +1017,20 @@ const readVSCodeSessionFromJSONL = async ( jsonlPath = cwdOrJsonlPath; } else { const encodedProject = cwdOrJsonlPath.replace(/[^a-zA-Z0-9-]/g, '-'); - const claudeDir = path.join(os.homedir(), '.claude', 'projects'); - jsonlPath = path.join(claudeDir, encodedProject, `${sessionId}.jsonl`); + jsonlPath = path.join(projectsDir, encodedProject, `${sessionId}.jsonl`); } if (!fs.existsSync(jsonlPath)) return result; - // Parallel: head for first prompt, tail for last prompt + assistant, grep -c for count + // Parallel: head for first prompt, tail for last prompt + assistant, grep -c for count. + // Each read is capped in bytes under the callers' 1 MiB exec maxBuffer: a + // pasted image makes one line hundreds of KB, and an overflowing read + // returned nothing at all — no prompts, no cwd, and a timestamp of 0 that + // sorted the session out of the list. A line cut by the cap fails to parse + // and is skipped like any other bad line. const [headOutput, tailOutput, countOutput] = await Promise.all([ - execPromise(`head -n 20 "${jsonlPath}"`), - execPromise(`tail -n 100 "${jsonlPath}"`), + execPromise(`head -n 20 "${jsonlPath}" | head -c 1000000`), + execPromise(`tail -n 100 "${jsonlPath}" | tail -c 1000000`), execPromise(`grep -c '"type":"user"' "${jsonlPath}" 2>/dev/null`), ]); @@ -1053,9 +1072,12 @@ let closedVSCodeTimestamp = 0; const CLOSED_VSCODE_CACHE_TTL_MS = 30000; // 30s — less frequent than active detection /** - * Scan ~/.claude/projects/ for closed VS Code sessions. + * Scan ~/.claude/projects/ for closed VS Code and Claude Desktop sessions — + * the entrypoints that never reach history.jsonl. * Reads first 4KB of each JSONL to check entrypoint, then uses head/tail * to extract first/last user prompt (shared algorithm with loadLastAssistantResponses). + * A transcript whose first line outgrows 4KB (a pasted image) has no + * entrypoint in that slice; those few are asked with `grep -m1` instead. * * Uses hooks index (vscode-sessions.jsonl) to skip already-known files. * Skips active sessions (caller should filter by activeMap). @@ -1082,9 +1104,10 @@ export const scanClosedVSCodeSessions = async ( const claudeDir = path.join(os.homedir(), '.claude', 'projects'); if (!fs.existsSync(claudeDir)) return []; - // Phase 1: Identify VS Code session files + // Phase 1: Identify VS Code / Claude Desktop session files // Use hooks index for known sessions, scan remaining files - const vsCodeFiles: { sessionId: string; cwd: string; jsonlPath: string }[] = []; + const vsCodeFiles: { sessionId: string; cwd: string; jsonlPath: string; entrypoint: string }[] = []; + const unsniffed: { sessionId: string; jsonlPath: string }[] = []; // Add known sessions from hooks index for (const [sessionId, cwd] of vsCodeIndex) { @@ -1092,7 +1115,7 @@ export const scanClosedVSCodeSessions = async ( const encodedProject = cwd.replace(/[^a-zA-Z0-9-]/g, '-'); const jsonlPath = path.join(claudeDir, encodedProject, `${sessionId}.jsonl`); if (fs.existsSync(jsonlPath)) { - vsCodeFiles.push({ sessionId, cwd, jsonlPath }); + vsCodeFiles.push({ sessionId, cwd, jsonlPath, entrypoint: 'claude-vscode' }); } } @@ -1120,18 +1143,30 @@ export const scanClosedVSCodeSessions = async ( } finally { fs.closeSync(fd); } - if (chunk.includes('"entrypoint":"claude-vscode"') || chunk.includes('"entrypoint": "claude-vscode"')) { + const entrypoint = entrypointIn(chunk); + if (!entrypoint) { + unsniffed.push({ sessionId, jsonlPath: filePath }); + } else if (isTranscriptOnlyEntrypoint(entrypoint)) { // cwd will be read from JSONL content (directory name decode is lossy) - vsCodeFiles.push({ sessionId, cwd: '', jsonlPath: filePath }); + vsCodeFiles.push({ sessionId, cwd: '', jsonlPath: filePath, entrypoint }); } } catch {} } } + await Promise.all( + unsniffed.map(async ({ sessionId, jsonlPath }) => { + const found = await execPromise(`grep -m1 -o '"entrypoint": *"[^"]*"' "${jsonlPath}"`); + const entrypoint = entrypointIn(found); + if (isTranscriptOnlyEntrypoint(entrypoint)) { + vsCodeFiles.push({ sessionId, cwd: '', jsonlPath, entrypoint }); + } + }), + ); // Phase 2: Read first/last prompt + assistant for each session using head/tail (parallel) // Uses readVSCodeSessionFromJSONL (shared with active session detection) const sessions: ClaudeSession[] = []; - const promises = vsCodeFiles.map(async ({ sessionId, cwd, jsonlPath }) => { + const promises = vsCodeFiles.map(async ({ sessionId, cwd, jsonlPath, entrypoint }) => { // For scanned files (not from hooks index), pass jsonlPath directly to avoid lossy cwd decode const useJsonlPath = !vsCodeIndex.has(sessionId); const info = await readVSCodeSessionFromJSONL( @@ -1149,7 +1184,7 @@ export const scanClosedVSCodeSessions = async ( lastTimestamp: info.lastTimestamp, messageCount: info.messageCount, isActive: false, - entrypoint: 'claude-vscode', + entrypoint, }); }); @@ -1218,14 +1253,18 @@ export const detectActiveSessions = async (): Promise => { entrypoints.set(sessionId, entrypoint); - if (entrypoint === 'claude-vscode') { - // VS Code sessions: not in history.jsonl, add directly + if (isTranscriptOnlyEntrypoint(entrypoint)) { + // VS Code / Claude Desktop sessions: not in history.jsonl, add directly. + // A Desktop session used to fall into the CLI branch below, whose + // cwd guess handed its pid to an unrelated closed CLI session. activeMap.set(sessionId, pid); exactPids.add(pid); // Queue async JSONL read (head/tail in parallel) const startedAt = data.startedAt; vscodeReadPromises.push( - readVSCodeSessionFromJSONL(sessionId, cwd, execPromise).then((info) => { + readVSCodeSessionFromJSONL( + sessionId, cwd, execPromise, false, getProjectsDir(account.dir), + ).then((info) => { vscodeSessions.push({ sessionId, project: cwd, @@ -1496,6 +1535,12 @@ export const openSession = async ( openSessionInVSCode(sessionId, projectPath, true); return; } + // Running in Claude Desktop's Code tab — its own deep link, by session id, + // so sessions sharing a directory cannot be confused. + if (isActive && entrypoint === 'claude-desktop') { + openSessionInClaudeDesktop(sessionId); + return; + } switch (effectiveTerminal) { case 'vscode': @@ -1988,6 +2033,75 @@ export const openSessionInVSCode = (sessionId: string, projectPath?: string, isA }); }; +/** + * Desktop's own id for a Claude Code session, which its deep link needs. + * Desktop keeps one record per Code session at + * `claude-code-sessions///local_.json`, holding its id in + * `sessionId` and the Claude Code one in `cliSessionId`; the two differ + * (measured 2026-10 on all 14 records that carry both). Older records have + * no `cliSessionId` — Desktop used the same id then, which is why the link + * worked with the Claude Code id when first tried (2026-04) — so with no + * record, fall back to that. + */ +export const findClaudeDesktopRouteId = ( + cliSessionId: string, + sessionsRoot = path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude-code-sessions'), +): string => { + const fallback = `local_${cliSessionId}`; + try { + for (const a of fs.readdirSync(sessionsRoot)) { + const aDir = path.join(sessionsRoot, a); + if (!fs.statSync(aDir).isDirectory()) continue; + for (const b of fs.readdirSync(aDir)) { + const bDir = path.join(aDir, b); + if (!fs.statSync(bDir).isDirectory()) continue; + for (const f of fs.readdirSync(bDir)) { + if (!/^local_[0-9a-f-]+\.json$/i.test(f)) continue; + const text = fs.readFileSync(path.join(bDir, f), 'utf-8'); + // Cheap filter before parsing; the records run to hundreds of KB. + if (!text.includes(cliSessionId)) continue; + try { + const record = JSON.parse(text); + if ( + record.cliSessionId === cliSessionId && + typeof record.sessionId === 'string' && + /^local_[0-9a-f-]+$/i.test(record.sessionId) + ) { + return record.sessionId; + } + } catch {} + } + } + } + } catch {} + return fallback; +}; + +/** + * Bring a Claude Desktop Code-tab session to the front. The deep link is + * Desktop's own in-app route for the session (found by trial in 2026-04, + * #111), not a documented API; if `open` fails, focus the app instead. A + * route Desktop no longer knows fails silently — Desktop shows "This session + * couldn't be found" and `open` still exits 0. + */ +export const openSessionInClaudeDesktop = (sessionId: string): void => { + const { execFile } = require('child_process'); + const focusApp = () => + execFile('open', ['-a', 'Claude'], (error: any) => { + if (error) console.error('[openSessionInClaudeDesktop] failed to focus Claude:', error); + }); + if (!/^[0-9a-f-]+$/i.test(sessionId)) { + focusApp(); + return; + } + const routeId = findClaudeDesktopRouteId(sessionId); + execFile('open', [`claude://claude.ai/claude-code-desktop/${routeId}`], (error: any) => { + if (!error) return; + console.error('[openSessionInClaudeDesktop] deep link failed, focusing the app:', error); + focusApp(); + }); +}; + /** * "Open" a session that's running in CodeV's embedded terminal. * Switches to the Terminal tab instead of opening an external terminal. diff --git a/src/main.ts b/src/main.ts index c5b9383..53f4eb9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2818,6 +2818,11 @@ ipcMain.handle('detect-terminal-apps', async (_event, pidMap: Record { const id = s.sessionId; const prInfo = prLinks[id]; - const terminalBadge = terminalApps[id] || ((s as any).entrypoint === 'claude-vscode' ? 'vscode' : ''); + const entrypoint = (s as any).entrypoint; + const terminalBadge = + terminalApps[id] || + (entrypoint === 'claude-vscode' ? 'vscode' : entrypoint === 'claude-desktop' ? 'desktop' : ''); // A saved-list member whose session is gone has only what was captured; // search those fields too, or a query on its captured title drops it. const captured = s.__listMember as SessionListMember | undefined; @@ -1917,10 +1920,10 @@ function SwitcherApp() { activePid: activeMap[s.sessionId], entrypoint: entrypointMap[s.sessionId] || s.entrypoint, })); - // Merge VS Code sessions that aren't already in the list + // Merge VS Code / Claude Desktop sessions that aren't already in the list for (const vs of vscodeSessions) { if (!updated.find((s: any) => s.sessionId === vs.sessionId)) { - updated.push({ ...vs, entrypoint: 'claude-vscode' }); + updated.push({ ...vs, entrypoint: vs.entrypoint || 'claude-vscode' }); } } // Re-sort by lastTimestamp descending From 1c3b2323eb0fa427b69940681bb16d00bd2b8d89 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 23 Oct 2026 02:11:37 +0800 Subject: [PATCH 2/4] fix(sessions): open closed Desktop sessions in Desktop; keep transcript-only rows across refetches Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++- README.md | 2 +- src/claude-session-utility.ts | 13 ++++++---- src/switcher-ui.tsx | 45 ++++++++++++++++++++++++++++------- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba913b..38b090e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ - Feat: **Ghostty switches to the right tab when several sessions share a directory** ([#63](https://github.com/grimmerk/codev/issues/63)). Ghostty's tip builds expose each terminal's `tty` (ghostty-org/ghostty#11922), so switching now works the way it does in iTerm2 and Terminal.app: by tty and by `/rename` title, in the order the session's process provenance calls for, and only then by working directory. Before, a session without a custom title went to the first tab in its directory — measured on one machine with 16 sessions sharing `~/git`, every one of them landed on the same tab. Detection's fallback cross-reference uses the tty the same way. **Stable Ghostty (1.3.1 and earlier) has no `tty`**, so there CodeV behaves exactly as before — title, then directory — until a release ships it - Fix: detection's fallback cross-reference no longer gives a running process to the wrong session when one `/rename` title contains another (`Fix` inside `Fix backend`). Every terminal (iTerm2, cmux, Terminal.app, Ghostty) took the first title its tab matched, from every transcript in the directory; it now takes the longest match among the sessions actually queued for that process. One function for all four, where there used to be four copies -- Feat: **Claude Desktop's Code sessions show up in the Sessions tab** ([#111](https://github.com/grimmerk/codev/issues/111)), running and closed, with a `[DESKTOP]` badge on running ones. Selecting a running one opens it in Desktop through Desktop's own link for that session, so two sessions in one directory cannot be confused; a closed one resumes in your terminal, as a closed VS Code session does. Desktop never writes `history.jsonl`, so these are read from their transcripts, the way VS Code sessions already were +- Feat: **Claude Desktop's Code sessions show up in the Sessions tab** ([#111](https://github.com/grimmerk/codev/issues/111)), running and closed, with a `[DESKTOP]` badge on running ones. Selecting one opens it in Desktop through Desktop's own link for that session, so two sessions in one directory cannot be confused; for a closed one, Desktop starts the process itself, as its own sidebar does. The link needs Desktop's id for the session, which differs from Claude Code's and is read from Desktop's session records. Desktop never writes `history.jsonl`, so these are read from their transcripts, the way VS Code sessions already were. A Desktop session counts as running only while Desktop has a process for it — after you open it there, not merely because it is in Desktop's sidebar +- Fix: VS Code and Desktop rows no longer vanish for a moment each time the window comes back. Every refetch rebuilt the list from `history.jsonl` and added those rows only when background detection finished, so they dropped out and reappeared further up, shifting the list - Fix: a running Desktop session no longer lights up an unrelated session. Its id was not in `history.jsonl`, so detection guessed by directory and gave Desktop's process to a closed CLI session in the same folder whenever that folder had one (found by reading the code; Desktop's process sits under no terminal, so the title cross-reference could not catch it) - Fix: a VS Code or Desktop session whose transcript has a pasted image near its start or end could show no prompts and a time of 0, which sorted it out of the list. The two reads exceeded a 1 MiB output limit and came back empty; they are now capped below it. A transcript whose first line hides its `entrypoint` past the first 4 KB (the same image) is now still recognised diff --git a/README.md b/README.md index 94ca0b8..6c56653 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t | Ghostty | TTY and title, ordered like iTerm2 (TTY first for an exact pid, title first for a guessed one), then cwd fallback. TTY needs a Ghostty tip build; stable 1.3.x: title → cwd | AppleScript new tab/window | On stable Ghostty, same-cwd sessions need `/rename`. **Note:** Ghostty may not support `⌘+V` (paste) and `⌘+Z` (undo) in CodeV's search bar by default — add `keybind = super+v=paste_from_clipboard` and `keybind = super+z=undo` to `~/.config/ghostty/config` ([ghostty#10749](https://github.com/ghostty-org/ghostty/issues/10749#issuecomment-4131892831)) | | cmux | Title match → TTY fallback | CLI new-workspace | Same as iTerm2 (requires cmux v0.63+); requires socket access in cmux Settings (`automation` or `allowAll`) | | VS Code | URI handler (session-level) | `open -b` + URI handler | Requires Claude Code VS Code extension v2.1.72+; `[VSCODE]` badge on active sessions; adaptive resume via IDE lock file polling (~0.5s if project already open) | -| Claude Desktop (Code tab) | Deep link to the session (`claude://claude.ai/claude-code-desktop/local_`, with Desktop's own id for it, looked up from its session records), falls back to focusing the app | — (new sessions start in a terminal) | Listed from transcripts, since Desktop does not write `history.jsonl`; `[DESKTOP]` badge on active sessions. The deep link is Desktop's own in-app route, not a documented API. A closed Desktop session resumes in your terminal, like a closed VS Code one | +| Claude Desktop (Code tab) | Deep link to the session (`claude://claude.ai/claude-code-desktop/local_`, with Desktop's own id for it, looked up from its session records), falls back to focusing the app | — (new sessions start in a terminal) | Listed from transcripts, since Desktop does not write `history.jsonl`; `[DESKTOP]` badge on active sessions. The deep link is Desktop's own in-app route, not a documented API. A closed Desktop session opens in Desktop too, which starts its process; a session counts as running only while Desktop has a process for it, which is after you open it there | #### Keeping the machine responsive diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 8d80e0d..a4fb378 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1530,14 +1530,19 @@ export const openSession = async ( // Check if this is an active VS Code session — switch via URI handler // Always pass projectPath so the correct VS Code window gets focused first - const entrypoint = cachedEntrypoints?.get(sessionId); + const entrypoint = + cachedEntrypoints?.get(sessionId) ?? + cachedClosedVSCode?.find((s) => s.sessionId === sessionId)?.entrypoint; if (isActive && entrypoint === 'claude-vscode') { openSessionInVSCode(sessionId, projectPath, true); return; } - // Running in Claude Desktop's Code tab — its own deep link, by session id, - // so sessions sharing a directory cannot be confused. - if (isActive && entrypoint === 'claude-desktop') { + // A Claude Desktop Code-tab session, running or not, opens in Desktop: its + // deep link names the session, so sessions sharing a directory cannot be + // confused, and Desktop starts the process itself for a closed one — the + // way its own sidebar does. Resuming it in a terminal instead moved the + // conversation out of Desktop. + if (entrypoint === 'claude-desktop') { openSessionInClaudeDesktop(sessionId); return; } diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index cc5d82b..975a676 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -314,6 +314,10 @@ const styleElement = document.createElement('style'); styleElement.innerHTML = globalStyles; document.head.appendChild(styleElement); +/** A VS Code or Claude Desktop session: listed from its transcript, never from history.jsonl. */ +const isTranscriptOnlySession = (s: { entrypoint?: string }): boolean => + s.entrypoint === 'claude-vscode' || s.entrypoint === 'claude-desktop'; + function invokeVSCode(path: string, optionPress = false) { // press option for VSCode -r --reuse-window // Force to open a file or folder in an already opened window. @@ -1880,12 +1884,28 @@ function SwitcherApp() { // Step 1: Show sessions immediately, preserve old active states (SWR via ref) const result = await window.electronAPI.getClaudeSessions(100); const cachedActive = activeStateRef.current; - const newSessions = (result || []).map((s: any) => { + const fromHistory = (result || []).map((s: any) => { if (s.sessionId in cachedActive) { return { ...s, isActive: true, activePid: cachedActive[s.sessionId] }; } return s; }); + // VS Code / Claude Desktop rows are not in history.jsonl; they arrive with + // steps 3 and 5. Keep the previous ones meanwhile, or every refetch drops + // them and they reappear a moment later, sliding the list under the user. + const historyIds = new Set(fromHistory.map((s: any) => s.sessionId)); + const carried = allSessionsRef.current + .filter((s: any) => isTranscriptOnlySession(s) && !historyIds.has(s.sessionId)) + .map((s: any) => ({ + ...s, + isActive: s.sessionId in cachedActive, + activePid: cachedActive[s.sessionId], + })); + const newSessions = carried.length + ? [...fromHistory, ...carried] + .sort((a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0)) + .slice(0, 100) + : fromHistory; setAllSessions(newSessions); allSessionsRef.current = newSessions; // Read via ref, not state: this runs from persistent callbacks (window @@ -1920,11 +1940,13 @@ function SwitcherApp() { activePid: activeMap[s.sessionId], entrypoint: entrypointMap[s.sessionId] || s.entrypoint, })); - // Merge VS Code / Claude Desktop sessions that aren't already in the list + // Merge VS Code / Claude Desktop sessions; a row carried over from the + // last fetch is replaced, so its prompts and time are current for (const vs of vscodeSessions) { - if (!updated.find((s: any) => s.sessionId === vs.sessionId)) { - updated.push({ ...vs, entrypoint: vs.entrypoint || 'claude-vscode' }); - } + const fresh = { ...vs, entrypoint: vs.entrypoint || 'claude-vscode' }; + const at = updated.findIndex((s: any) => s.sessionId === vs.sessionId); + if (at === -1) updated.push(fresh); + else if (isTranscriptOnlySession(updated[at])) updated[at] = fresh; } // Re-sort by lastTimestamp descending updated.sort((a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0)); @@ -1965,10 +1987,17 @@ function SwitcherApp() { if (closedVS && closedVS.length > 0) { // Merge closed VS Code sessions, deduplicate, sort, cap at 100 const mergeAndCap = (prev: any[]) => { - const existingIds = new Set(prev.map((s: any) => s.sessionId)); + // A closed row carried over from the last fetch is replaced by + // the scan's fresh copy; history rows keep priority. + const closedById = new Map(closedVS.map((s: any) => [s.sessionId, s])); + const kept = prev.map((s: any) => + isTranscriptOnlySession(s) && !s.isActive && closedById.has(s.sessionId) + ? closedById.get(s.sessionId) + : s, + ); + const existingIds = new Set(kept.map((s: any) => s.sessionId)); const newSessions = closedVS.filter((s: any) => !existingIds.has(s.sessionId)); - if (newSessions.length === 0) return prev; - const merged = [...prev, ...newSessions]; + const merged = [...kept, ...newSessions]; merged.sort((a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0)); return merged.slice(0, 100); }; From 3fc08c3a03c2d65a367d9ae11152f59f9e9bd28b Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 23 Oct 2026 03:46:36 +0800 Subject: [PATCH 3/4] feat(sessions): a DESKTOP badge on every Claude Desktop row, styled apart Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- README.md | 2 +- src/switcher-ui.tsx | 27 +++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b090e..3c283c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Feat: **Ghostty switches to the right tab when several sessions share a directory** ([#63](https://github.com/grimmerk/codev/issues/63)). Ghostty's tip builds expose each terminal's `tty` (ghostty-org/ghostty#11922), so switching now works the way it does in iTerm2 and Terminal.app: by tty and by `/rename` title, in the order the session's process provenance calls for, and only then by working directory. Before, a session without a custom title went to the first tab in its directory — measured on one machine with 16 sessions sharing `~/git`, every one of them landed on the same tab. Detection's fallback cross-reference uses the tty the same way. **Stable Ghostty (1.3.1 and earlier) has no `tty`**, so there CodeV behaves exactly as before — title, then directory — until a release ships it - Fix: detection's fallback cross-reference no longer gives a running process to the wrong session when one `/rename` title contains another (`Fix` inside `Fix backend`). Every terminal (iTerm2, cmux, Terminal.app, Ghostty) took the first title its tab matched, from every transcript in the directory; it now takes the longest match among the sessions actually queued for that process. One function for all four, where there used to be four copies -- Feat: **Claude Desktop's Code sessions show up in the Sessions tab** ([#111](https://github.com/grimmerk/codev/issues/111)), running and closed, with a `[DESKTOP]` badge on running ones. Selecting one opens it in Desktop through Desktop's own link for that session, so two sessions in one directory cannot be confused; for a closed one, Desktop starts the process itself, as its own sidebar does. The link needs Desktop's id for the session, which differs from Claude Code's and is read from Desktop's session records. Desktop never writes `history.jsonl`, so these are read from their transcripts, the way VS Code sessions already were. A Desktop session counts as running only while Desktop has a process for it — after you open it there, not merely because it is in Desktop's sidebar +- Feat: **Claude Desktop's Code sessions show up in the Sessions tab** ([#111](https://github.com/grimmerk/codev/issues/111)), running and closed, with a `✳ DESKTOP` badge on every one of them — unlike the terminal badges, which appear only on running sessions, because it says where a click goes rather than where a process was found. Selecting one opens it in Desktop through Desktop's own link for that session, so two sessions in one directory cannot be confused; for a closed one, Desktop starts the process itself, as its own sidebar does. The link needs Desktop's id for the session, which differs from Claude Code's and is read from Desktop's session records. Desktop never writes `history.jsonl`, so these are read from their transcripts, the way VS Code sessions already were. A Desktop session counts as running only while Desktop has a process for it — after you open it there, not merely because it is in Desktop's sidebar - Fix: VS Code and Desktop rows no longer vanish for a moment each time the window comes back. Every refetch rebuilt the list from `history.jsonl` and added those rows only when background detection finished, so they dropped out and reappeared further up, shifting the list - Fix: a running Desktop session no longer lights up an unrelated session. Its id was not in `history.jsonl`, so detection guessed by directory and gave Desktop's process to a closed CLI session in the same folder whenever that folder had one (found by reading the code; Desktop's process sits under no terminal, so the title cross-reference could not catch it) - Fix: a VS Code or Desktop session whose transcript has a pasted image near its start or end could show no prompts and a time of 0, which sorted it out of the list. The two reads exceeded a 1 MiB output limit and came back empty; they are now capped below it. A transcript whose first line hides its `entrypoint` past the first 4 KB (the same image) is now still recognised diff --git a/README.md b/README.md index 6c56653..fe1a642 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t | Ghostty | TTY and title, ordered like iTerm2 (TTY first for an exact pid, title first for a guessed one), then cwd fallback. TTY needs a Ghostty tip build; stable 1.3.x: title → cwd | AppleScript new tab/window | On stable Ghostty, same-cwd sessions need `/rename`. **Note:** Ghostty may not support `⌘+V` (paste) and `⌘+Z` (undo) in CodeV's search bar by default — add `keybind = super+v=paste_from_clipboard` and `keybind = super+z=undo` to `~/.config/ghostty/config` ([ghostty#10749](https://github.com/ghostty-org/ghostty/issues/10749#issuecomment-4131892831)) | | cmux | Title match → TTY fallback | CLI new-workspace | Same as iTerm2 (requires cmux v0.63+); requires socket access in cmux Settings (`automation` or `allowAll`) | | VS Code | URI handler (session-level) | `open -b` + URI handler | Requires Claude Code VS Code extension v2.1.72+; `[VSCODE]` badge on active sessions; adaptive resume via IDE lock file polling (~0.5s if project already open) | -| Claude Desktop (Code tab) | Deep link to the session (`claude://claude.ai/claude-code-desktop/local_`, with Desktop's own id for it, looked up from its session records), falls back to focusing the app | — (new sessions start in a terminal) | Listed from transcripts, since Desktop does not write `history.jsonl`; `[DESKTOP]` badge on active sessions. The deep link is Desktop's own in-app route, not a documented API. A closed Desktop session opens in Desktop too, which starts its process; a session counts as running only while Desktop has a process for it, which is after you open it there | +| Claude Desktop (Code tab) | Deep link to the session (`claude://claude.ai/claude-code-desktop/local_`, with Desktop's own id for it, looked up from its session records), falls back to focusing the app | — (new sessions start in a terminal) | Listed from transcripts, since Desktop does not write `history.jsonl`; `✳ DESKTOP` badge on every Desktop session, running or not, since a click on any of them goes to Desktop. The deep link is Desktop's own in-app route, not a documented API. A closed Desktop session opens in Desktop too, which starts its process; a session counts as running only while Desktop has a process for it, which is after you open it there | #### Keeping the machine responsive diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 975a676..13acf4d 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -3636,6 +3636,33 @@ function SwitcherApp() { )} {(() => { + // A Claude Desktop session always opens in Desktop, so + // its badge names where a click goes, running or not — + // styled apart from the terminal badges, which only + // report where a running session was found. + if (session.entrypoint === 'claude-desktop') { + return ( + + ✳{' '} + + + ); + } // Only show terminal/IDE badge for active sessions const badge = session.isActive ? (terminalApps[session.sessionId] && terminalApps[session.sessionId] !== 'unknown' ? terminalApps[session.sessionId] : null) From c4a60fd0588f1e45aab76fd68da26c5fab3db003 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 23 Oct 2026 23:09:14 +0800 Subject: [PATCH 4/4] fix(sessions): batch the entrypoint grep; remember Desktop entrypoints past exit Review round 1 on #168: the grep fallback started one process per unsniffed transcript at once, and a Desktop session selected in the window after its process exited (absent from both caches) resumed in a terminal instead of opening Desktop. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/claude-session-utility.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index a4fb378..e2b5166 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1068,6 +1068,12 @@ const readVSCodeSessionFromJSONL = async ( // Cache for closed VS Code sessions scan let cachedClosedVSCode: ClaudeSession[] | null = null; +// Every VS Code / Desktop entrypoint seen, by session id, never cleared: a +// session's entrypoint does not change. The two caches above and below can +// both miss a session that just exited — the closed scan skipped it while it +// ran, and active detection drops it once it stops — and a Desktop row +// selected in that window must still open in Desktop. +const knownTranscriptOnlyEntrypoints = new Map(); let closedVSCodeTimestamp = 0; const CLOSED_VSCODE_CACHE_TTL_MS = 30000; // 30s — less frequent than active detection @@ -1153,15 +1159,13 @@ export const scanClosedVSCodeSessions = async ( } catch {} } } - await Promise.all( - unsniffed.map(async ({ sessionId, jsonlPath }) => { - const found = await execPromise(`grep -m1 -o '"entrypoint": *"[^"]*"' "${jsonlPath}"`); - const entrypoint = entrypointIn(found); - if (isTranscriptOnlyEntrypoint(entrypoint)) { - vsCodeFiles.push({ sessionId, cwd: '', jsonlPath, entrypoint }); - } - }), - ); + await runInBatches(unsniffed, 25, async ({ sessionId, jsonlPath }) => { + const found = await execPromise(`grep -m1 -o '"entrypoint": *"[^"]*"' "${jsonlPath}"`); + const entrypoint = entrypointIn(found); + if (isTranscriptOnlyEntrypoint(entrypoint)) { + vsCodeFiles.push({ sessionId, cwd: '', jsonlPath, entrypoint }); + } + }); // Phase 2: Read first/last prompt + assistant for each session using head/tail (parallel) // Uses readVSCodeSessionFromJSONL (shared with active session detection) @@ -1192,6 +1196,7 @@ export const scanClosedVSCodeSessions = async ( sessions.sort((a, b) => b.lastTimestamp - a.lastTimestamp); cachedClosedVSCode = sessions; + for (const s of sessions) knownTranscriptOnlyEntrypoints.set(s.sessionId, s.entrypoint as string); closedVSCodeTimestamp = now; return sessions.filter(s => !activeSessionIds.has(s.sessionId)); }; @@ -1254,6 +1259,7 @@ export const detectActiveSessions = async (): Promise => { entrypoints.set(sessionId, entrypoint); if (isTranscriptOnlyEntrypoint(entrypoint)) { + knownTranscriptOnlyEntrypoints.set(sessionId, entrypoint); // VS Code / Claude Desktop sessions: not in history.jsonl, add directly. // A Desktop session used to fall into the CLI branch below, whose // cwd guess handed its pid to an unrelated closed CLI session. @@ -1531,8 +1537,7 @@ export const openSession = async ( // Check if this is an active VS Code session — switch via URI handler // Always pass projectPath so the correct VS Code window gets focused first const entrypoint = - cachedEntrypoints?.get(sessionId) ?? - cachedClosedVSCode?.find((s) => s.sessionId === sessionId)?.entrypoint; + cachedEntrypoints?.get(sessionId) ?? knownTranscriptOnlyEntrypoints.get(sessionId); if (isActive && entrypoint === 'claude-vscode') { openSessionInVSCode(sessionId, projectPath, true); return;