From 569f24c11a6ddb582c822d404e441568f874caea Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 15:44:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20sessionBundle=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=96=87=E4=BB=B6=E6=9F=A5=E6=89=BE/?= =?UTF-8?q?=E5=AE=89=E8=A3=85/=E5=8E=BB=E9=87=8D=E5=8E=9F=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- vscode/src/cc/sessionBundle.test.ts | 125 ++++++++++++++++++++++++++++ vscode/src/cc/sessionBundle.ts | 122 +++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 vscode/src/cc/sessionBundle.test.ts create mode 100644 vscode/src/cc/sessionBundle.ts diff --git a/vscode/src/cc/sessionBundle.test.ts b/vscode/src/cc/sessionBundle.test.ts new file mode 100644 index 0000000..6dd5c53 --- /dev/null +++ b/vscode/src/cc/sessionBundle.test.ts @@ -0,0 +1,125 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + codexRolloutRelPathFor, + findClaudeSessionFiles, + installClaudeSession, + installCodexSession, + listClaudeSessionCopies, +} from './sessionBundle' + +const tempDirs: string[] = [] +afterEach(() => { + while (tempDirs.length > 0) { + const d = tempDirs.pop() + if (d) + rmSync(d, { recursive: true, force: true }) + } +}) +function tempDir(prefix: string): string { + const d = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(d) + return d +} + +const SID = '11111111-2222-4333-8444-555555555555' + +function writeSession(projectsRoot: string, encoded: string, sid: string, body: string, withDir = false): string { + const dir = path.join(projectsRoot, encoded) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, `${sid}.jsonl`), body) + if (withDir) { + mkdirSync(path.join(dir, sid, 'subagents'), { recursive: true }) + writeFileSync(path.join(dir, sid, 'subagents', 'agent-a.jsonl'), '{}\n') + } + return dir +} + +describe('listClaudeSessionCopies / findClaudeSessionFiles', () => { + it('列出所有 project 目录下的同 sid 副本,含子目录', async () => { + const root = tempDir('cc-projects-') + const a = writeSession(root, '-home-a-proj', SID, 'a\n', true) + const b = writeSession(root, '-home-a-wt', SID, 'b\n') + const copies = await listClaudeSessionCopies(SID, root) + expect(copies.map(c => c.projectsDir).sort()).toEqual([a, b].sort()) + expect(copies.find(c => c.projectsDir === a)?.dir).toBe(path.join(a, SID)) + expect(copies.find(c => c.projectsDir === b)?.dir).toBeUndefined() + }) + + it('多份副本取 jsonl mtime 最新的', async () => { + const root = tempDir('cc-projects-') + const old = writeSession(root, '-old', SID, 'old\n') + const fresh = writeSession(root, '-fresh', SID, 'fresh\n') + utimesSync(path.join(old, `${SID}.jsonl`), new Date(0), new Date(0)) + const found = await findClaudeSessionFiles(SID, root) + expect(found?.projectsDir).toBe(fresh) + }) + + it('projectsRoot 不存在 → 空 / undefined', async () => { + expect(await listClaudeSessionCopies(SID, '/nonexistent/x')).toEqual([]) + expect(await findClaudeSessionFiles(SID, '/nonexistent/x')).toBeUndefined() + }) +}) + +describe('installClaudeSession', () => { + it('从外部目录装入目标 project 目录,并清掉本机其它副本', async () => { + const root = tempDir('cc-projects-') + const stale = writeSession(root, '-stale', SID, 'stale\n', true) + const src = tempDir('cc-src-') + mkdirSync(path.join(src, SID, 'tool-results'), { recursive: true }) + writeFileSync(path.join(src, `${SID}.jsonl`), 'new\n') + writeFileSync(path.join(src, SID, 'tool-results', 'r.txt'), 'r') + const dst = path.join(root, '-home-me-wt') + await installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: dst, projectsRoot: root }) + expect(readFileSync(path.join(dst, `${SID}.jsonl`), 'utf8')).toBe('new\n') + expect(existsSync(path.join(dst, SID, 'tool-results', 'r.txt'))).toBe(true) + expect(existsSync(path.join(stale, `${SID}.jsonl`))).toBe(false) + expect(existsSync(path.join(stale, SID))).toBe(false) + }) + + it('源目录就在 projectsRoot 下(搬迁):目标保留,源删除', async () => { + const root = tempDir('cc-projects-') + const from = writeSession(root, '-wt', SID, 'x\n', true) + const to = path.join(root, '-proj') + await installClaudeSession({ sid: SID, srcDir: from, dstProjectsDir: to, projectsRoot: root }) + expect(readFileSync(path.join(to, `${SID}.jsonl`), 'utf8')).toBe('x\n') + expect(existsSync(path.join(to, SID, 'subagents', 'agent-a.jsonl'))).toBe(true) + expect(existsSync(path.join(from, `${SID}.jsonl`))).toBe(false) + }) + + it('源就是目标 → 幂等不报错', async () => { + const root = tempDir('cc-projects-') + const dir = writeSession(root, '-proj', SID, 'x\n') + await installClaudeSession({ sid: SID, srcDir: dir, dstProjectsDir: dir, projectsRoot: root }) + expect(readFileSync(path.join(dir, `${SID}.jsonl`), 'utf8')).toBe('x\n') + }) + + it('源缺 jsonl → 抛错', async () => { + const root = tempDir('cc-projects-') + const src = tempDir('cc-src-') + await expect(installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: path.join(root, '-x'), projectsRoot: root })).rejects.toThrow(SID) + }) +}) + +describe('codex', () => { + const UUID = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' + it('按文件名尾部 uuid 定位 rollout,返回相对路径', async () => { + const codexRoot = tempDir('codex-') + const day = path.join(codexRoot, '2026', '08', '26') + mkdirSync(day, { recursive: true }) + writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${UUID}.jsonl`), '{}\n') + expect(await codexRolloutRelPathFor(UUID.toUpperCase(), codexRoot)).toBe(path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`)) + expect(await codexRolloutRelPathFor('00000000-0000-4000-8000-000000000000', codexRoot)).toBeUndefined() + }) + + it('installCodexSession 按 relPath 落到 codexRoot 下并覆盖', async () => { + const codexRoot = tempDir('codex-') + const src = tempDir('codex-src-') + const rel = path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`) + writeFileSync(path.join(src, 'r.jsonl'), 'new\n') + await installCodexSession(rel, path.join(src, 'r.jsonl'), codexRoot) + expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n') + }) +}) diff --git a/vscode/src/cc/sessionBundle.ts b/vscode/src/cc/sessionBundle.ts new file mode 100644 index 0000000..b32d227 --- /dev/null +++ b/vscode/src/cc/sessionBundle.ts @@ -0,0 +1,122 @@ +/** + * claude / codex 会话文件的搬运原语。 + * + * claude 会话 = `/.jsonl` + 可选 `//` + * (subagents、tool-results),两者必须一起搬。新版 claude 在任意目录 + * `--resume` 时会搜全部 project 目录,同一 sid 出现两份就拒绝 resume—— + * 所以「安装」总是先拷再删本机其它副本。 + */ + +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { ROLLOUT_UUID_REGEX } from './codexSessionWatcher' + +export interface ClaudeSessionFiles { + projectsDir: string + jsonl: string + dir?: string +} + +export function claudeProjectsRoot(): string { + return path.join(os.homedir(), '.claude', 'projects') +} + +async function exists(p: string): Promise { + try { + await fsp.stat(p) + return true + } + catch { + return false + } +} + +async function sessionFilesIn(projectsDir: string, sid: string): Promise { + const jsonl = path.join(projectsDir, `${sid}.jsonl`) + if (!(await exists(jsonl))) + return undefined + const dir = path.join(projectsDir, sid) + return (await exists(dir)) ? { projectsDir, jsonl, dir } : { projectsDir, jsonl } +} + +export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise { + let entries: string[] + try { + entries = await fsp.readdir(projectsRoot) + } + catch { + return [] + } + const out: ClaudeSessionFiles[] = [] + for (const name of entries) { + const found = await sessionFilesIn(path.join(projectsRoot, name), sid) + if (found) + out.push(found) + } + return out +} + +export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise { + const copies = await listClaudeSessionCopies(sid, projectsRoot) + if (copies.length === 0) + return undefined + const stamped = await Promise.all(copies.map(async c => ({ c, mtime: (await fsp.stat(c.jsonl)).mtimeMs }))) + stamped.sort((a, b) => b.mtime - a.mtime) + return stamped[0].c +} + +export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise { + await fsp.mkdir(dstDir, { recursive: true }) + await fsp.copyFile(files.jsonl, path.join(dstDir, path.basename(files.jsonl))) + if (files.dir) + await fsp.cp(files.dir, path.join(dstDir, path.basename(files.dir)), { recursive: true, force: true }) +} + +async function removeClaudeSessionFiles(files: ClaudeSessionFiles): Promise { + await fsp.rm(files.jsonl, { force: true }) + if (files.dir) + await fsp.rm(files.dir, { recursive: true, force: true }) +} + +export async function installClaudeSession(opts: { + sid: string + srcDir: string + dstProjectsDir: string + projectsRoot: string +}): Promise { + const src = await sessionFilesIn(path.resolve(opts.srcDir), opts.sid) + if (!src) + throw new Error(`源目录缺少会话文件 ${opts.sid}.jsonl:${opts.srcDir}`) + const dst = path.resolve(opts.dstProjectsDir) + if (src.projectsDir !== dst) + await copyClaudeSessionFiles(src, dst) + for (const copy of await listClaudeSessionCopies(opts.sid, opts.projectsRoot)) { + if (path.resolve(copy.projectsDir) !== dst) + await removeClaudeSessionFiles(copy) + } +} + +/** codex rollout 全局存放,按文件名尾部 uuid 定位;返回相对 codexRoot 的路径。 */ +export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise { + const want = sid.toLowerCase() + let entries: string[] + try { + entries = await fsp.readdir(codexRoot, { recursive: true }) + } + catch { + return undefined + } + for (const entry of entries) { + const m = path.basename(entry).match(ROLLOUT_UUID_REGEX) + if (m && m[1].toLowerCase() === want) + return entry + } + return undefined +} + +export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise { + const dst = path.join(codexRoot, relPath) + await fsp.mkdir(path.dirname(dst), { recursive: true }) + await fsp.copyFile(srcFile, dst) +}