← back to Kickbacks Ai Upstream
src/activity/logTail.ts
154 lines
import { existsSync, statSync, readSync, openSync, closeSync } from "node:fs";
export interface Activity {
tool: string;
elapsedMs: number;
ts: number;
/** True when the latest assistant turn has finished (stop_reason set and
* not "tool_use") — drives the in-slot completion freeze. */
done: boolean;
}
/** Best-effort, read-only tail of Claude Code's live JSONL session transcript
* (`~/.claude/projects/<sanitized-cwd>/<session>.jsonl`). Each line is a JSON
* record; assistant lines carry `message.content[]` (with `tool_use` blocks),
* a `timestamp`, and `message.stop_reason`. Any error/miss yields null so the
* injected block self-simulates. NEVER throws. The format is version-fragile
* and deliberately non-load-bearing (spec §4.5) — the S5 matrix flags drift. */
const IDLE_STALE_MS = 90_000;
/** Min gap between resolver re-runs while the pinned file sits idle-stale, so
* a simply-idle user doesn't re-glob the transcript tree on every poll. */
const RERESOLVE_MIN_MS = 15_000;
export class LogTail {
private firstSeen = Date.now();
private lastTool = "";
private path: string;
private lastReresolveAt = 0;
private readonly resolvePath?: () => string;
constructor(pathOrResolver: string | (() => string)) {
if (typeof pathOrResolver === "function") {
this.path = "";
this.resolvePath = pathOrResolver;
} else {
this.path = pathOrResolver;
}
}
private currentPath(): string {
try {
if (this.path && existsSync(this.path)) {
if (!this.resolvePath) return this.path;
// Transcripts are never deleted: a NEW chat session writes a NEW
// .jsonl while the pinned one just goes quiet. Once the pinned file
// is idle-stale, re-resolve (throttled) and adopt any newer
// transcript — otherwise the pin is forever (audit #2: dead statusbar
// ad, overlay txnIdle suppression, blind desync watchdog).
const age = Date.now() - statSync(this.path).mtimeMs;
if (age <= IDLE_STALE_MS) return this.path;
if (Date.now() - this.lastReresolveAt < RERESOLVE_MIN_MS) {
return this.path;
}
this.lastReresolveAt = Date.now();
}
const next = this.resolvePath?.() || "";
if (next && next !== this.path) {
this.path = next;
this.lastTool = "";
this.firstSeen = Date.now();
}
return this.path;
} catch { return this.path; }
}
/** Age in ms since Claude Code last wrote its session transcript, or null
* when there's no readable transcript. An INDEPENDENT activity signal: it
* reflects real CC usage (the user firing sessions), NOT our injected
* overlay. The desync watchdog uses it to tell "user is using CC but our
* ads aren't rendering" (heal) from "user is simply idle" (leave alone).
* Cheaper than current() (a single stat, no read). Never throws. */
activityAgeMs(): number | null {
try {
const path = this.currentPath();
if (!path || !existsSync(path)) return null;
// Clamp: a just-written file's mtime can be a hair ahead of Date.now()
// (fs timestamp precision / clock skew), which would otherwise yield a
// nonsensical negative age.
return Math.max(0, Date.now() - statSync(path).mtimeMs);
} catch { return null; }
}
current(): Activity | null {
try {
const path = this.currentPath();
if (!path || !existsSync(path)) return null;
const st = statSync(path);
const size = st.size;
const staleMs = Date.now() - st.mtimeMs;
const want = Math.min(size, 128 * 1024);
if (want === 0) return null;
const fd = openSync(path, "r");
let text: string;
try {
const buf = Buffer.alloc(want);
readSync(fd, buf, 0, want, size - want);
text = buf.toString("utf8");
} finally { closeSync(fd); }
// A *partial* (mid-file) read may slice the first line — drop it.
// A full read (want === size, offset 0) has no partial line: keep all.
const lines = text.split("\n");
if (want < size) lines.shift();
let tool = "";
let done: boolean | null = null; // null until we see an assistant line
let pendingUserAfterAssistant = false;
// Walk newest → oldest: first assistant line decides `done`; first
// tool_use block is the current/most-recent tool.
for (let i = lines.length - 1; i >= 0; i--) {
const ln = lines[i];
if (!ln) continue;
let o: Record<string, unknown>;
try { o = JSON.parse(ln); } catch { continue; }
const msg = o.message as Record<string, unknown> | undefined;
if (done === null && o.type === "user") {
pendingUserAfterAssistant = true;
continue;
}
if (o.type === "assistant" && msg) {
if (done === null) {
if (pendingUserAfterAssistant) {
// A fresh user prompt after the latest assistant line means the
// next assistant response has not landed yet. Treat it as active
// instead of inheriting the prior completed turn.
done = false;
} else {
const sr = msg.stop_reason as string | null | undefined;
// Set + not "tool_use" => the turn ended (end_turn/stop_sequence).
done = !!sr && sr !== "tool_use";
}
}
if (!tool && Array.isArray(msg.content)) {
for (const b of msg.content as Array<Record<string, unknown>>) {
if (b && b.type === "tool_use" && typeof b.name === "string") {
tool = b.name; break;
}
}
}
}
if (tool && done !== null) break;
}
if (done === null && pendingUserAfterAssistant) done = false;
if (!tool && done === null) return null; // nothing usable in the tail
if (tool && tool !== this.lastTool) {
this.lastTool = tool; this.firstSeen = Date.now();
}
const isDone = done === true || staleMs > IDLE_STALE_MS;
return { tool, elapsedMs: Date.now() - this.firstSeen,
ts: Date.now(), done: isDone };
} catch { return null; }
}
}