Files
superwork/vscode/src/cc/codexSessionWatcher.ts
T

189 lines
6.0 KiB
TypeScript

/**
* Watch the codex sessions root directory for the next new `rollout-*.jsonl`
* file to appear, returning its thread_id.
*
* Used by the auto-review flow: when we `sendText('codex exec review ...')`
* into a terminal tab, codex writes its session transcript to
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. We can't sniff
* the terminal's stdout, so instead we watch this filesystem root for new
* files and extract the thread_id from the filename / first line.
*
* File layout (probed 2026-05-20):
*
* - Directory: ~/.codex/sessions/YYYY/MM/DD/
* - Filename: rollout-2026-04-27T02-57-26-019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a.jsonl
* └────────────── thread_id (UUID v7) ──────────────┘
* - First line: {"timestamp":"...","type":"session_meta","payload":{"id":"019dcb27-...","cwd":"..."}}
*
* Strategy: prefer UUID extracted from the filename (no I/O); fall back to
* parsing the first JSON line if the regex doesn't match.
*/
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
/** UUID at the tail of `rollout-<ISO>-<uuid>.jsonl`. v7 UUIDs are still 8-4-4-4-12. */
export const ROLLOUT_UUID_REGEX = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
/**
* Try to extract a thread_id (UUID) from a rollout filename.
* Returns null when the name doesn't match the expected pattern.
*/
function extractIdFromFilename(filename: string): string | null {
const m = filename.match(ROLLOUT_UUID_REGEX)
return m ? m[1] : null
}
/**
* Read the first line of `filePath` and try to extract `payload.id`
* (codex's session_meta thread id). Also accepts top-level `thread_id` /
* `session_id` / `id` for robustness against future codex format tweaks.
*/
async function extractIdFromFirstLine(filePath: string): Promise<string | null> {
let raw: string
try {
// Files are typically <100KB at session start; reading the whole thing
// and slicing to the first newline is simpler than a streaming reader
// and avoids race-with-partial-write issues.
raw = await fsp.readFile(filePath, 'utf8')
}
catch {
return null
}
const nl = raw.indexOf('\n')
const firstLine = nl === -1 ? raw : raw.slice(0, nl)
if (!firstLine.trim())
return null
let parsed: unknown
try {
parsed = JSON.parse(firstLine)
}
catch {
return null
}
if (!parsed || typeof parsed !== 'object')
return null
const obj = parsed as Record<string, unknown>
const payload = obj.payload
if (payload && typeof payload === 'object') {
const pid = (payload as Record<string, unknown>).id
if (typeof pid === 'string' && pid.length > 0)
return pid
}
for (const k of ['thread_id', 'session_id', 'id'] as const) {
const v = obj[k]
if (typeof v === 'string' && v.length > 0)
return v
}
return null
}
/**
* Watch `baseDir` recursively for the next new `rollout-*.jsonl` file and
* resolve with its thread_id. Resolves with null on timeout, watcher error,
* or when `fs.watch` isn't supported on this platform.
*
* Caller is responsible for ensuring `baseDir` exists; if it doesn't, this
* returns null immediately.
*/
export async function watchForNewCodexSession(opts: {
/** Base directory to watch recursively, typically `~/.codex/sessions`. */
baseDir: string
/** Max milliseconds to wait before giving up. */
timeoutMs: number
}): Promise<string | null> {
const { baseDir, timeoutMs } = opts
try {
const stat = await fsp.stat(baseDir)
if (!stat.isDirectory())
return null
}
catch {
// baseDir doesn't exist (codex never run on this machine?). The first
// codex run will create it, but `fs.watch` on a missing path throws —
// bail out so the caller doesn't hang.
return null
}
return new Promise<string | null>((resolve) => {
let settled = false
let watcher: fs.FSWatcher | null = null
let timer: NodeJS.Timeout | null = null
// Dedupe: fs.watch fires 'rename' twice on some platforms; skip files we
// already inspected this session.
const seen = new Set<string>()
const done = (id: 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(id)
}
try {
// `recursive: true` is supported on Linux since Node 20. On older
// runtimes the call throws — fall through to done(null).
watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (event, filename) => {
if (settled || !filename)
return
if (event !== 'rename')
return
const name = filename.toString()
if (!name.endsWith('.jsonl'))
return
const base = path.basename(name)
if (!base.startsWith('rollout-'))
return
if (seen.has(name))
return
seen.add(name)
const full = path.join(baseDir, name)
// `rename` fires for both create AND delete; confirm the file is
// really there before claiming a hit.
fsp.stat(full).then(async () => {
if (settled)
return
let id = extractIdFromFilename(base)
if (!id)
id = await extractIdFromFirstLine(full)
if (id)
done(id)
}).catch(() => {
// File no longer exists (delete event) — ignore.
})
})
watcher.on('error', (err) => {
// eslint-disable-next-line no-console
console.warn('[superpowers] codexSessionWatcher: watch error', err)
done(null)
})
}
catch (err) {
// eslint-disable-next-line no-console
console.warn('[superpowers] codexSessionWatcher: fs.watch threw', err)
done(null)
return
}
timer = setTimeout(() => {
done(null)
}, timeoutMs)
})
}