/** * 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) } /** * 迁移会话里发送方的 worktree 路径重写成接收方的路径。 * split/join 而非正则替换——路径里的 `.` `(` 等字符不用转义,出现次数就是替换次数。 */ export async function rewritePathInFile(file: string, from: string, to: string): Promise { const content = await fsp.readFile(file, 'utf8') const parts = content.split(from) const count = parts.length - 1 if (count > 0) await fsp.writeFile(file, parts.join(to)) return count } export async function rewritePathInTree(dir: string, from: string, to: string): Promise { let entries: Array<{ name: string, isDirectory: () => boolean, isFile: () => boolean }> try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return 0 } let total = 0 for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isDirectory()) total += await rewritePathInTree(full, from, to) else if (entry.isFile()) total += await rewritePathInFile(full, from, to) } return total }