import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { statSync } from "node:fs"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { registerPromptSegment } from "../../vera-prompt-inspector/src/registry"; import { abortAllJobs, getBackgroundStore, type CccBackgroundJob } from "./background"; const execFileAsync = promisify(execFile); const STATUS_TIMEOUT_MS = 5_000; const STATUS_MAX_BUFFER = 1 * 1024 * 1024; type IndexActivity = { filesListed: number; added: number; deleted: number; reprocessed: number; unchanged: number; error: number; }; type IndexStats = { chunks: number; files: number; languages: Array<{ lang: string; chunks: number }>; }; export type StatusState = | { kind: "unavailable"; reason: string } | { kind: "uninitialized" } | { kind: "error"; errorFirstLine: string } | { kind: "indexing"; project: string; activity: IndexActivity; stats: IndexStats; indexDbPath?: string } | { kind: "empty"; project: string; indexDbPath?: string } | { kind: "healthy"; project: string; stats: IndexStats; indexDbPath?: string }; interface SpawnResult { stdout: string; stderr: string; exitCode: number; missing?: boolean; } 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 relativeTime(date: Date): string { const ms = Date.now() - date.getTime(); if (ms < 0) return "just now"; const min = Math.round(ms / 60_000); if (min < 1) return "just now"; if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`; const hr = Math.round(min / 60); if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`; const day = Math.round(hr / 24); return `${day} day${day === 1 ? "" : "s"} ago`; } function readMtime(path?: string): Date | null { if (!path) return null; try { return statSync(path).mtime; } catch { return null; } } async function spawnStatus(cwd: string): Promise { try { const result = await execFileAsync("ccc", ["status"], { cwd, timeout: STATUS_TIMEOUT_MS, maxBuffer: STATUS_MAX_BUFFER, env: { ...process.env, NO_COLOR: "1", TERM: "dumb", PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" }, }); return { stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? ""), exitCode: 0, }; } catch (err: any) { if (err?.code === "ENOENT") { return { stdout: "", stderr: "", exitCode: -1, missing: true }; } const code = typeof err?.code === "number" ? err.code : -1; return { stdout: String(err?.stdout ?? ""), stderr: String(err?.stderr ?? err?.message ?? ""), exitCode: code, }; } } interface ParsedStatus { project?: string; indexDb?: string; activity?: IndexActivity; stats?: IndexStats; } export function parseStatusOutput(stdout: string): ParsedStatus { const text = stripAnsi(stdout); const project = text.match(/^Project:\s+(.+?)\s*$/m)?.[1]; const indexDb = text.match(/^Index DB:\s+(.+?)\s*$/m)?.[1]; const actMatch = text.match( /^Indexing in progress:\s+(\d+) files listed \| (\d+) added, (\d+) deleted, (\d+) reprocessed, (\d+) unchanged, error: (\d+)\s*$/m, ); const activity: IndexActivity | undefined = actMatch ? { filesListed: Number(actMatch[1]), added: Number(actMatch[2]), deleted: Number(actMatch[3]), reprocessed: Number(actMatch[4]), unchanged: Number(actMatch[5]), error: Number(actMatch[6]), } : undefined; const chunksMatch = text.match(/^\s+Chunks:\s+(\d+)\s*$/m); const filesMatch = text.match(/^\s+Files:\s+(\d+)\s*$/m); let stats: IndexStats | undefined; if (chunksMatch && filesMatch) { const languages: Array<{ lang: string; chunks: number }> = []; const lines = text.split(/\r?\n/); let inLang = false; for (const line of lines) { if (/^\s+Languages:\s*$/.test(line)) { inLang = true; continue; } if (inLang) { const m = line.match(/^\s{4,}(\S+):\s+(\d+) chunks\s*$/); if (m) { languages.push({ lang: m[1], chunks: Number(m[2]) }); } else if (line.trim().length > 0) { inLang = false; } } } stats = { chunks: Number(chunksMatch[1]), files: Number(filesMatch[1]), languages, }; } return { project, indexDb, activity, stats }; } export function deriveState(spawn: SpawnResult): StatusState { if (spawn.missing) { return { kind: "unavailable", reason: "ccc CLI not on PATH" }; } const stdoutText = stripAnsi(spawn.stdout); const stderrText = stripAnsi(spawn.stderr); const combined = `${stdoutText}\n${stderrText}`; if (/Not in an initialized project directory/i.test(combined)) { return { kind: "uninitialized" }; } if (spawn.exitCode !== 0) { const firstLine = firstNonEmptyLine(stderrText) || firstNonEmptyLine(stdoutText) || "ccc status failed"; return { kind: "error", errorFirstLine: firstLine }; } const parsed = parseStatusOutput(spawn.stdout); if (!parsed.stats || !parsed.project) { return { kind: "error", errorFirstLine: "ccc status returned no parseable stats" }; } const project = parsed.project; const stats = parsed.stats; const indexDbPath = parsed.indexDb; if (parsed.activity && parsed.activity.filesListed > 0) { return { kind: "indexing", project, stats, activity: parsed.activity, indexDbPath }; } if (stats.files === 0) { return { kind: "empty", project, indexDbPath }; } return { kind: "healthy", project, stats, indexDbPath }; } interface SegmentDetail { label: string; value: string; } interface SegmentBuild { text: string; details: SegmentDetail[]; } export function buildStatusSegment(state: StatusState, cwd: string): SegmentBuild { const lines: string[] = ["## CCC Index Status", ""]; const details: SegmentDetail[] = [{ label: "state", value: state.kind }]; switch (state.kind) { case "unavailable": lines.push("State: unavailable"); lines.push(`Reason: ${state.reason}.`); lines.push("Install: https://github.com/cocoindex-io/cocoindex-code"); break; case "uninitialized": lines.push("State: uninitialized"); lines.push(`Project: ${cwd}`); lines.push("Action: run `ccc_init` before any `ccc_search`."); break; case "error": lines.push("State: error"); lines.push(`Diagnostic: ${state.errorFirstLine}`); lines.push("Action: run `ccc_doctor` to diagnose."); details.push({ label: "error", value: state.errorFirstLine }); break; case "indexing": { lines.push("State: indexing"); lines.push(`Project: ${state.project}`); const a = state.activity; lines.push( `Activity: ${a.filesListed} files listed | ${a.added} added, ${a.deleted} deleted, ${a.reprocessed} reprocessed, ${a.unchanged} unchanged, error: ${a.error}`, ); lines.push(`Index (in flight): ${state.stats.files} files, ${state.stats.chunks} chunks`); if (state.stats.languages.length > 0) { const top = state.stats.languages .slice(0, 5) .map((l) => `${l.lang} ${l.chunks}`) .join(", "); lines.push(`Languages: ${top}`); } const mtime = readMtime(state.indexDbPath); if (mtime) lines.push(`Last index activity: ${relativeTime(mtime)}`); details.push({ label: "files", value: String(state.stats.files) }); details.push({ label: "activity", value: `${a.filesListed} files listed` }); break; } case "empty": lines.push("State: empty (initialized but never indexed)"); lines.push(`Project: ${state.project}`); lines.push("Action: run `ccc_index` to do the first build."); break; case "healthy": { lines.push("State: healthy"); lines.push(`Project: ${state.project}`); lines.push(`Index: ${state.stats.files} files, ${state.stats.chunks} chunks`); if (state.stats.languages.length > 0) { const top = state.stats.languages .slice(0, 5) .map((l) => `${l.lang} ${l.chunks}`) .join(", "); lines.push(`Languages: ${top}`); } const mtime = readMtime(state.indexDbPath); if (mtime) lines.push(`Last index activity: ${relativeTime(mtime)}`); details.push({ label: "files", value: String(state.stats.files) }); details.push({ label: "chunks", value: String(state.stats.chunks) }); break; } } // Background jobs surfaced by Phase 3. const store = getBackgroundStore(); const running: CccBackgroundJob[] = []; const recent: CccBackgroundJob[] = []; for (const job of store.jobs.values()) { if (job.status === "running") running.push(job); else recent.push(job); } running.sort((a, b) => a.startedAt - b.startedAt); recent.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)); if (running.length > 0) { lines.push(""); lines.push("Pending background jobs:"); for (const job of running) { const elapsed = Math.max(0, Math.round((Date.now() - job.startedAt) / 1000)); lines.push(` - ccc_${job.kind} (job ${job.id}, running ${elapsed}s)`); } details.push({ label: "pending jobs", value: String(running.length) }); } if (recent.length > 0) { lines.push(""); lines.push("Recent background jobs:"); for (const job of recent) { const when = job.endedAt ? new Date(job.endedAt).toISOString().slice(11, 19) + "Z" : "?"; let body: string; if (job.status === "done") { body = job.summary ?? "completed"; } else { body = `${job.status}: ${job.errorFirstLine ?? "no detail"}`; } lines.push(` - ccc_${job.kind} (job ${job.id}): ${body} at ${when}`); } } return { text: lines.join("\n"), details }; } export function registerStatusSegment(pi: ExtensionAPI): void { let probeNotified = false; pi.on("session_start", async (_event, ctx) => { const result = await spawnStatus(ctx.cwd); const state = deriveState(result); if (probeNotified) return; if (state.kind === "unavailable") { ctx.ui.notify("ccc CLI not on PATH; semantic search disabled", "warning"); probeNotified = true; } else if (state.kind === "error") { ctx.ui.notify(`ccc status error: ${state.errorFirstLine}`, "warning"); probeNotified = true; } }); pi.on("before_agent_start", async (_event, ctx) => { const result = await spawnStatus(ctx.cwd); const state = deriveState(result); const seg = buildStatusSegment(state, ctx.cwd); registerPromptSegment({ id: "ccc-status", label: "ccc index status", category: "context", kind: "per-turn", text: seg.text, details: seg.details, }); }); pi.on("session_end", async () => { abortAllJobs("session shutdown"); }); }