fix: activity detection — fix path encoding, use tail -1 for JSONL

Two tightly coupled infrastructure fixes:

- Fix toClaudeProjectPath(): leading `/` becomes `-` (not stripped),
  matching Claude Code's actual project directory naming convention.
- Replace manual 4KB buffer read in readLastJsonlEntry() with
  `tail -1` + JSON.parse — handles any file size, any line length,
  and eliminates the truncated-line edge case entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-17 17:53:12 +05:30
parent 9c5927a7f6
commit 8cb34efd2d
3 changed files with 23 additions and 44 deletions

View File

@ -2,7 +2,11 @@
* Shared utility functions for agent-orchestrator plugins.
*/
import { open } from "node:fs/promises";
import { execFile } from "node:child_process";
import { stat } from "node:fs/promises";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* POSIX-safe shell escaping: wraps value in single quotes,
@ -33,60 +37,34 @@ export function validateUrl(url: string, label: string): void {
}
/**
* Read only the last 4KB of JSONL files to find the last entry.
* Avoids loading entire (potentially large) files into memory.
*/
const TAIL_READ_BYTES = 4096;
/**
* Read the last entry from a JSONL file efficiently.
* Only reads the last TAIL_READ_BYTES (4KB) from the file to avoid loading
* entire potentially large files into memory.
* Read the last entry from a JSONL file.
* Uses `tail -1` to get the last line handles any file size, any line length.
*
* @param filePath - Path to the JSONL file
* @returns Object containing the last entry's type and file mtime, or null if file is empty/invalid
* @returns Object containing the last entry's type and file mtime, or null if empty/invalid
*/
export async function readLastJsonlEntry(
filePath: string,
): Promise<{ lastType: string | null; modifiedAt: Date } | null> {
let fh;
try {
fh = await open(filePath, "r");
const fileStat = await fh.stat();
const size = fileStat.size;
if (size === 0) return null;
const [{ stdout }, fileStat] = await Promise.all([
execFileAsync("tail", ["-1", filePath], { timeout: 5_000 }),
stat(filePath),
]);
// Read only the last TAIL_READ_BYTES (4KB) from the file
const readSize = Math.min(TAIL_READ_BYTES, size);
const buffer = Buffer.alloc(readSize);
const { bytesRead } = await fh.read(buffer, 0, readSize, size - readSize);
const line = stdout.trim();
if (!line) return null;
const chunk = buffer.toString("utf-8", 0, bytesRead);
// Walk backwards through lines to find the last valid JSON object with a type
const lines = chunk.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) continue;
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.type === "string") {
return { lastType: obj.type, modifiedAt: fileStat.mtime };
}
}
} catch {
// Skip malformed lines (possibly truncated first line in our chunk)
}
// JSONL entries have deterministic structure: {"type":"<value>",...}
const parsed: unknown = JSON.parse(line);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
const lastType = typeof obj.type === "string" ? obj.type : null;
return { lastType, modifiedAt: fileStat.mtime };
}
// No entry with a type field found
return { lastType: null, modifiedAt: fileStat.mtime };
} catch {
return null;
} finally {
await fh?.close();
}
}

View File

@ -437,7 +437,7 @@ describe("getSessionInfo", () => {
mockJsonlFiles('{"type":"user","message":{"content":"hello"}}');
await agent.getSessionInfo(makeSession({ workspacePath: "/Users/dev/.worktrees/ao/ao-3" }));
expect(mockReaddir).toHaveBeenCalledWith(
"/mock/home/.claude/projects/Users-dev--worktrees-ao-ao-3",
"/mock/home/.claude/projects/-Users-dev--worktrees-ao-ao-3",
);
});
});

View File

@ -195,7 +195,8 @@ export const manifest = {
export function toClaudeProjectPath(workspacePath: string): string {
// Handle Windows drive letters (C:\Users\... → C-Users-...)
const normalized = workspacePath.replace(/\\/g, "/");
return normalized.replace(/^\//, "").replace(/:/g, "").replace(/[/.]/g, "-");
// Claude Code replaces / and . with - (keeping the leading slash as a leading -)
return normalized.replace(/:/g, "").replace(/[/.]/g, "-");
}
/** Find the most recently modified .jsonl session file in a directory */