11
This commit is contained in:
commit
e6f1776d4f
264 files changed
+54215
No files matched your search
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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. */
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Lists Claude settings profiles from a hardcoded directory. Each `.json`
|
||||
* file there is treated as a profile whose `name` is its basename without
|
||||
* the extension and whose `path` is the absolute file path, suitable for
|
||||
* passing to `claude --settings <path>`.
|
||||
*
|
||||
* Failures (directory missing, unreadable, etc.) are swallowed — the
|
||||
* caller treats an empty list as "no profile selector".
|
||||
*/
|
||||
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
const PROFILES_DIR = '/home/cruldra/Sources/cruldra-profile/claude-config/profiles'
|
||||
|
||||
export interface ClaudeProfile {
|
||||
/** basename without .json */
|
||||
name: string
|
||||
/** absolute path */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List Claude settings profiles from the hardcoded directory. Returns an
|
||||
* empty array if the directory is missing or unreadable; does not throw.
|
||||
* Sorted alphabetically by name.
|
||||
*/
|
||||
export async function listClaudeProfiles(): Promise<ClaudeProfile[]> {
|
||||
try {
|
||||
const entries = await readdir(PROFILES_DIR)
|
||||
const profiles: ClaudeProfile[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.json'))
|
||||
continue
|
||||
const name = entry.slice(0, -'.json'.length)
|
||||
profiles.push({ name, path: path.join(PROFILES_DIR, entry) })
|
||||
}
|
||||
profiles.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return profiles
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Prompt templates for the two claude entry points (brainstorm and
|
||||
* implement-plan). Defaults live in `src/settings/store.ts` (alongside the
|
||||
* other tunables); these helpers just read the persisted setting and run
|
||||
* placeholder substitution.
|
||||
*
|
||||
* Substitution is a plain global string replace of the placeholder text
|
||||
* (`{userRequest}` / `{planFile}`) — no template engine. If the user removes
|
||||
* the placeholder, substitution is a no-op and that's their bug.
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from 'vscode'
|
||||
import { getSettings } from '../settings/store'
|
||||
|
||||
export function getBrainstormPrompt(
|
||||
ctx: ExtensionContext,
|
||||
vars: { userRequest: string, nonce: string, imagePaths?: string[] },
|
||||
): string {
|
||||
const tpl = getSettings(ctx).brainstormPrompt
|
||||
let out = tpl
|
||||
.split('{userRequest}')
|
||||
.join(vars.userRequest)
|
||||
.split('{nonce}')
|
||||
.join(vars.nonce)
|
||||
if (vars.imagePaths && vars.imagePaths.length > 0) {
|
||||
const lines = vars.imagePaths.map(p => `- ${p}`).join('\n')
|
||||
out += `\n\n参考图片(请用 Read 工具查看):\n${lines}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getBrainstormContinuePrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string }): string {
|
||||
const tpl = getSettings(ctx).brainstormContinuePrompt
|
||||
// For youtrack cards the board number is synthetic; substitute the readable
|
||||
// id (e.g. LXF-12) so the cc agent references the real issue (readable via
|
||||
// `opencli yt issues show <id>`), not a non-existent gitea number.
|
||||
return tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
|
||||
}
|
||||
|
||||
export function getImplementPlanPrompt(ctx: ExtensionContext, vars: { planFile: string, issueNumber: number, issueRef?: string }): string {
|
||||
const tpl = getSettings(ctx).implementPlanPrompt
|
||||
return tpl
|
||||
.split('{planFile}')
|
||||
.join(vars.planFile)
|
||||
.split('{issueNumber}')
|
||||
.join(vars.issueRef ?? String(vars.issueNumber))
|
||||
}
|
||||
|
||||
export function getReviewPrompt(ctx: ExtensionContext, vars: { prNumber: string }): string {
|
||||
const tpl = getSettings(ctx).reviewPrompt
|
||||
return tpl.split('{prNumber}').join(vars.prNumber)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Spawns `codex exec review --json` headlessly and waits for it to finish.
|
||||
*
|
||||
* Output handling: codex itself is responsible for posting the review back
|
||||
* as a PR comment (instructed via the review prompt). This wrapper only
|
||||
* scans codex's NDJSON stdout for the very first `thread.started` event so
|
||||
* the caller can persist the `thread_id` (review session id) for manual
|
||||
* resume; everything after that is ignored. Failures are logged but never
|
||||
* thrown — the webhook coordinator treats review runs as fire-and-forget.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { logger } from '../logging/logger'
|
||||
|
||||
export interface RunReviewOpts {
|
||||
workspaceRoot: string
|
||||
/** Already-substituted prompt — placeholders must be resolved by the caller. */
|
||||
prompt: string
|
||||
/** Spawn timeout in milliseconds. Defaults to 5 minutes. */
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Called once with the codex `thread_id` parsed from the first
|
||||
* `thread.started` NDJSON event. Errors thrown inside this callback are
|
||||
* caught and logged — they never propagate out of `runReview`.
|
||||
*/
|
||||
onThreadId?: (id: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000
|
||||
|
||||
/**
|
||||
* Run `codex exec review --json` and wait for it to exit. Errors are
|
||||
* logged as warnings; this function never throws.
|
||||
*/
|
||||
export async function runReview(opts: RunReviewOpts): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const args: string[] = [
|
||||
'exec',
|
||||
'-c',
|
||||
'model_reasoning_effort=xhigh',
|
||||
'review',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--json',
|
||||
opts.prompt,
|
||||
]
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn('codex', args, {
|
||||
cwd: opts.workspaceRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'codex exec review 启动失败',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
// NDJSON parsing: accumulate stdout, split on newline, parse each line.
|
||||
// Stop parsing after the first `thread.started` event — let the rest
|
||||
// of stdout drain to /dev/null so codex can keep running.
|
||||
let buf = ''
|
||||
let threadIdHandled = false
|
||||
let stderrTail = ''
|
||||
|
||||
let diagLogged = 0
|
||||
const handleLine = (line: string): void => {
|
||||
if (threadIdHandled || !line.trim())
|
||||
return
|
||||
// Diagnostic: log the first 3 lines so we can inspect codex's actual
|
||||
// event shape if thread id detection misses.
|
||||
if (diagLogged < 3) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'webhook',
|
||||
message: `codex stdout[${diagLogged}]`,
|
||||
details: line.slice(0, 500),
|
||||
})
|
||||
diagLogged += 1
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object')
|
||||
return
|
||||
const obj = parsed as { type?: unknown, thread_id?: unknown, session_id?: unknown, id?: unknown }
|
||||
// Try multiple field names: thread_id (current), session_id (older
|
||||
// codex), id (fallback when the event is a "session created" shape).
|
||||
// Accept any event so long as it carries one of these as a string.
|
||||
const candidate
|
||||
= (typeof obj.thread_id === 'string' && obj.thread_id.length > 0 && obj.thread_id)
|
||||
|| (typeof obj.session_id === 'string' && obj.session_id.length > 0 && obj.session_id)
|
||||
|| (typeof obj.id === 'string' && obj.id.length > 0 && /^[0-9a-f-]{16,}$/i.test(obj.id) && obj.id)
|
||||
|| ''
|
||||
if (candidate) {
|
||||
threadIdHandled = true
|
||||
const id = candidate
|
||||
if (opts.onThreadId) {
|
||||
try {
|
||||
const ret = opts.onThreadId(id)
|
||||
if (ret && typeof (ret as Promise<unknown>).then === 'function') {
|
||||
;(ret as Promise<unknown>).catch((err) => {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'onThreadId 回调异常',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'onThreadId 回调抛错',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.setEncoding('utf-8')
|
||||
child.stdout?.on('data', (chunk: string) => {
|
||||
if (threadIdHandled)
|
||||
return
|
||||
buf += chunk
|
||||
let idx = buf.indexOf('\n')
|
||||
while (idx >= 0) {
|
||||
const line = buf.slice(0, idx)
|
||||
buf = buf.slice(idx + 1)
|
||||
handleLine(line)
|
||||
if (threadIdHandled) {
|
||||
buf = ''
|
||||
break
|
||||
}
|
||||
idx = buf.indexOf('\n')
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.setEncoding('utf-8')
|
||||
child.stderr?.on('data', (chunk: string) => {
|
||||
// Keep only the tail so we can include it in the failure message.
|
||||
stderrTail = (stderrTail + chunk).slice(-2000)
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'codex exec review 进程错误',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
if (timedOut) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: `codex exec review 超时 (${timeoutMs}ms)`,
|
||||
details: stderrTail.slice(-500),
|
||||
})
|
||||
}
|
||||
else if ((code !== null && code !== 0) || (signal && !timedOut)) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: `codex exec review 退出码异常 code=${code} signal=${signal ?? '<none>'}`,
|
||||
details: stderrTail.slice(-500),
|
||||
})
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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) {
|
||||
// eslint-disable-next-line no-console
|
||||
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) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[superpowers] sessionWatcher: watch error', err)
|
||||
done(null)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode with
|
||||
* `--output-format json`. We rely on `claude` being available on PATH;
|
||||
* callers should surface the install-hint error message to the user when
|
||||
* ENOENT is raised.
|
||||
*
|
||||
* Two execution paths share the same output-parsing logic:
|
||||
*
|
||||
* - **Text-only** (no images): plain `claude -p "<prompt>" --output-format json`
|
||||
* via `spawn` with stdin ignored so the CLI does not wait for piped input.
|
||||
*
|
||||
* - **With images**: switch to `claude -p --input-format stream-json
|
||||
* --output-format json` via `spawn`, then feed a single NDJSON line on
|
||||
* stdin containing the user message with text + image content blocks
|
||||
* (Anthropic Messages API shape). The official docs state single-message
|
||||
* input does NOT support image attachments — stream-json is the only
|
||||
* supported path. See:
|
||||
* https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000
|
||||
const MAX_BUFFER_BYTES = 10 * 1024 * 1024
|
||||
const EXIT_DIAGNOSTIC_MAX_CHARS = 2000
|
||||
|
||||
export interface ClaudeImage {
|
||||
/** e.g. "image/png", "image/jpeg", "image/webp", "image/gif" */
|
||||
mediaType: string
|
||||
/** Raw base64 (no `data:...;base64,` prefix) */
|
||||
base64: string
|
||||
}
|
||||
|
||||
export interface ClaudeResult {
|
||||
sessionId: string
|
||||
resultText: string
|
||||
rawJson: string
|
||||
}
|
||||
|
||||
export class ClaudeError extends Error {
|
||||
constructor(message: string, public readonly stderr: string) {
|
||||
super(message)
|
||||
this.name = 'ClaudeError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeTimeoutError extends ClaudeError {
|
||||
constructor(message: string, stderr: string) {
|
||||
super(message, stderr)
|
||||
this.name = 'ClaudeTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedClaudeJson {
|
||||
session_id: string
|
||||
result: string
|
||||
}
|
||||
|
||||
function tryParseClaudeJson(text: string): ParsedClaudeJson | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
parsed
|
||||
&& typeof parsed === 'object'
|
||||
&& 'result' in parsed
|
||||
&& 'session_id' in parsed
|
||||
) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
if (typeof obj.result === 'string' && typeof obj.session_id === 'string') {
|
||||
return { session_id: obj.session_id, result: obj.result }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractClaudePayload(stdout: string): ParsedClaudeJson | null {
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed)
|
||||
return null
|
||||
const direct = tryParseClaudeJson(trimmed)
|
||||
if (direct)
|
||||
return direct
|
||||
|
||||
// Fallback: streaming JSON (one object per line). Walk from the end to
|
||||
// find the last line that parses with the right shape.
|
||||
const lines = trimmed.split('\n')
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim()
|
||||
if (!line)
|
||||
continue
|
||||
const candidate = tryParseClaudeJson(line)
|
||||
if (candidate)
|
||||
return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
function truncateDiagnostic(text: string): string {
|
||||
if (text.length <= EXIT_DIAGNOSTIC_MAX_CHARS)
|
||||
return text
|
||||
return `${text.slice(0, EXIT_DIAGNOSTIC_MAX_CHARS)}…`
|
||||
}
|
||||
|
||||
function formatDiagnosticValue(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean')
|
||||
return String(value)
|
||||
if (value && typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractJsonDiagnostic(text: string): string | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object')
|
||||
return null
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
for (const key of ['error', 'message', 'result', 'summary']) {
|
||||
const diagnostic = formatDiagnosticValue(obj[key])
|
||||
if (diagnostic)
|
||||
return truncateDiagnostic(diagnostic)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractStdoutDiagnostic(stdout: string): string | null {
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed)
|
||||
return null
|
||||
|
||||
const direct = extractJsonDiagnostic(trimmed)
|
||||
if (direct)
|
||||
return direct
|
||||
|
||||
const lines = trimmed.split('\n')
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim()
|
||||
if (!line)
|
||||
continue
|
||||
const diagnostic = extractJsonDiagnostic(line)
|
||||
if (diagnostic)
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
return truncateDiagnostic(trimmed)
|
||||
}
|
||||
|
||||
function buildClaudeExitDiagnostic(stderr: string, stdout: string): string {
|
||||
const stderrDiagnostic = stderr.trim()
|
||||
if (stderrDiagnostic)
|
||||
return truncateDiagnostic(stderrDiagnostic)
|
||||
|
||||
return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)'
|
||||
}
|
||||
|
||||
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: 'text', text: prompt },
|
||||
]
|
||||
for (const img of images) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: img.mediaType,
|
||||
data: img.base64,
|
||||
},
|
||||
})
|
||||
}
|
||||
const message = {
|
||||
type: 'user',
|
||||
message: { role: 'user', content },
|
||||
parent_tool_use_id: null,
|
||||
}
|
||||
return `${JSON.stringify(message)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造后台 claude 子进程的环境变量:从父进程 env 剔除会导致 headless 鉴权
|
||||
* 失败或嵌套会话静默退出的变量,让子进程回落到订阅 OAuth 凭据。
|
||||
*
|
||||
* - ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN:headless `-p` 无条件优先用它们,
|
||||
* 若属于受限/过期 org 会 403 Request not allowed。剔除后回落 ~/.claude 订阅登录。
|
||||
* - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION:
|
||||
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
|
||||
*/
|
||||
function buildClaudeChildEnv(): NodeJS.ProcessEnv {
|
||||
// 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源
|
||||
// (profile 没配则回落 ~/.claude 订阅 OAuth),避免宿主 env 残留的 BASE_URL/TOKEN
|
||||
// 覆盖 profile 导致 403。同时清除嵌套 Claude 会话标记,避免后台 `claude -p` 误判处于嵌套会话。
|
||||
const NESTED_GUARDS = new Set([
|
||||
'CLAUDECODE',
|
||||
'CLAUDE_CODE_ENTRYPOINT',
|
||||
'CLAUDE_CODE_SESSION_ID',
|
||||
'CLAUDE_CODE_SESSION',
|
||||
])
|
||||
const out: NodeJS.ProcessEnv = {}
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (k.startsWith('ANTHROPIC_'))
|
||||
continue
|
||||
if (NESTED_GUARDS.has(k))
|
||||
continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function spawnClaude(opts: {
|
||||
prompt: string
|
||||
cwd: string
|
||||
timeoutMs?: number
|
||||
images?: ClaudeImage[]
|
||||
profilePath?: string
|
||||
bare?: boolean
|
||||
}): Promise<ClaudeResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const hasImages = !!opts.images && opts.images.length > 0
|
||||
|
||||
if (!hasImages) {
|
||||
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare)
|
||||
}
|
||||
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare)
|
||||
}
|
||||
|
||||
function spawnClaudeText(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
profilePath?: string,
|
||||
bare?: boolean,
|
||||
): Promise<ClaudeResult> {
|
||||
const args = [
|
||||
...(bare ? ['--bare'] : []),
|
||||
'--dangerously-skip-permissions',
|
||||
...(profilePath ? ['--settings', profilePath] : []),
|
||||
'-p',
|
||||
prompt,
|
||||
'--output-format',
|
||||
'json',
|
||||
]
|
||||
|
||||
return new Promise<ClaudeResult>((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
let settled = false
|
||||
|
||||
const child = spawn('claude', args, {
|
||||
cwd,
|
||||
env: buildClaudeChildEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
child.kill('SIGKILL')
|
||||
reject(new ClaudeTimeoutError(
|
||||
`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`,
|
||||
stderr,
|
||||
))
|
||||
}, timeoutMs)
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
const errCode = (err as NodeJS.ErrnoException).code
|
||||
if (errCode === 'ENOENT') {
|
||||
reject(new ClaudeError(
|
||||
'未检测到 claude CLI,请确认已安装并在 PATH 中',
|
||||
stderr,
|
||||
))
|
||||
return
|
||||
}
|
||||
reject(new ClaudeError(
|
||||
`Claude 调用失败: ${err.message}`,
|
||||
stderr,
|
||||
))
|
||||
})
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length
|
||||
if (stdoutBytes > MAX_BUFFER_BYTES) {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.kill('SIGKILL')
|
||||
reject(new ClaudeError(
|
||||
`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`,
|
||||
stderr,
|
||||
))
|
||||
return
|
||||
}
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) {
|
||||
reject(new ClaudeError(
|
||||
`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`,
|
||||
stderr,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = extractClaudePayload(stdout)
|
||||
if (!payload) {
|
||||
reject(new ClaudeError(
|
||||
'Claude 返回异常: 无法解析 JSON 输出',
|
||||
stdout,
|
||||
))
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
sessionId: payload.session_id,
|
||||
resultText: payload.result,
|
||||
rawJson: stdout,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function spawnClaudeStreamed(
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
images: ClaudeImage[],
|
||||
profilePath?: string,
|
||||
bare?: boolean,
|
||||
): Promise<ClaudeResult> {
|
||||
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must
|
||||
// be paired with --output-format=stream-json (the friendlier `--output-format
|
||||
// json` is rejected). The output is NDJSON; extractClaudePayload walks lines
|
||||
// from the end to find the final result-shaped object.
|
||||
const args = [
|
||||
...(bare ? ['--bare'] : []),
|
||||
'--dangerously-skip-permissions',
|
||||
...(profilePath ? ['--settings', profilePath] : []),
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
]
|
||||
const ndjson = buildStreamJsonLine(prompt, images)
|
||||
|
||||
return new Promise<ClaudeResult>((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
let settled = false
|
||||
|
||||
const child = spawn('claude', args, {
|
||||
cwd,
|
||||
env: buildClaudeChildEnv(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
child.kill('SIGKILL')
|
||||
reject(new ClaudeTimeoutError(
|
||||
`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`,
|
||||
stderr,
|
||||
))
|
||||
}, timeoutMs)
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
const errCode = (err as NodeJS.ErrnoException).code
|
||||
if (errCode === 'ENOENT') {
|
||||
reject(new ClaudeError(
|
||||
'未检测到 claude CLI,请确认已安装并在 PATH 中',
|
||||
stderr,
|
||||
))
|
||||
return
|
||||
}
|
||||
reject(new ClaudeError(
|
||||
`Claude 调用失败: ${err.message}`,
|
||||
stderr,
|
||||
))
|
||||
})
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length
|
||||
if (stdoutBytes > MAX_BUFFER_BYTES) {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.kill('SIGKILL')
|
||||
reject(new ClaudeError(
|
||||
`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`,
|
||||
stderr,
|
||||
))
|
||||
return
|
||||
}
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (code !== 0) {
|
||||
reject(new ClaudeError(
|
||||
`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`,
|
||||
stderr,
|
||||
))
|
||||
return
|
||||
}
|
||||
const payload = extractClaudePayload(stdout)
|
||||
if (!payload) {
|
||||
reject(new ClaudeError(
|
||||
'Claude 返回异常: 无法解析 JSON 输出',
|
||||
stdout,
|
||||
))
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
sessionId: payload.session_id,
|
||||
resultText: payload.result,
|
||||
rawJson: stdout,
|
||||
})
|
||||
})
|
||||
|
||||
// Send the single NDJSON line and close stdin so claude knows the user
|
||||
// turn is complete. If the child has already exited (e.g. arg validation
|
||||
// failed), stdin will EPIPE — swallow it; the 'close' handler will surface
|
||||
// the real error from stderr + exit code.
|
||||
child.stdin.on('error', () => { /* ignore EPIPE / ECONNRESET */ })
|
||||
try {
|
||||
child.stdin.write(ndjson)
|
||||
child.stdin.end()
|
||||
}
|
||||
catch {
|
||||
// child already gone; close handler will run with the error code.
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user