Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
- 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
- Fix: **closing Settings puts the cursor back in the search box.** Opening Settings moves focus into its panel, as before; but closing it with the same button left focus on that button, so typing searched nothing until you clicked a row or switched apps and back. The Settings button counts as part of the panel, which the switcher deliberately never steals focus from, so its click-to-refocus skipped it. Closing the panel now hands focus back to the current tab's search box
- 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

## 1.0.93

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>`, 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

Expand Down
160 changes: 142 additions & 18 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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().
*/
Expand All @@ -988,6 +1002,7 @@ const readVSCodeSessionFromJSONL = async (
cwdOrJsonlPath: string,
execPromise: (cmd: string) => Promise<string>,
isJsonlPath = false,
projectsDir = getProjectsDir(),
): Promise<{
firstUserMessage: string;
lastUserMessage: string;
Expand All @@ -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`),
]);

Expand Down Expand Up @@ -1049,13 +1068,22 @@ 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<string, string>();
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).
Expand All @@ -1082,17 +1110,18 @@ 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) {
if (activeSessionIds.has(sessionId)) continue;
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' });
}
}

Expand Down Expand Up @@ -1120,18 +1149,28 @@ 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 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)
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(
Expand All @@ -1149,14 +1188,15 @@ export const scanClosedVSCodeSessions = async (
lastTimestamp: info.lastTimestamp,
messageCount: info.messageCount,
isActive: false,
entrypoint: 'claude-vscode',
entrypoint,
});
});

await Promise.all(promises);
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));
};
Expand Down Expand Up @@ -1218,14 +1258,19 @@ export const detectActiveSessions = async (): Promise<ActiveSessionResult> => {

entrypoints.set(sessionId, entrypoint);

if (entrypoint === 'claude-vscode') {
// VS Code sessions: not in history.jsonl, add directly
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.
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,
Expand Down Expand Up @@ -1491,11 +1536,21 @@ 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) ?? knownTranscriptOnlyEntrypoints.get(sessionId);
if (isActive && entrypoint === 'claude-vscode') {
openSessionInVSCode(sessionId, projectPath, true);
return;
}
// 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;
}

switch (effectiveTerminal) {
case 'vscode':
Expand Down Expand Up @@ -1988,6 +2043,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/<a>/<b>/local_<desktop id>.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.
Expand Down
5 changes: 5 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2818,6 +2818,11 @@ ipcMain.handle('detect-terminal-apps', async (_event, pidMap: Record<string, num
results[sessionId] = 'vscode';
return;
}
// Claude Desktop's Code tab: its process tree holds no terminal
if (entrypointMap && entrypointMap[sessionId] === 'claude-desktop') {
results[sessionId] = 'desktop';
return;
}
results[sessionId] = await detectTerminalApp(pid);
}));
return results;
Expand Down
Loading
Loading