155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
/**
|
||
* claude / codex 会话文件的搬运原语。
|
||
*
|
||
* claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选 `<projectsDir>/<sid>/`
|
||
* (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<boolean> {
|
||
try {
|
||
await fsp.stat(p)
|
||
return true
|
||
}
|
||
catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function sessionFilesIn(projectsDir: string, sid: string): Promise<ClaudeSessionFiles | undefined> {
|
||
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<ClaudeSessionFiles[]> {
|
||
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<ClaudeSessionFiles | undefined> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string | undefined> {
|
||
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<void> {
|
||
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<number> {
|
||
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<number> {
|
||
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
|
||
}
|