From da7e6631ccaf3058b2336c545d5f3fcfa66521da Mon Sep 17 00:00:00 2001 From: JATMN Date: Sun, 25 Oct 2026 09:39:23 -0700 Subject: [PATCH] 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. --- src/utils/conversationRecovery.test.ts | 28 +++ src/utils/conversationRecovery.ts | 59 +---- src/utils/sessionStorage.ts | 32 ++- vscode-extension/openclaude-vscode/README.md | 8 + .../src/chat/chatProvider.js | 46 +++- .../src/chat/processManager.js | 4 +- .../src/chat/processManager.test.js | 8 +- .../openclaude-vscode/src/chat/resume.test.js | 208 ++++++++++++++++++ .../src/chat/sessionManager.js | 116 +++++++--- .../src/chat/sessionManager.test.js | 139 ++++++++++++ 10 files changed, 545 insertions(+), 103 deletions(-) create mode 100644 vscode-extension/openclaude-vscode/src/chat/resume.test.js create mode 100644 vscode-extension/openclaude-vscode/src/chat/sessionManager.test.js diff --git a/src/utils/conversationRecovery.test.ts b/src/utils/conversationRecovery.test.ts index d364d9ac0..a9436ab2c 100644 --- a/src/utils/conversationRecovery.test.ts +++ b/src/utils/conversationRecovery.test.ts @@ -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') diff --git a/src/utils/conversationRecovery.ts b/src/utils/conversationRecovery.ts index b77c15cb7..5d6c35ece 100644 --- a/src/utils/conversationRecovery.ts +++ b/src/utils/conversationRecovery.ts @@ -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 ? 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) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index a78cfc6a0..d7020414e 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -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 { +export async function loadFullLog(source: LogOption | string): Promise { + // 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 { 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 { const sessionId = mostRecentLeaf.sessionId as UUID | undefined return { ...log, + sessionId: fromPath ? sessionId : log.sessionId, messages: removeExtraFields(transcript), firstPrompt: extractFirstPrompt(transcript), messageCount: countVisibleMessages(transcript), diff --git a/vscode-extension/openclaude-vscode/README.md b/vscode-extension/openclaude-vscode/README.md index 66ac96ddc..434c568ee 100644 --- a/vscode-extension/openclaude-vscode/README.md +++ b/vscode-extension/openclaude-vscode/README.md @@ -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` diff --git a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js index eac0ab585..1be7f7d5c 100644 --- a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js +++ b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js @@ -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 }); diff --git a/vscode-extension/openclaude-vscode/src/chat/processManager.js b/vscode-extension/openclaude-vscode/src/chat/processManager.js index f279c0c5e..6e88add7f 100644 --- a/vscode-extension/openclaude-vscode/src/chat/processManager.js +++ b/vscode-extension/openclaude-vscode/src/chat/processManager.js @@ -26,6 +26,7 @@ class ProcessManager { * @param {string} [opts.cwd] - Working directory * @param {Record} [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'); } diff --git a/vscode-extension/openclaude-vscode/src/chat/processManager.test.js b/vscode-extension/openclaude-vscode/src/chat/processManager.test.js index 5696115d9..72f103fbb 100644 --- a/vscode-extension/openclaude-vscode/src/chat/processManager.test.js +++ b/vscode-extension/openclaude-vscode/src/chat/processManager.test.js @@ -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); diff --git a/vscode-extension/openclaude-vscode/src/chat/resume.test.js b/vscode-extension/openclaude-vscode/src/chat/resume.test.js new file mode 100644 index 000000000..9c7841f9c --- /dev/null +++ b/vscode-extension/openclaude-vscode/src/chat/resume.test.js @@ -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); +}); diff --git a/vscode-extension/openclaude-vscode/src/chat/sessionManager.js b/vscode-extension/openclaude-vscode/src/chat/sessionManager.js index c5530c999..33e88b6f7 100644 --- a/vscode-extension/openclaude-vscode/src/chat/sessionManager.js +++ b/vscode-extension/openclaude-vscode/src/chat/sessionManager.js @@ -5,16 +5,18 @@ * Session files live under: * ~/.openclaude/projects//.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); diff --git a/vscode-extension/openclaude-vscode/src/chat/sessionManager.test.js b/vscode-extension/openclaude-vscode/src/chat/sessionManager.test.js new file mode 100644 index 000000000..4f8d3cf15 --- /dev/null +++ b/vscode-extension/openclaude-vscode/src/chat/sessionManager.test.js @@ -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); +});