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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 1.0.69

- Feat: adaptive VS Code resume via IDE lock file polling
- Replaces fixed 2s delay with `~/.claude/ide/*.lock` detection
- Already-open project: instant (~0.5s vs ~2s before)
- Cold start: adaptive poll + 1.5s post-ready delay
- Fix: duplicate Claude Code tab on VS Code window restore (cases 3, 5, 7)
- Active sessions skip URI handler when project needs to be opened
- Closed sessions wait for extension ready before URI handler
- Fix: resume not opening session tab when project window already open (case 2)
- Fix: active VS Code session switching to wrong window

## 1.0.68

- Feat: quick-launch new Claude session from Projects tab
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ For the full same-cwd accuracy matrix (detection + switch by launch method and t
| Terminal.app | Title match → TTY fallback | AppleScript `do script` | Built-in macOS terminal; same TTY accuracy as iTerm2 |
| Ghostty | Title match → cwd fallback | AppleScript new tab/window | Needs `/rename` for same-cwd. **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 |
| 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) |

### Embedded Terminal

Expand Down
41 changes: 33 additions & 8 deletions docs/vscode-session-support-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,22 @@ Verified behavior:

### Layer 4: Resume (closed sessions)

Two-step process for VS Code resume:
1. Open project folder: `code "<projectPath>"`
2. After 2s delay (for VS Code to load workspace): URI handler `vscode://anthropic.claude-code/open?session=<UUID>`
Adaptive resume via IDE lock file polling (PR #109):
1. Check `~/.claude/ide/*.lock` for matching project + alive PID
2. If project already open → focus window + 500ms delay + URI handler (~0.5s)
3. If project not open → `open -b bundleId projectPath` + poll lock files (250ms interval, 5s timeout) + 1.5s post-ready delay + URI handler
4. Active sessions + project not open → only `open -b` (skip URI handler, let VS Code restore handle tab)
5. Fallback to fixed 2s delay if `~/.claude/ide/` doesn't exist

If the user's Launch Terminal is not set to VS Code, closed sessions resume in the default terminal (standard behavior).

Measured latency:
| Scenario | Time |
|----------|------|
| Active session switch | Instant |
| Resume in already-open VS Code project | ~1-2s |
| Resume in new VS Code project | ~3-5s |
| Active session switch (project open) | ~0.5s |
| Resume in already-open VS Code project | ~0.5s |
| Resume, project closed, VS Code open | ~2.5s |
| Resume, VS Code cold start | ~3-4s |

### Layer 5: Settings

Expand Down Expand Up @@ -132,6 +136,26 @@ open "vscode://anthropic.claude-code/open"
open "vscode://anthropic.claude-code/open?prompt=help%20me%20review%20this%20PR"
```

### VS Code-specific handling (differences from terminals)

VS Code sessions require fundamentally different handling from terminal sessions at every layer:

| Layer | Terminals (iTerm2/Ghostty/cmux/Terminal.app) | VS Code |
|-------|----------------------------------------------|---------|
| **Detection** | `history.jsonl` (append-only log) | JSONL scan + hooks index (not in `history.jsonl`) |
| **Active detection** | Process tree walk → terminal type | `entrypoint: "claude-vscode"` in session JSON |
| **Status** | Same hooks, same status files | Same hooks, same status files (via `$CLAUDE_CODE_ENTRYPOINT` env var) |
| **Switch (active)** | AppleScript / CLI per-terminal | URI handler `vscode://...?session=<UUID>` |
| **Resume (closed)** | `cd <path> && claude --resume <id>` in new tab | `open -b` + IDE lock file polling + URI handler |
| **Window focus** | AppleScript per-terminal | `open -b bundleId projectPath` (macOS `open` with bundle ID) |
| **Readiness detection** | Not needed (terminal is always ready) | Poll `~/.claude/ide/*.lock` for extension ready |
| **Restore conflict** | N/A (terminals don't restore session tabs) | VS Code restores Claude Code tabs → skip URI handler for active sessions to avoid duplicate |
| **Title** | `/rename` custom title | `ai-title` (auto-generated, no `/rename` support) |
| **User message** | Direct text | Filter `<ide_opened_file>` context blocks |
| **Same-cwd ambiguity** | TTY/title cross-reference | N/A — UUID-based URI handler is precise |
| **Dock icon** | N/A | Use `open -b bundleId` instead of `code` CLI to avoid extra icon |
| **Cold start timing** | Immediate (terminal launches fast) | Adaptive: lock file poll (250ms × max 20) + 1.5s post-ready delay |

## Performance

| Operation | Cost | Notes |
Expand All @@ -142,7 +166,8 @@ open "vscode://anthropic.claude-code/open?prompt=help%20me%20review%20this%20PR"
| head/tail read per session | ~5-10ms | Parallel head-20 + tail-100 + grep-c |
| ai-title grep | +~1ms/session | Parallel with existing greps |
| URI handler switch | instant | Single `open` command |
| URI handler resume | ~1-5s | `code <path>` + 2s delay + URI handler |
| URI handler resume (project open) | ~0.5s | Lock file detect + 500ms + URI handler |
| URI handler resume (project not open) | ~2.5-4s | `open -b` + poll + 1.5s delay + URI handler |
| Hooks index write | ~5ms/event | Per hook event, marker file prevents duplicates |
| Session count | capped at 100 | Sort by timestamp, then slice after merge |
| Timestamp normalization | +0ms | ISO string → unix ms conversion in reader |
Expand All @@ -164,7 +189,7 @@ First call shows a permission dialog in VS Code. User can check "Do not ask me a

1. **Closed sessions not in `history.jsonl`**: Workaround via JSONL scan + hooks index. Upstream fix may come via [#24579](https://github.com/anthropics/claude-code/issues/24579).
2. **No `/rename` in VS Code**: Use `ai-title` as fallback ([#33165](https://github.com/anthropics/claude-code/issues/33165)).
3. **Resume delay**: 2s fixed delay for workspace loading. Could be optimized by detecting if project is already open.
3. **Resume delay**: ~~2s fixed delay for workspace loading.~~ Replaced by IDE lock file polling (PR #109). Already-open projects are instant (~0.5s). Cold start uses adaptive poll + 1.5s post-ready delay.
4. **URI handler one-time dialog**: First use requires user to click "Allow" in VS Code.
5. **JSONL timestamp format**: VS Code uses ISO strings, CLI uses unix ms. Normalized at read time.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "CodeV",
"productName": "CodeV",
"version": "1.0.68",
"version": "1.0.69",
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
"repository": {
"type": "git",
Expand Down
132 changes: 106 additions & 26 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,9 +1043,10 @@ 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);
if (isActive && entrypoint === 'claude-vscode') {
openSessionInVSCode(sessionId);
openSessionInVSCode(sessionId, projectPath, true);
return;
}

Expand Down Expand Up @@ -1311,16 +1312,21 @@ export const launchNewClaudeSession = (
): void => {
if (terminalApp === 'vscode') {
const { execFile } = require('child_process');
// Use `open -b` with bundle ID to avoid extra Dock icon (vs `code` CLI)
if (isVSCodeProjectOpen(projectPath)) {
// Project already open → instant
execFile('open', ['vscode://anthropic.claude-code/open']);
return;
}
// Launch VS Code, poll for extension ready, then open new session
const bundleId = getCurrentIDEBundleId();
execFile('open', ['-b', bundleId, projectPath], (error: any) => {
if (error) {
console.error('[launchNewClaudeSession] failed to open VS Code:', error);
return;
}
setTimeout(() => {
waitForVSCodeExtensionReady(projectPath).then(() => {
execFile('open', ['vscode://anthropic.claude-code/open']);
}, 2000);
});
});
return;
}
Expand All @@ -1333,38 +1339,112 @@ export const launchNewClaudeSession = (
runCommandInTerminal(`cd "${projectPath}" && claude`, 'claude', projectPath, terminalApp, terminalMode);
};

/**
* Check if VS Code has a specific project open by reading IDE lock files.
* Lock files are created by Claude Code extension per VS Code window.
* Returns true if a lock file with matching workspaceFolders + alive PID exists.
*/
const isVSCodeProjectOpen = (projectPath: string): boolean => {
const ideDir = path.join(os.homedir(), '.claude', 'ide');
if (!fs.existsSync(ideDir)) return false;
try {
for (const file of fs.readdirSync(ideDir)) {
if (!file.endsWith('.lock')) continue;
try {
const content = JSON.parse(fs.readFileSync(path.join(ideDir, file), 'utf-8'));
if (!content.pid || !content.workspaceFolders) continue;
const hasFolder = content.workspaceFolders.some((f: string) => f === projectPath);
if (!hasFolder) continue;
// Verify PID is alive
try { process.kill(content.pid, 0); return true; } catch { /* dead PID */ }
} catch {}
}
} catch {}
return false;
};

/**
* Poll IDE lock files until a matching project appears (extension ready).
* Resolves when lock file with matching workspaceFolders + alive PID is found,
* or after timeout (fallback).
*/
const waitForVSCodeExtensionReady = (projectPath: string, timeoutMs = 5000, intervalMs = 250): Promise<void> => {
return new Promise((resolve) => {
// Quick check — maybe it's already ready
if (isVSCodeProjectOpen(projectPath)) { resolve(); return; }

// If IDE lock dir doesn't exist, lock file mechanism may not be available
// Fall back to fixed 2s delay instead of waiting 8s
const ideDir = path.join(os.homedir(), '.claude', 'ide');
if (!fs.existsSync(ideDir)) {
setTimeout(resolve, 2000);
return;
}

const startTime = Date.now();
const timer = setInterval(() => {
if (isVSCodeProjectOpen(projectPath) || Date.now() - startTime > timeoutMs) {
clearInterval(timer);
resolve();
}
}, intervalMs);
});
};

/**
* Open a Claude Code session in VS Code via URI handler.
* For active sessions: switches to the existing session tab.
* For closed sessions: opens the project folder first, then resumes via URI handler.
* - Active sessions: instant switch via URI handler.
* - Active + project open: focus window + URI handler (instant switch).
* - Active + project not open: just open -b (let VS Code restore handle the session tab).
* - Closed + project open: focus window + 500ms delay + URI handler.
* - Closed + project not open: open -b + poll extension ready + 500ms delay + URI handler.
*/
export const openSessionInVSCode = (sessionId: string, projectPath?: string): void => {
export const openSessionInVSCode = (sessionId: string, projectPath?: string, isActiveSession = false): void => {
const { execFile } = require('child_process');
const uri = `vscode://anthropic.claude-code/open?session=${sessionId}`;

if (projectPath) {
// Closed session: open project folder first, then resume after a short delay
// Use `open -b` with bundle ID to avoid extra Dock icon (vs `code` CLI)
if (!projectPath) {
execFile('open', [uri], (error: any) => {
if (error) console.error('[openSessionInVSCode] failed:', error);
});
return;
}

// Check if VS Code already has this project open (extension ready)
if (isVSCodeProjectOpen(projectPath)) {
// Focus the correct window, then URI handler after short delay
const bundleId = getCurrentIDEBundleId();
execFile('open', ['-b', bundleId, projectPath], (error: any) => {
if (error) {
console.error('[openSessionInVSCode] failed to open project:', error);
return;
}
// Wait for VS Code to open the workspace before resuming session
execFile('open', ['-b', bundleId, projectPath], () => {
setTimeout(() => {
const uri = `vscode://anthropic.claude-code/open?session=${sessionId}`;
execFile('open', [uri]);
}, 2000);
});
} else {
// Active session: directly switch via URI handler
const uri = `vscode://anthropic.claude-code/open?session=${sessionId}`;
execFile('open', [uri], (error: any) => {
if (error) {
console.error('[openSessionInVSCode] failed:', error);
}
}, 500);
});
return;
}

// Project not open → need to open it
const bundleId = getCurrentIDEBundleId();
execFile('open', ['-b', bundleId, projectPath], (error: any) => {
if (error) {
console.error('[openSessionInVSCode] failed to open project:', error);
return;
}

if (isActiveSession) {
// Active session: VS Code restore will reopen the session tab automatically.
// Don't fire URI handler — it would create a duplicate.
return;
}

// Closed session: poll for extension ready, then URI handler to resume
// Extra 1.5s delay after extension ready to let VS Code finish restoring
// session tabs (avoids duplicate if tab was restored by VS Code)
waitForVSCodeExtensionReady(projectPath).then(() => {
setTimeout(() => {
execFile('open', [uri]);
}, 1500);
});
});
};

/**
Expand Down