From 8cb34efd2db6524c8eddc6fdd52dc89853dd71b0 Mon Sep 17 00:00:00 2001 From: Prateek Date: Tue, 17 Feb 2026 17:53:12 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20activity=20detection=20=E2=80=94=20fix?= =?UTF-8?q?=20path=20encoding,=20use=20tail=20-1=20for=20JSONL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/utils.ts | 62 ++++++------------- .../agent-claude-code/src/index.test.ts | 2 +- .../plugins/agent-claude-code/src/index.ts | 3 +- 3 files changed, 23 insertions(+), 44 deletions(-) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 61899f46c..b90df9648 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -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; - 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":"",...} + const parsed: unknown = JSON.parse(line); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const obj = parsed as Record; + 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(); } } diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index bb58fd26d..698bf79d5 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -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", ); }); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index b2d303865..fbde55b38 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -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 */