/** * Watch a claude projects directory for the next new .jsonl file to appear. * * Used by the "实施" flow: when we spawn `claude` in a worktree, the CLI * writes its session transcript to `~/.claude/projects//.jsonl`. * We pre-snapshot the directory, then watch for any new `.jsonl` whose name * isn't in the snapshot — that file's basename (minus `.jsonl`) is the * implementation session id. */ import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' /** * Watch a claude projects directory for the next new .jsonl file to appear, * returning its basename (without the .jsonl suffix) — that's the session id. * * Pre-snapshots existing files at start time so a later-created file is what * resolves the promise. Times out to null after `timeoutMs`. * * Caller is responsible for ensuring the directory exists before calling * (use `fs.mkdir(dir, { recursive: true })`). */ export async function watchForNewSession(opts: { projectsDir: string timeoutMs?: number }): Promise { const { projectsDir, timeoutMs = 120_000 } = opts let snapshot: Set try { const existing = await fsp.readdir(projectsDir) snapshot = new Set(existing.filter(n => n.endsWith('.jsonl'))) } catch (err) { console.warn('[superpowers] sessionWatcher: readdir failed', err) return null } return new Promise((resolve) => { let settled = false let watcher: fs.FSWatcher | null = null let timer: NodeJS.Timeout | null = null const done = (sid: string | null): void => { if (settled) return settled = true if (timer) { clearTimeout(timer) timer = null } if (watcher) { try { watcher.close() } catch { // ignore close errors } watcher = null } resolve(sid) } try { watcher = fs.watch(projectsDir, { persistent: false }, (event, filename) => { if (settled) return if (event !== 'rename' || !filename) return const name = filename.toString() if (!name.endsWith('.jsonl')) return if (snapshot.has(name)) return // `rename` fires for both create and delete; confirm the file is // really there before resolving. const full = path.join(projectsDir, name) fsp.stat(full).then(() => { if (settled) return const sid = name.slice(0, -'.jsonl'.length) done(sid) }).catch(() => { // File no longer exists (delete event) — ignore. }) }) watcher.on('error', (err) => { console.warn('[superpowers] sessionWatcher: watch error', err) done(null) }) } catch (err) { console.warn('[superpowers] sessionWatcher: fs.watch threw', err) done(null) return } timer = setTimeout(() => { done(null) }, timeoutMs) }) } /** * 轮询版会话捕获:snapshot 现有 .jsonl,之后每 intervalMs 轮询一次目录, * 返回首个新出现的 .jsonl 的 session id(去掉 .jsonl 后缀)。超时返回 null。 * * 比 fs.watch 在繁忙/共享 projects 目录里更可靠:fs.watch 的 rename 事件在 * 高并发写入的目录里会漏掉,导致永远等不到新会话。轮询通过周期性 readdir * diff 来避免这个问题。 * * Caller is responsible for ensuring the directory exists before calling * (use `fs.mkdir(dir, { recursive: true })`). */ export async function pollForNewSession(opts: { projectsDir: string timeoutMs?: number intervalMs?: number }): Promise { const { projectsDir, timeoutMs = 120_000, intervalMs = 1000 } = opts let snapshot: Set try { const existing = await fsp.readdir(projectsDir) snapshot = new Set(existing.filter(n => n.endsWith('.jsonl'))) } catch (err) { console.warn('[superpowers] sessionWatcher: poll readdir failed', err) return null } return new Promise((resolve) => { let settled = false let timer: NodeJS.Timeout | null = null const deadline = Date.now() + timeoutMs const done = (sid: string | null): void => { if (settled) return settled = true if (timer) { clearInterval(timer) timer = null } resolve(sid) } const tick = async (): Promise => { if (settled) return if (Date.now() >= deadline) { done(null) return } let current: string[] try { current = await fsp.readdir(projectsDir) } catch { // 目录可能临时 readdir 失败(被并发改写等);忽略本轮,继续轮询。 return } const fresh = current.filter(n => n.endsWith('.jsonl') && !snapshot.has(n)) if (fresh.length === 0) return // 多个新文件时取 mtime 最新的那个。 let newest: string | null = null let newestMtime = -1 for (const name of fresh) { try { const st = await fsp.stat(path.join(projectsDir, name)) if (st.mtimeMs > newestMtime) { newestMtime = st.mtimeMs newest = name } } catch { // 文件可能已被删除;跳过。 } } if (settled) return if (newest) done(newest.slice(0, -'.jsonl'.length)) } timer = setInterval(() => { void tick() }, intervalMs) }) } /** Convert an absolute path to claude's projects-dir encoding ('/' -> '-'). */ export function encodeCwdForProjectsDir(absPath: string): string { return absPath.replace(/[/.]/g, '-') } /** Absolute path of the claude projects subdir for a given cwd. */ export function projectsDirFor(absCwd: string): string { return path.join(os.homedir(), '.claude', 'projects', encodeCwdForProjectsDir(absCwd)) }