/** * 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。 * * 会话 id 存在工单的共享状态里,别的机器上开的会话本机没有 transcript, * resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。 * * - claude:`~/.claude/projects/<编码后的 cwd>/.jsonl`。新版 `claude --resume` * 会搜全部 project 目录,任一 project 目录下有 `.jsonl` 即可 resume; * `/` 子目录放的是 subagent 等附属文件,不用往下扫。 * - codex:`~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`,全局存放, * 与 worktree 无关。 */ import type { Issue } from '../issues/types' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher' import { CLAUDE_SESSION_FIELDS } from '../cc/handoffManifest' import { claudeProjectsRoot, codexRolloutRelPathFor, listClaudeSessionCopies } from '../cc/sessionBundle' /** codex 会话根目录(全局,不随工作区变化)。 */ export function defaultCodexSessionsDir(): string { return path.join(os.homedir(), '.codex', 'sessions') } /** * 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id * 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按 * 「本机没有会话文件」处理即可。 */ export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise> { const ids = new Set() let entries: string[] try { entries = await fsp.readdir(baseDir, { recursive: true }) } catch { return ids } for (const entry of entries) { const m = path.basename(entry).match(ROLLOUT_UUID_REGEX) if (m) ids.add(m[1].toLowerCase()) } return ids } /** * 扫 projects 根目录下每个 project 目录(只看这一层),收集 `.jsonl` * 的会话 id(统一小写)。根目录不存在返回空 Set。 */ export async function listClaudeSessionIds(projectsRoot: string = claudeProjectsRoot()): Promise> { const ids = new Set() let projects: string[] try { projects = await fsp.readdir(projectsRoot) } catch { return ids } await Promise.all(projects.map(async (name) => { let files: string[] try { files = await fsp.readdir(path.join(projectsRoot, name)) } // 根目录下混着的普通文件(.DS_Store 等)readdir 抛 ENOTDIR,跳过 catch { return } for (const file of files) { if (file.endsWith('.jsonl')) ids.add(file.slice(0, -'.jsonl'.length).toLowerCase()) } })) return ids } /** 单个 claude 会话 id 本机是否有 transcript;resume 前的权威判定。 */ export async function claudeSessionFileExists(sid: string, projectsRoot: string = claudeProjectsRoot()): Promise { return (await listClaudeSessionCopies(sid, projectsRoot)).length > 0 } /** 单个 codex 会话 id 本机是否有 rollout 文件;resume 前的权威判定。 */ export async function codexSessionFileExists(sid: string, codexRoot: string = defaultCodexSessionsDir()): Promise { return (await codexRolloutRelPathFor(sid, codexRoot)) !== undefined } type SessionIdFields = Pick function claudeIdsOf(item: SessionIdFields): string[] { return CLAUDE_SESSION_FIELDS .map(({ field }) => item[field]) .filter((id): id is string => typeof id === 'string' && id.length > 0) } function codexIdsOf(item: SessionIdFields): string[] { return item.reviewSessionId ? [item.reviewSessionId] : [] } /** * 给工单附加计算字段 `missingSessionIds`(不落任何持久化):它引用的会话 id * 里本机没有会话文件的那些;全部能 resume 时不加字段。每种会话目录整批最多 * 扫一次,没有任何工单引用该种 id 时连扫都不扫。 */ export async function annotateMissingSessionIds( items: T[], opts: { claudeRoot?: string, codexRoot?: string } = {}, ): Promise> { const [claudeIds, codexIds] = await Promise.all([ items.some(i => claudeIdsOf(i).length > 0) ? listClaudeSessionIds(opts.claudeRoot) : undefined, items.some(i => codexIdsOf(i).length > 0) ? listCodexSessionIds(opts.codexRoot) : undefined, ]) return items.map((item) => { const missing = [ ...claudeIdsOf(item).filter(id => !claudeIds?.has(id.toLowerCase())), ...codexIdsOf(item).filter(id => !codexIds?.has(id.toLowerCase())), ] return missing.length > 0 ? { ...item, missingSessionIds: missing } : item }) }