import { spawn, type ChildProcess } from "node:child_process"; export type CccJobKind = "init" | "index"; export type CccJobStatus = "running" | "done" | "error" | "aborted"; export interface CccBackgroundJob { id: string; kind: CccJobKind; status: CccJobStatus; startedAt: number; endedAt?: number; abortController: AbortController; child?: ChildProcess; /** Short success summary, e.g. "indexed 1234 files in 38s" or "initialized". */ summary?: string; /** First non-empty line of stderr/stdout when the job failed. */ errorFirstLine?: string; } interface CccBackgroundStore { schemaVersion: number; jobs: Map; nextId: number; } // globalThis-backed store, mirrors vera-prompt-inspector/src/registry.ts. // Pi extension loader materialises each package as its own ESM realm; two // packages importing this file then see distinct module instances. The // store on globalThis collapses them to a single shared slot. const GLOBAL_KEY = "__veraCccBackgroundStore__"; const SCHEMA_VERSION = 1; export function getBackgroundStore(): CccBackgroundStore { const g = globalThis as { [key: string]: unknown }; const existing = g[GLOBAL_KEY] as CccBackgroundStore | undefined; if (existing && existing.schemaVersion === SCHEMA_VERSION) return existing; const fresh: CccBackgroundStore = { schemaVersion: SCHEMA_VERSION, jobs: new Map(), nextId: 1, }; g[GLOBAL_KEY] = fresh; return fresh; } function createJobId(store: CccBackgroundStore, kind: CccJobKind): string { const seq = String(store.nextId).padStart(3, "0"); store.nextId += 1; return `ccc_${kind}_${Date.now()}_${seq}`; } function findRunningOfKind(store: CccBackgroundStore, kind: CccJobKind): CccBackgroundJob | null { for (const job of store.jobs.values()) { if (job.kind === kind && job.status === "running") return job; } return null; } function compactFinishedJobs(store: CccBackgroundStore, kind: CccJobKind): void { const finished: CccBackgroundJob[] = []; for (const job of store.jobs.values()) { if (job.kind === kind && job.status !== "running") finished.push(job); } finished.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)); for (const old of finished.slice(1)) { store.jobs.delete(old.id); } } function stripAnsi(text: string): string { return text.replace(/\u001b\[[0-9;]*m/g, ""); } function firstNonEmptyLine(text: string): string { for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (line) return line; } return ""; } function summarizeOutput(kind: CccJobKind, stdout: string, elapsedMs: number): string { const text = stripAnsi(stdout); if (kind === "index") { const match = text.match(/indexed\s+(\d+)\s+files?/i) ?? text.match(/(\d+)\s+files?\b/i); const elapsed = Math.round(elapsedMs / 1000); if (match) return `indexed ${match[1]} files in ${elapsed}s`; return `completed in ${elapsed}s`; } return firstNonEmptyLine(text) || "initialized"; } export interface StartJobOptions { cwd: string; kind: CccJobKind; args: string[]; /** Used for ui.notify on error. May be omitted in non-extension contexts. */ notify?: (message: string, level: "info" | "warning" | "error") => void; } export interface StartJobResult { job: CccBackgroundJob; /** True when an existing same-kind running job was returned without spawning a new child. */ reused: boolean; } export function startJob(opts: StartJobOptions): StartJobResult { const store = getBackgroundStore(); const existing = findRunningOfKind(store, opts.kind); if (existing) return { job: existing, reused: true }; const id = createJobId(store, opts.kind); const startedAt = Date.now(); const abortController = new AbortController(); const job: CccBackgroundJob = { id, kind: opts.kind, status: "running", startedAt, abortController, }; store.jobs.set(id, job); void (async () => { try { const child = spawn("ccc", opts.args, { cwd: opts.cwd, env: { ...process.env, NO_COLOR: "1", TERM: "dumb", PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" }, // On Windows `ccc` is a `.cmd` shim and node refuses to spawn it // directly without shell:true (CVE-2024-27980). The args are static // ("index" / "init [--force]") so shell expansion is safe. shell: process.platform === "win32", }); job.child = child; const STDOUT_CAP = 64 * 1024; let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk: Buffer | string) => { if (stdout.length < STDOUT_CAP) stdout += String(chunk); }); child.stderr?.on("data", (chunk: Buffer | string) => { if (stderr.length < STDOUT_CAP) stderr += String(chunk); }); const onAbort = (): void => { try { child.kill("SIGTERM"); } catch { /* ignore */ } }; abortController.signal.addEventListener("abort", onAbort); const exitCode: number = await new Promise((resolve) => { let resolved = false; const done = (code: number): void => { if (resolved) return; resolved = true; resolve(code); }; child.on("close", (code) => done(code ?? -1)); child.on("error", () => done(-1)); }); abortController.signal.removeEventListener("abort", onAbort); job.endedAt = Date.now(); const elapsedMs = job.endedAt - startedAt; if (abortController.signal.aborted) { job.status = "aborted"; job.errorFirstLine = "aborted"; } else if (exitCode === 0) { job.status = "done"; job.summary = summarizeOutput(opts.kind, stdout, elapsedMs); } else { job.status = "error"; const stderrClean = stripAnsi(stderr); const stdoutClean = stripAnsi(stdout); job.errorFirstLine = firstNonEmptyLine(stderrClean) || firstNonEmptyLine(stdoutClean) || `ccc ${opts.kind} exit ${exitCode}`; if (opts.notify) { try { opts.notify(`ccc_${opts.kind} error: ${job.errorFirstLine}`, "warning"); } catch { /* ignore */ } } } } catch (err: any) { job.endedAt = Date.now(); job.status = "error"; job.errorFirstLine = err?.message ?? String(err); if (opts.notify) { try { opts.notify(`ccc_${opts.kind} failed: ${job.errorFirstLine}`, "warning"); } catch { /* ignore */ } } } finally { compactFinishedJobs(store, opts.kind); } })(); return { job, reused: false }; } export function abortAllJobs(reason = "session shutdown"): void { const store = getBackgroundStore(); for (const job of store.jobs.values()) { if (job.status !== "running") continue; job.abortController.abort(); if (job.status === "running") { job.status = "aborted"; job.endedAt = Date.now(); job.errorFirstLine = reason; } } }