← back to Kickbacks Ai Upstream
src/locate.ts
176 lines
import { homedir } from "node:os";
import { join } from "node:path";
import { readdirSync, existsSync, statSync, openSync, readSync,
closeSync } from "node:fs";
import { compareClaudeCodeInstall } from "./util/claudeCodeVersion";
// readdir-based glob for `<root>/anthropic.claude-code-*/webview/index.js`.
// Swapped from `node:fs`'s `globSync` (the installed @types/node@20 lacks the
// declaration though Node 22 has it at runtime). Returns absolute paths; never throws.
export function globClaudeCode(root: string): string[] {
try {
if (!existsSync(root)) return [];
const out: string[] = [];
for (const name of readdirSync(root)) {
if (!name.startsWith("anthropic.claude-code-")) continue;
const idx = join(root, name, "webview", "index.js");
if (existsSync(idx)) out.push(idx);
}
return out;
} catch { return []; }
}
export function locateClaudeCode(): string | null {
// Explicit escape hatch for S5 matrix / manual smoke / portable installs.
const explicit = process.env.KICKBACKS_CC_TARGET
|| process.env.VIBE_ADS_CC_TARGET;
if (explicit && existsSync(explicit)) return explicit;
// Covers local (.vscode/.vscode-insiders/.cursor) AND remote/server hosts
// (Remote-SSH, dev containers, vscode.dev) where extensions live under
// *-server/. Keep in sync with ROOTS in adapters/registry.ts.
for (const root of [join(homedir(), ".vscode", "extensions"),
join(homedir(), ".vscode-insiders", "extensions"),
join(homedir(), ".vscode-server", "extensions"),
join(homedir(), ".vscode-server-insiders", "extensions"),
join(homedir(), ".cursor", "extensions"),
join(homedir(), ".cursor-server", "extensions")]) {
try {
const hits = globClaudeCode(root).sort(compareClaudeCodeInstall);
if (hits.length) return hits[hits.length - 1];
} catch { /* ignore */ }
}
return null;
}
/** Best-effort `entrypoint` tag of a session transcript: "claude-vscode" for
* the interactive VS Code panel, "cli" for terminal sessions. Reads the head
* of the file and returns the first record's top-level `entrypoint` string,
* or null when untagged/unreadable (older CC builds; queue-operation lines
* carry no tag, so we scan past them). Line-parsed JSON — a transcript whose
* message TEXT merely quotes `"entrypoint":"cli"` cannot spoof the tag.
* Never throws. */
export function transcriptEntrypoint(path: string): string | null {
const hit = entrypointCache.get(path);
if (hit !== undefined) return hit;
const tag = readTranscriptEntrypoint(path);
if (tag !== null) {
if (entrypointCache.size >= ENTRYPOINT_CACHE_MAX) {
const oldest = entrypointCache.keys().next().value as string;
entrypointCache.delete(oldest);
}
entrypointCache.set(path, tag);
}
return tag;
}
// Per-path tag memo: a transcript's entrypoint tag is written once at session
// start and never changes, so a TAGGED result is cacheable for the process
// lifetime — this keeps the repeated locate/cliSessionActive probes (statusbar
// poll, 60s cliSync tick) from re-reading the same heads. Untagged (null)
// results are NOT cached: a brand-new session's head may not be flushed yet.
const entrypointCache = new Map<string, string>();
const ENTRYPOINT_CACHE_MAX = 256;
function readTranscriptEntrypoint(path: string): string | null {
try {
const fd = openSync(path, "r");
let text: string;
try {
const buf = Buffer.alloc(16 * 1024);
const n = readSync(fd, buf, 0, buf.length, 0);
text = buf.toString("utf8", 0, n);
} finally { closeSync(fd); }
for (const ln of text.split("\n")) {
if (!ln) continue;
let o: Record<string, unknown>;
try { o = JSON.parse(ln) as Record<string, unknown>; }
catch { continue; } // sliced tail line / junk
if (typeof o.entrypoint === "string") return o.entrypoint;
}
return null;
} catch { return null; }
}
// Mtime-sorted (newest first) scan of every transcript under
// ~/.claude/projects — shared by the per-surface resolvers below.
function scanTranscripts(): { p: string; m: number }[] {
const root = join(homedir(), ".claude", "projects");
if (!existsSync(root)) return [];
const cands: { p: string; m: number }[] = [];
for (const proj of readdirSync(root)) {
let entries: string[];
try { entries = readdirSync(join(root, proj)); } catch { continue; }
for (const f of entries) {
if (!f.endsWith(".jsonl")) continue;
const p = join(root, proj, f);
try { cands.push({ p, m: statSync(p).mtimeMs }); } catch { /* ignore */ }
}
}
cands.sort((a, b) => b.m - a.m);
return cands;
}
// Discover Claude Code's live JSONL transcript. CRITICAL: multiple Claude
// sessions can share a cwd (e.g. an interactive VS Code session AND a CLI/
// agent session). "Newest jsonl" alone tails whichever moved last — often the
// wrong one, so `done`/tool track a different session. We therefore prefer the
// newest transcript whose `entrypoint` is "claude-vscode" (the interactive VS
// Code instance whose webview we patch); fall back to newest-overall only if
// none are tagged (older CC builds). VIBE_ADS_CC_LOG overrides. "" => LogTail
// yields null => block self-simulates.
export function locateClaudeCodeLog(): string {
const explicit = process.env.KICKBACKS_CC_LOG
|| process.env.VIBE_ADS_CC_LOG;
if (explicit && existsSync(explicit)) return explicit;
try {
const cands = scanTranscripts();
if (!cands.length) return "";
// Documented filter (audit #24): newest transcript positively tagged
// entrypoint:"claude-vscode" wins; fall back to the newest UNTAGGED one
// (older CC builds); when every probed candidate is tagged as another
// surface (e.g. "cli") return "" — no signal beats a wrong signal (a
// terminal session would feed the desync watchdog a false "CC active").
// Probes are bounded to the newest few: anything older isn't live.
// NOTE: when multiple VS Code sessions share a cwd they're all tagged
// "claude-vscode" with NO filesystem signal identifying which belongs to
// this window — newest-mtime stays the best available proxy among them.
let newestUntagged = "";
for (const c of cands.slice(0, 20)) {
const tag = transcriptEntrypoint(c.p);
if (tag === "claude-vscode") return c.p;
if (tag === null && !newestUntagged) newestUntagged = c.p;
}
return newestUntagged;
} catch { return ""; }
}
// The TERMINAL-side mirror of locateClaudeCodeLog: newest transcript
// positively tagged entrypoint:"cli" (a `claude` TUI session); falls back to
// the newest UNTAGGED transcript when no "cli" tag is found (older CC builds
// write no tag — pre-fix this resolver refused them, so a TUI user on an
// untagged build saw statusline/spinner ads all day and NEVER earned: the
// 2026-06-11 launch-day cohort, 26 users at $0. The impression counter
// (cliSessionActive) was already fail-open for untagged, so the books showed
// impressions-without-ticks — exactly the Steven signature). Positively
// OTHER-tagged transcripts (sdk-*, desktop, claude-vscode…) are still never
// claimed: those sessions render no statusline, so ticking them would bill a
// surface nobody sees. Untagged MAY double-claim with locateClaudeCodeLog's
// own untagged fallback; that is financially safe — billable view events
// share the server-side per-(user,ad) cooldown bucket across surfaces, so a
// cross-surface double-tick is written as a zero-debit audit row.
// Consumers: the statusline view-tick loop (cliTick).
export function locateClaudeCliLog(): string {
const explicit = process.env.KICKBACKS_CLI_LOG;
if (explicit && existsSync(explicit)) return explicit;
try {
const cands = scanTranscripts();
let newestUntagged = "";
for (const c of cands.slice(0, 20)) {
const tag = transcriptEntrypoint(c.p);
if (tag === "cli") return c.p;
if (tag === null && !newestUntagged) newestUntagged = c.p;
}
return newestUntagged;
} catch { return ""; }
}