fix(vscode): align session discovery and resume with CLI storage (#2415)

* fix(vscode): align session discovery with CLI storage

* fix(vscode): discover mixed runtime session histories

* fix(vscode): preserve selected history through resume

* fix(vscode): cancel pending resume startup on abort

* fix(vscode): resolve selected transcripts before CLI launch

* fix(vscode): preserve exact-store resumes with relative config roots

* fix(vscode): skip zero-byte transcripts during session discovery

An empty exact-directory placeholder was treated as the selected session and hid a populated alternate history.
This commit is contained in:
JATMN
2026-10-25 09:39:23 -07:00
committed by GitHub
parent 8cab4d1174
commit da7e6631cc
10 changed files with 545 additions and 103 deletions
+28
View File
@@ -11,6 +11,7 @@ import {
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import type { NormalizedMessage } from '../types/message.js'
import { snapshotEnv } from '../test/envSnapshot.js'
const nonce = `${Date.now()}-${Math.random()}`
const realProviders = {
@@ -219,6 +220,33 @@ test('loadConversationForResume accepts a small transcript from jsonl path', asy
expect(result?.messages.length).toBeGreaterThan(0)
})
test('jsonl resume retains the selected main conversation, metadata, and write directory', async () => {
const restoreEnv = snapshotEnv(['CLAUDE_CODE_SIMPLE'])
try {
process.env.CLAUDE_CODE_SIMPLE = '1'
const childSessionId = id(89)
const path = await writeJsonlEntries([
user(id(80), 'inherited prompt'),
{ ...assistant(id(81), 'continued answer', id(80)), sessionId: childSessionId },
{ ...user(id(82), 'unrelated agent'), isSidechain: true,
timestamp: '2026-04-03T00:00:00.000Z' },
{ type: 'custom-title', sessionId: childSessionId, customTitle: 'Selected history' },
{ type: 'tag', sessionId: childSessionId, tag: 'keep-tag' },
{ type: 'agent-setting', sessionId: childSessionId, agentSetting: 'reviewer' },
])
const { loadConversationForResume } = await importFreshConversationRecovery()
const result = await loadConversationForResume('fixture', path)
expect(result?.sessionId).toBe(childSessionId)
expect(result?.fullPath).toBe(path)
expect(result?.customTitle).toBe('Selected history')
expect(result?.tag).toBe('keep-tag')
expect(result?.agentSetting).toBe('reviewer')
expect(result?.messages.map(message => message.uuid)).toEqual([id(80), id(81)])
} finally {
restoreEnv()
}
})
test('loadConversationForResume preserves goal metadata from a loaded log option', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
const goal = activeGoal('keep going after resume')
+4 -55
View File
@@ -1,6 +1,6 @@
import { feature } from 'bun:bundle'
import type { UUID } from 'crypto'
import { relative } from 'path'
import { relative, resolve } from 'path'
import { getCwd } from 'src/utils/cwd.js'
import { addInvokedSkill } from '../bootstrap/state.js'
import { asSessionId } from '../types/ids.js'
@@ -42,15 +42,12 @@ import {
import { copyPlanForResume } from './plans.js'
import { processSessionStartHooks } from './sessionStart.js'
import {
buildConversationChain,
checkResumeConsistency,
getLastSessionLog,
getSessionIdFromLog,
isLiteLog,
loadFullLog,
loadMessageLogs,
loadTranscriptFile,
removeExtraFields,
} from './sessionStorage.js'
import { jsonStringify } from './slowOperations.js'
import type { ContentReplacementRecord } from './toolResultStorage.js'
@@ -647,50 +644,6 @@ export function restoreSkillStateFromMessages(messages: Message[]): void {
}
}
/**
* Chain-walk a transcript jsonl by path. Same sequence loadFullLog
* runs internally — loadTranscriptFile → find newest non-sidechain
* leaf → buildConversationChain → removeExtraFields — just starting
* from an arbitrary path instead of the sid-derived one.
*
* leafUuids is populated by loadTranscriptFile as "uuids that no
* other message's parentUuid points at" — the chain tips. There can
* be several (sidechains, orphans); newest non-sidechain is the main
* conversation's end.
*/
export async function loadMessagesFromJsonlPath(path: string): Promise<{
messages: SerializedMessage[]
sessionId: UUID | undefined
goal: LogOption['goal'] | undefined
}> {
const {
messages: byUuid,
goalStates,
leafUuids,
} = await loadTranscriptFile(path)
let tip: (typeof byUuid extends Map<UUID, infer T> ? T : never) | null = null
let tipTs = 0
for (const m of byUuid.values()) {
if (m.isSidechain || !leafUuids.has(m.uuid)) continue
const ts = new Date(m.timestamp).getTime()
if (ts > tipTs) {
tipTs = ts
tip = m
}
}
if (!tip) return { messages: [], sessionId: undefined, goal: undefined }
const chain = buildConversationChain(byUuid, tip)
const sessionId = tip.sessionId as UUID | undefined
return {
messages: removeExtraFields(chain),
// Leaf's sessionId — forked sessions copy chain[0] from the source
// transcript, so the root retains the source session's ID. Matches
// loadFullLog's mostRecentLeaf.sessionId.
sessionId,
goal: sessionId ? goalStates.get(sessionId) : undefined,
}
}
/**
* Loads a conversation for resume from various sources.
* This is the centralized function for loading and deserializing conversations.
@@ -753,13 +706,9 @@ export async function loadConversationForResume(
return !id || !skip.has(id)
}) ?? null
} else if (sourceJsonlFile) {
// --resume with a .jsonl path (cli/print.ts routes on suffix).
// Same chain walk as the sid branch below — only the starting
// path differs.
const loaded = await loadMessagesFromJsonlPath(sourceJsonlFile)
messages = loaded.messages
sessionId = loaded.sessionId
goal = loaded.goal
// Preserve metadata and the source directory so continuation appends
// to the selected transcript even when another runtime hashes cwd differently.
log = await loadFullLog(resolve(sourceJsonlFile))
} else if (typeof source === 'string') {
// Load specific session by ID
log = await getLastSessionLog(source as UUID)
+26 -6
View File
@@ -4123,13 +4123,30 @@ export function isLiteLog(log: LogOption): boolean {
}
/**
* Loads full messages for a lite log by reading its JSONL file.
* Loads messages and resume metadata from a lite log or an explicit JSONL path.
* Returns a new LogOption with populated messages array.
* If the log is already full or loading fails, returns the original log.
* Explicit paths select the latest non-sidechain leaf and retain its session ID.
* If the log is already full or loading fails, returns the original/empty log.
*/
export async function loadFullLog(log: LogOption): Promise<LogOption> {
export async function loadFullLog(source: LogOption | string): Promise<LogOption> {
// Explicit file resumes use the same metadata restoration as the picker.
// The leaf supplies their identity; filenames may have been renamed.
const fromPath = typeof source === 'string'
const log: LogOption = fromPath
? {
fullPath: source,
date: '',
messages: [],
value: 0,
created: new Date(0),
modified: new Date(0),
firstPrompt: '',
messageCount: 0,
isSidechain: false,
}
: source
// If already full, return as-is
if (!isLiteLog(log)) {
if (!fromPath && !isLiteLog(log)) {
return log
}
@@ -4167,12 +4184,14 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
return log
}
// Find the most recent user/assistant leaf message from the transcript
// Keep explicit-path resume on the main chain; preserve picker selection.
const mostRecentLeaf = findLatestMessage(
messages.values(),
msg =>
leafUuids.has(msg.uuid) &&
(msg.type === 'user' || msg.type === 'assistant'),
(fromPath
? !msg.isSidechain
: msg.type === 'user' || msg.type === 'assistant'),
)
if (!mostRecentLeaf) {
return log
@@ -4185,6 +4204,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
const sessionId = mostRecentLeaf.sessionId as UUID | undefined
return {
...log,
sessionId: fromPath ? sessionId : log.sessionId,
messages: removeExtraFields(transcript),
firstPrompt: extractFirstPrompt(transcript),
messageCount: countVisibleMessages(transcript),
@@ -31,6 +31,14 @@ A practical VS Code companion for OpenClaude with a project-aware **Control Cent
- VS Code `1.95+`
- `openclaude` available in your terminal PATH (`npm install -g @twigpine/openclaude@latest`)
## Resuming sessions
**OpenClaude: Resume Session** reads the current workspace's saved conversations
from `~/.openclaude/projects/`, including long workspace paths. When a session lives in an alternate runtime directory, resume passes its resolved transcript path to the CLI so continued messages stay with that history. If you use
`OPENCLAUDE_CONFIG_DIR`, start VS Code with the same environment override as the
CLI so both read the same history. Claude Code's `CLAUDE_CONFIG_DIR` and
`~/.claude/` are separate and are not used for OpenClaude session discovery.
## Commands
- `OpenClaude: Open Control Center`
@@ -41,6 +41,8 @@ class ChatController {
constructor(sessionManager) {
this._sessionManager = sessionManager;
this._process = null;
this._startGeneration = 0;
this._pendingStart = null;
this._webviews = new Set();
this._accumulatedText = '';
this._toolUses = [];
@@ -77,8 +79,16 @@ class ChatController {
this.broadcast(msg);
}
async startSession(opts = {}) {
startSession(opts = {}) {
this.stopSession();
const pending = this._startSession(opts).finally(() => {
if (this._pendingStart === pending) this._pendingStart = null;
});
this._pendingStart = pending;
return pending;
}
async _startSession(opts) {
this._accumulatedText = '';
this._toolUses = [];
// Only clear messages if this is a brand new session (not continuing)
@@ -89,11 +99,19 @@ class ChatController {
const { command, cwd, env, permissionMode } = getLaunchConfig();
const generation = this._startGeneration;
const resumeTarget = opts.sessionId
? await this._sessionManager.getResumeTarget(opts.sessionId)
: undefined;
// A stop or another selection can arrive while resolving the transcript.
if (generation !== this._startGeneration) return;
this._process = new ProcessManager({
command,
cwd,
env,
sessionId: opts.sessionId,
resumeTarget,
continueSession: opts.continueSession || false,
model: opts.model,
permissionMode,
@@ -141,6 +159,8 @@ class ChatController {
}
stopSession() {
this._startGeneration++;
this._pendingStart = null;
if (this._process) {
this._process.dispose();
this._process = null;
@@ -149,14 +169,14 @@ class ChatController {
}
async sendMessage(text) {
// Keep the process alive for multi-turn — just send directly.
// The CLI maintains full session state (tools, history) across turns.
// Only start a new process if none exists or it died.
if (!this._process || !this._process.running) {
await this.startSession({
sessionId: this._currentSessionId || undefined,
});
}
// Reuse a pending restart so simultaneous sends retain both messages.
// A stop or a new selection cancels sends waiting on the old startup.
const pending = this._pendingStart || ((!this._process || !this._process.running)
? this.startSession({ sessionId: this._currentSessionId || undefined })
: null);
const generation = this._startGeneration;
if (pending) await pending;
if (generation !== this._startGeneration) return;
await this._doSend(text);
}
@@ -180,6 +200,14 @@ class ChatController {
}
abort() {
if (this._pendingStart && !this._process) {
// Discovery has no child to signal yet. Invalidate its startup and all
// sends waiting on it, just as stopping or replacing the session does.
this.stopSession();
this._broadcast({ type: 'stream_end', text: '', usage: null, final: true });
this._onDidChangeState.fire('idle');
return;
}
if (this._process) {
this._process.abort();
this._broadcast({ type: 'stream_end', text: this._accumulatedText, usage: null });
@@ -26,6 +26,7 @@ class ProcessManager {
* @param {string} [opts.cwd] - Working directory
* @param {Record<string,string>} [opts.env] - Extra env vars
* @param {string} [opts.sessionId] - Session to resume
* @param {string} [opts.resumeTarget] - Resolved transcript path or session ID
* @param {boolean} [opts.continueSession] - Use --continue instead of --resume
* @param {string} [opts.model] - Model override
* @param {string[]} [opts.extraArgs] - Additional CLI flags
@@ -35,6 +36,7 @@ class ProcessManager {
this._cwd = opts.cwd || undefined;
this._env = opts.env || {};
this._sessionId = opts.sessionId || null;
this._resumeTarget = opts.resumeTarget || this._sessionId;
this._continueSession = opts.continueSession || false;
this._model = opts.model || null;
this._permissionMode = opts.permissionMode || 'acceptEdits';
@@ -73,7 +75,7 @@ class ProcessManager {
];
if (this._sessionId) {
args.push('--resume', this._sessionId);
args.push('--resume', this._resumeTarget);
} else if (this._continueSession) {
args.push('--continue');
}
@@ -105,13 +105,17 @@ test('real launcher preserves argv and NDJSON through executable and Windows npm
}
}
const values = ['model"&echo INJECTED>injected.txt', '%OPENCLAUDE_TEST_VALUE%', '!OPENCLAUDE_TEST_VALUE!', '', 'trailing\\', 'space & | < > ( ) ^', 'quote\\\"tail'];
for (const { command, env } of commands) {
const resumePath = path.join(dir, 'saved %OPENCLAUDE_TEST_VALUE% & ! history.jsonl');
for (const { command, env, resumeTarget } of commands.flatMap(entry => [
entry, { ...entry, resumeTarget: resumePath },
])) {
const result = run(`
const { ProcessManager } = require(${JSON.stringify(managerPath)});
const pm = new ProcessManager({
command: ${JSON.stringify(command)}, cwd: ${JSON.stringify(dir)},
env: { ...${JSON.stringify(env)}, OPENCLAUDE_TEST_VALUE: 'EXPANDED' },
model: ${JSON.stringify(values[0])}, sessionId: ${JSON.stringify(values[1])},
resumeTarget: ${JSON.stringify(resumeTarget)},
extraArgs: ${JSON.stringify(values.slice(2))},
});
const messages = [];
@@ -130,7 +134,7 @@ test('real launcher preserves argv and NDJSON through executable and Windows npm
assert.deepEqual(result.messages[0].argv, [
'--print', '--verbose', '--input-format=stream-json', '--output-format=stream-json',
'--include-partial-messages', '--permission-mode', 'acceptEdits',
'--resume', values[1], '--model', values[0], ...values.slice(2),
'--resume', resumeTarget ?? values[1], '--model', values[0], ...values.slice(2),
]);
assert.equal(result.messages[1].received.message.content, 'literal & message');
assert.equal(fs.existsSync(path.join(dir, 'injected.txt')), false);
@@ -0,0 +1,208 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
// Isolate VS Code/process doubles from Bun's shared module cache. The controller,
// resolver, and argument builder below are the production CommonJS modules.
// Leave five seconds beyond the child deadline for assertions and cleanup.
test('resume hands the selected transcript to the CLI, including reconnect and Windows paths', { timeout: 20000 }, () => {
const result = spawnSync('node', ['-e', String.raw`
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const Module = require('node:module');
const { EventEmitter } = require('node:events');
const { PassThrough } = require('node:stream');
const calls = [];
const children = [];
class Emitter {
constructor() {
this.emitter = new EventEmitter();
this.event = listener => {
this.emitter.on('event', listener);
return { dispose: () => this.emitter.off('event', listener) };
};
}
fire(value) { this.emitter.emit('event', value); }
dispose() { this.emitter.removeAllListeners(); }
}
let cwd;
const originalLoad = Module._load;
Module._load = function(name, ...rest) {
if (name === 'vscode') return {
EventEmitter: Emitter,
workspace: {
get workspaceFolders() { return [{ uri: { fsPath: cwd } }]; },
getConfiguration: () => ({ get: (_key, fallback) => fallback }),
},
};
if (name === 'child_process') return { spawn(command, args, options) {
calls.push({ command, args, options });
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.kill = signal => { child.killed = true; child.killSignal = signal; };
children.push(child);
return child;
} };
return originalLoad.call(this, name, ...rest);
};
const { SessionManager } = require('./sessionManager');
const { ChatController } = require('./chatProvider');
const { ProcessManager } = require('./processManager');
(async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-resume-'));
const controller = new ChatController(new SessionManager());
try {
cwd = path.join(root, 'a'.repeat(100), 'b'.repeat(100));
// The host resolves this root here; the CLI starts from the workspace.
process.chdir(root);
process.env.OPENCLAUDE_CONFIG_DIR = 'config with spaces & %PATH% !';
const id = '38f67e50-946e-4d74-b0b6-c19759b99623';
const prefix = cwd.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 200);
const file = path.join(root, process.env.OPENCLAUDE_CONFIG_DIR, 'projects', prefix + '-alternate', id + '.jsonl');
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify({ type: 'user', cwd, message: { content: 'saved prompt' } }) + '\n');
const manager = controller.sessionManager;
manager.setCwd(cwd);
const [selected] = await manager.listSessions();
assert.equal(selected.id, id);
await controller.startSession({ sessionId: selected.id });
assert.equal(controller.sessionId, id);
const resumeValue = () => calls.at(-1).args[calls.at(-1).args.indexOf('--resume') + 1];
assert.equal(calls.at(-1).options.cwd, cwd);
assert.equal(path.resolve(calls.at(-1).options.cwd, resumeValue()), file,
'the CLI must resolve the selected transcript from its own cwd');
assert.equal(resumeValue(), file);
children.at(-1).emit('close', 0);
controller._doSend = async () => {}; // Only inspect the restart launch, no model traffic.
await controller.sendMessage('continue');
assert.equal(resumeValue(), file);
assert.equal(controller.sessionId, id);
controller.stopSession();
// A stop during the new asynchronous lookup must not spawn an orphan.
let finish;
manager.getResumeTarget = () => new Promise(resolve => { finish = resolve; });
const pending = controller.startSession({ sessionId: id });
controller.stopSession();
const count = calls.length;
finish(file);
await pending;
assert.equal(calls.length, count);
// Two sends share the pending restart and both reach the new process.
const sent = [];
controller._doSend = async text => {
assert.ok(controller._process?.running);
sent.push(text);
};
const one = controller.sendMessage('one');
const two = controller.sendMessage('two');
const before = calls.length;
finish(file);
await Promise.all([one, two]);
assert.equal(calls.length, before + 1);
assert.deepEqual(sent, ['one', 'two']);
controller.stopSession();
const stoppedOne = controller.sendMessage('cancel one');
const stoppedTwo = controller.sendMessage('cancel two');
controller.stopSession();
const stoppedCount = calls.length;
finish(file);
await Promise.all([stoppedOne, stoppedTwo]);
assert.equal(calls.length, stoppedCount);
assert.deepEqual(sent, ['one', 'two']);
// Stop/Abort Generation must cancel a reconnect before it has a child.
// Hold the real resolver's directory read so the user action arrives
// during discovery, without replacing the controller's send method.
delete manager.getResumeTarget;
const aborting = new ChatController(manager);
const updates = [];
const states = [];
aborting.registerWebview({ postMessage: message => updates.push(message) });
aborting.onDidChangeState(state => states.push(state));
const realReaddir = fs.readdir;
let releaseRead;
const lookupStarted = new Promise(resolve => {
fs.readdir = async (directory, ...args) => {
if (directory === path.join(process.env.OPENCLAUDE_CONFIG_DIR, 'projects') && !releaseRead) {
await new Promise(release => { releaseRead = release; resolve(); });
}
return realReaddir(directory, ...args);
};
});
try {
// The saved session has already exited; sending now reconnects.
aborting._currentSessionId = id;
const cancelledOne = aborting.sendMessage('abort one');
const cancelledTwo = aborting.sendMessage('abort two');
await lookupStarted;
const beforeAbort = calls.length;
aborting.abort();
releaseRead();
await new Promise(setImmediate);
// If broken startup launches a child, let its real send path settle
// so the no-launch assertion, rather than a timeout, catches it.
if (calls.length > beforeAbort) children.at(-1).stdout.write('{"type":"system"}\n');
await Promise.all([cancelledOne, cancelledTwo]);
assert.equal(calls.length, beforeAbort, 'Abort must not launch the pending reconnect');
assert.ok(updates.some(message => message.type === 'stream_end' && message.final));
assert.equal(states.at(-1), 'idle');
} finally {
releaseRead?.();
fs.readdir = realReaddir;
aborting.stopSession();
}
// Cancelling discovery must leave ordinary running-process SIGINT intact.
await aborting.startSession({ sessionId: id });
const activeChild = children.at(-1);
aborting.abort();
assert.equal(activeChild.killSignal, 'SIGINT');
aborting.stopSession();
// An exact copy under a relative root also needs a stable file target.
delete manager.getResumeTarget;
let hash = 0;
for (const char of cwd) hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
const exact = path.join(root, process.env.OPENCLAUDE_CONFIG_DIR, 'projects', prefix + '-' + Math.abs(hash).toString(36), id + '.jsonl');
await fs.mkdir(path.dirname(exact), { recursive: true });
await fs.copyFile(file, exact);
await controller.startSession({ sessionId: id });
assert.equal(resumeValue(), exact,
'relative config roots must preserve exact-copy identity across cwd changes');
// With an absolute root, the exact Node copy retains UUID resume.
process.env.OPENCLAUDE_CONFIG_DIR = path.resolve(process.env.OPENCLAUDE_CONFIG_DIR);
await controller.startSession({ sessionId: id });
assert.equal(resumeValue(), id);
assert.equal(await manager.getResumeTarget('missing'), 'missing');
await controller.startSession();
assert.ok(!calls.at(-1).args.includes('--resume'));
controller.stopSession();
Object.defineProperty(process, 'platform', { value: 'win32' });
const winPath = 'C:\\config with spaces & %PATH% !\\' + id + '.jsonl';
const pm = new ProcessManager({ command: 'openclaude', sessionId: id, resumeTarget: winPath });
pm.start();
const win = calls.at(-1);
assert.equal(win.options.env.OPENCLAUDE_VSCODE_RESUME_PATH, undefined);
assert.notEqual(win.options.shell, true);
assert.equal(win.options.windowsVerbatimArguments, true);
assert.deepEqual(win.args.slice(0, 5), ['/d', '/e:on', '/v:off', '/s', '/c']);
assert.ok(win.args[5].includes('"--resume" "C:'));
assert.ok(!win.args[5].includes('OPENCLAUDE_VSCODE_RESUME_PATH'));
assert.equal(pm.sessionId, id);
pm.dispose();
} finally {
controller.stopSession();
await fs.rm(root, { recursive: true, force: true });
}
})().catch(error => { console.error(error); process.exitCode = 1; });
`], { cwd: __dirname, encoding: 'utf8', timeout: 15000 });
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout);
});
@@ -5,16 +5,18 @@
* Session files live under:
* ~/.openclaude/projects/<sanitized-cwd>/<sessionId>.jsonl
*
* Falls back to ~/.claude/projects/ for legacy installs.
* OPENCLAUDE_CONFIG_DIR overrides the config root, matching the CLI.
*/
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const os = require('os');
const readline = require('readline');
const crypto = require('crypto');
const MAX_SANITIZED_LENGTH = 80;
// Keep in sync with src/utils/sessionStoragePortable.ts.
const MAX_SANITIZED_LENGTH = 200;
function sanitizePath(name) {
const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-');
@@ -32,23 +34,38 @@ function simpleHash(str) {
}
function resolveConfigDir() {
const envDir = process.env.CLAUDE_CONFIG_DIR;
if (envDir) return envDir;
const home = os.homedir();
const openClaudeDir = path.join(home, '.openclaude');
const legacyDir = path.join(home, '.claude');
if (!fs.existsSync(openClaudeDir) && fs.existsSync(legacyDir)) {
return legacyDir;
}
return openClaudeDir;
// Match src/utils/envUtils.ts: OpenClaude and Claude Code are independent.
return (process.env.OPENCLAUDE_CONFIG_DIR || path.join(os.homedir(), '.openclaude')).normalize('NFC');
}
function getProjectsDir() {
return path.join(resolveConfigDir(), 'projects');
}
function getProjectDir(cwd) {
return path.join(getProjectsDir(), sanitizePath(cwd));
/** Same rule as resolveSessionFilePath: a zero-byte file is not a session. */
async function hasTranscriptBytes(filePath) {
try {
const stat = await fsp.stat(filePath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
/** Return the Node directory first, followed by alternate long-path hashes. */
async function getProjectDirs(cwd) {
const sanitized = sanitizePath(cwd);
const exact = path.join(getProjectsDir(), sanitized);
if (sanitized.length <= MAX_SANITIZED_LENGTH) return [exact];
try {
const prefix = sanitized.slice(0, MAX_SANITIZED_LENGTH) + '-';
const entries = await fsp.readdir(getProjectsDir(), { withFileTypes: true });
return [exact, ...entries
.filter(e => e.isDirectory() && e.name !== sanitized && e.name.startsWith(prefix))
.map(e => path.join(getProjectsDir(), e.name))];
} catch {
return [exact];
}
}
class SessionManager {
@@ -61,18 +78,18 @@ class SessionManager {
}
async listSessions() {
const projectDir = this._cwd
? getProjectDir(this._cwd)
: null;
const dirs = projectDir
? [projectDir]
: await this._allProjectDirs();
const cwd = this._cwd;
const dirs = cwd ? await getProjectDirs(cwd) : await this._allProjectDirs();
const sessions = [];
for (const dir of dirs) {
const items = await this._readSessionDir(dir);
sessions.push(...items);
const seenIds = new Set();
for (const [index, dir] of dirs.entries()) {
const items = await this._readSessionDir(dir, cwd && index > 0 ? cwd : null);
for (const item of items) {
if (cwd && seenIds.has(item.id)) continue;
seenIds.add(item.id);
sessions.push(item);
}
}
sessions.sort((a, b) => b.timestamp - a.timestamp);
@@ -92,7 +109,7 @@ class SessionManager {
}
}
async _readSessionDir(dir) {
async _readSessionDir(dir, expectedCwd = null) {
if (!fs.existsSync(dir)) return [];
try {
const files = await fsp.readdir(dir);
@@ -102,6 +119,8 @@ class SessionManager {
for (const file of jsonlFiles) {
const filePath = path.join(dir, file);
try {
if (!await hasTranscriptBytes(filePath)) continue;
if (expectedCwd && !await this._matchesWorkspace(filePath, expectedCwd)) continue;
const meta = await this._extractSessionMeta(filePath);
if (meta) results.push(meta);
} catch { /* skip unreadable */ }
@@ -113,6 +132,27 @@ class SessionManager {
}
}
/** Verify alternate hashes using the first transcript cwd, not just a shared prefix. */
async _matchesWorkspace(filePath, cwd) {
const input = fs.createReadStream(filePath, { encoding: 'utf8' });
const lines = readline.createInterface({ input, crlfDelay: Infinity });
try {
for await (const line of lines) {
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (entry && (entry.type === 'user' || entry.type === 'assistant') && typeof entry.cwd === 'string') {
return entry.cwd === cwd;
}
}
return false;
} catch {
return false;
} finally {
lines.close();
input.destroy();
}
}
async _extractSessionMeta(filePath) {
const sessionId = path.basename(filePath, '.jsonl');
const stat = await fsp.stat(filePath);
@@ -170,19 +210,35 @@ class SessionManager {
};
}
async loadSession(sessionId) {
const projectDir = this._cwd ? getProjectDir(this._cwd) : null;
const dirs = projectDir ? [projectDir] : await this._allProjectDirs();
/** Resolve the same transcript used by the history view and CLI resume. */
async resolveSessionPath(sessionId) {
const cwd = this._cwd;
const dirs = cwd ? await getProjectDirs(cwd) : await this._allProjectDirs();
for (const dir of dirs) {
for (const [index, dir] of dirs.entries()) {
const filePath = path.join(dir, `${sessionId}.jsonl`);
if (fs.existsSync(filePath)) {
return this._parseSessionFile(filePath);
if (await hasTranscriptBytes(filePath)) {
if (cwd && index > 0 && !await this._matchesWorkspace(filePath, cwd)) continue;
return filePath;
}
}
return null;
}
/** Keep UUID resume for exact absolute stores; stabilize all other file targets. */
async getResumeTarget(sessionId) {
const filePath = await this.resolveSessionPath(sessionId);
const exact = this._cwd && path.join(getProjectsDir(), sanitizePath(this._cwd), `${sessionId}.jsonl`);
return filePath && (filePath !== exact || !path.isAbsolute(filePath))
? path.resolve(filePath)
: sessionId;
}
async loadSession(sessionId) {
const filePath = await this.resolveSessionPath(sessionId);
return filePath ? this._parseSessionFile(filePath) : null;
}
async _parseSessionFile(filePath) {
const content = await fsp.readFile(filePath, 'utf8');
const lines = content.split('\n').filter(Boolean);
@@ -0,0 +1,139 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spyOn } = require('bun:test');
const { acquireEnvMutex, releaseEnvMutex } = require('../../../../src/entrypoints/sdk/shared.js');
const { resolveClaudeConfigHomeDir } = require('../../../../src/utils/envUtils.js');
const { sanitizePath, MAX_SANITIZED_LENGTH } = require('../../../../src/utils/sessionStoragePortable.js');
const { djb2Hash } = require('../../../../src/utils/hash.js');
const { SessionManager } = require('./sessionManager');
let root;
let home;
let homedirSpy;
let savedEnv;
const sessionId = '38f67e50-946e-4d74-b0b6-c19759b99623';
test.beforeEach(async () => {
const result = await acquireEnvMutex();
assert.ok(result.acquired);
savedEnv = {
OPENCLAUDE_CONFIG_DIR: process.env.OPENCLAUDE_CONFIG_DIR,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
};
root = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-sessions-'));
home = path.join(root, 'home');
homedirSpy = spyOn(os, 'homedir').mockReturnValue(home);
delete process.env.OPENCLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
});
test.afterEach(async () => {
try {
homedirSpy?.mockRestore();
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await fs.rm(root, { recursive: true, force: true });
} finally {
releaseEnvMutex();
}
});
async function writeSession(configDir, cwd, text, { id = sessionId, projectName = sanitizePath(cwd) } = {}) {
// Use the CLI's production path rules rather than the extension's helper.
const directory = path.join(configDir, 'projects', projectName);
await fs.mkdir(directory, { recursive: true });
const filePath = path.join(directory, `${id}.jsonl`);
await fs.writeFile(filePath, JSON.stringify({
type: 'user', message: { role: 'user', content: text }, cwd,
}) + '\n');
return filePath;
}
test('list and load use the CLI config override and ignore Claude Code history', async () => {
const cwd = '/workspace/demo';
process.env.OPENCLAUDE_CONFIG_DIR = path.join(root, 'cafe\u0301');
process.env.CLAUDE_CONFIG_DIR = path.join(root, 'claude-code');
const configDir = resolveClaudeConfigHomeDir({
configDirEnv: process.env.OPENCLAUDE_CONFIG_DIR,
});
const expectedPath = await writeSession(configDir, cwd, 'OpenClaude session');
await writeSession(process.env.CLAUDE_CONFIG_DIR, cwd, 'Unrelated session');
const manager = new SessionManager();
manager.setCwd(cwd);
const sessions = await manager.listSessions();
assert.deepEqual(sessions.map(s => s.filePath), [expectedPath]);
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: 'OpenClaude session' }]);
manager.setCwd(null);
assert.deepEqual((await manager.listSessions()).map(s => s.filePath), [expectedPath]);
});
test('default discovery never falls back to an existing Claude Code directory', async () => {
const cwd = '/workspace/demo';
await writeSession(path.join(home, '.claude'), cwd, 'Unrelated session');
const manager = new SessionManager();
manager.setCwd(cwd);
assert.deepEqual(await manager.listSessions(), []);
assert.equal(await manager.loadSession(sessionId), null);
const expectedPath = await writeSession(resolveClaudeConfigHomeDir({ homeDir: home }), cwd, 'Default session');
assert.deepEqual((await manager.listSessions()).map(s => s.filePath), [expectedPath]);
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: 'Default session' }]);
});
test('list and load match CLI project directories at truncation boundaries', async () => {
const configDir = resolveClaudeConfigHomeDir({ homeDir: home });
for (const length of [80, 81, 200, 201]) {
const cwd = '/' + 'p'.repeat(length - 1);
const expectedPath = await writeSession(configDir, cwd, `Session ${length}`);
const manager = new SessionManager();
manager.setCwd(cwd);
assert.deepEqual((await manager.listSessions()).map(s => s.filePath), [expectedPath], `path length ${length}`);
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: `Session ${length}` }]);
}
});
test('mixed Node/Bun histories remain discoverable without duplicate or unrelated sessions', async () => {
const configDir = resolveClaudeConfigHomeDir({ homeDir: home });
const cwd = '/' + 'p'.repeat(MAX_SANITIZED_LENGTH) + '/project';
const prefix = cwd.replace(/[^a-zA-Z0-9]/g, '-').slice(0, MAX_SANITIZED_LENGTH);
const nodeProject = prefix + '-' + Math.abs(djb2Hash(cwd)).toString(36);
const bunId = 'fe3477aa-67af-47a0-a355-2f15c348d884';
const unrelatedId = 'cc0d9b97-04c0-402f-ad89-92e9993b53f9';
const nodePath = await writeSession(configDir, cwd, 'Node session', { projectName: nodeProject });
await writeSession(configDir, cwd, 'Older copy of Node session');
// cwd follows the message in real CLI transcripts, so it can lie beyond
// the metadata reader's 64 KiB head buffer.
const longPrompt = 'Bun session ' + 'x'.repeat(70000);
const bunPath = await writeSession(configDir, cwd, longPrompt, { id: bunId });
await writeSession(configDir, cwd + '-unrelated', 'Other workspace', { id: unrelatedId });
const manager = new SessionManager();
manager.setCwd(cwd);
assert.deepEqual((await manager.listSessions()).map(s => s.filePath).sort(), [nodePath, bunPath].sort());
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: 'Node session' }]);
assert.deepEqual(await manager.loadSession(bunId), [{ role: 'user', text: longPrompt }]);
assert.equal(await manager.loadSession(unrelatedId), null);
});
test('a zero-byte exact transcript does not hide a populated alternate history', async () => {
const configDir = resolveClaudeConfigHomeDir({ homeDir: home });
const cwd = '/' + 'p'.repeat(MAX_SANITIZED_LENGTH) + '/project';
const prefix = cwd.replace(/[^a-zA-Z0-9]/g, '-').slice(0, MAX_SANITIZED_LENGTH);
const exactProject = prefix + '-' + Math.abs(djb2Hash(cwd)).toString(36);
const exactPath = path.join(configDir, 'projects', exactProject, `${sessionId}.jsonl`);
await fs.mkdir(path.dirname(exactPath), { recursive: true });
await fs.writeFile(exactPath, '');
const alternatePath = await writeSession(configDir, cwd, 'Recovered history', {
projectName: prefix + '-alternate',
});
const manager = new SessionManager();
manager.setCwd(cwd);
assert.deepEqual((await manager.listSessions()).map(session => session.filePath), [alternatePath]);
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: 'Recovered history' }]);
assert.equal(await manager.getResumeTarget(sessionId), alternatePath);
});