mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user