205 lines
5.9 KiB
TypeScript
205 lines
5.9 KiB
TypeScript
/**
|
|
* 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/<encoded-cwd>/<sid>.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<string | null> {
|
|
const { projectsDir, timeoutMs = 120_000 } = opts
|
|
|
|
let snapshot: Set<string>
|
|
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<string | null>((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<string | null> {
|
|
const { projectsDir, timeoutMs = 120_000, intervalMs = 1000 } = opts
|
|
|
|
let snapshot: Set<string>
|
|
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<string | null>((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<void> => {
|
|
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))
|
|
}
|