Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion __tests__/hooks/builtin-policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { readFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import { BUILTIN_POLICIES, registerBuiltinPolicies } from "../../src/hooks/builtin-policies";
import { BUILTIN_POLICIES, registerBuiltinPolicies, clearGitBranchCache } from "../../src/hooks/builtin-policies";
import { getPoliciesForEvent, clearPolicies } from "../../src/hooks/policy-registry";
import type { PolicyContext } from "../../src/hooks/policy-types";

Expand Down Expand Up @@ -772,6 +772,7 @@ describe("hooks/builtin-policies", () => {

afterEach(() => {
vi.mocked(execSync).mockReset();
clearGitBranchCache();
});

it("blocks git commit on main", async () => {
Expand Down Expand Up @@ -1519,6 +1520,7 @@ describe("hooks/builtin-policies", () => {

afterEach(() => {
vi.mocked(execSync).mockReset();
clearGitBranchCache();
});

it("blocks git commit on a custom protected branch", async () => {
Expand Down
22 changes: 9 additions & 13 deletions __tests__/lib/runtime-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe("runtimeCache", () => {
});

describe("LRU eviction with maxSize", () => {
it("evicts the least-recently-used entry when maxSize is reached", async () => {
it("evicts the oldest-inserted entry when maxSize is reached", async () => {
let callCount = 0;
const fn = vi.fn().mockImplementation(async (x: string) => {
callCount++;
Expand All @@ -92,26 +92,22 @@ describe("runtimeCache", () => {
await cached("c"); // cache: [a, b, c]
expect(fn).toHaveBeenCalledTimes(3);

// Access "a" again (should be cached, moves to end)
const resultA = await cached("a"); // cache: [b, c, a]
// Access "a" again — cache hit, no reordering (insertion-order eviction)
const resultA = await cached("a"); // cache: [a, b, c] — no reorder
expect(fn).toHaveBeenCalledTimes(3); // no new call
expect(resultA).toBe("result-a-1");

// Insert "d" — should evict "b" (least recently used)
await cached("d"); // cache: [c, a, d]
// Insert "d" — evicts "a" (oldest inserted, even though recently accessed)
await cached("d"); // cache: [b, c, d]
expect(fn).toHaveBeenCalledTimes(4);

// "b" should have been evicted — next call should re-compute
await cached("b"); // cache: [a, d, b]
// "a" should have been evicted — next call should re-compute
await cached("a"); // cache: [c, d, a]
expect(fn).toHaveBeenCalledTimes(5);

// "c" should also have been evicted by now
await cached("c"); // cache: [d, b, c] — evicts "a"
// "b" should also have been evicted by now
await cached("b"); // cache: [d, a, b]
expect(fn).toHaveBeenCalledTimes(6);

// "a" was evicted, should re-compute
await cached("a");
expect(fn).toHaveBeenCalledTimes(7);
});

it("does not evict when cache is under maxSize", async () => {
Expand Down
48 changes: 2 additions & 46 deletions app/components/session-hooks-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,13 @@ import {
ShieldAlert,
Shield,
ChevronDown,
Copy,
Check,
} from "lucide-react";
import PaginationControls from "@/app/components/pagination-controls";
import { searchHookActivityAction } from "@/app/actions/get-hook-activity";
import type { HookActivityPayload } from "@/app/actions/get-hook-activity";
import { useAutoRefresh } from "@/contexts/AutoRefreshContext";

// -- Formatters --

function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60_000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
import { formatRelativeTime } from "@/lib/format-duration";
import { CopyButton } from "@/app/components/copy-button";

function formatAbsoluteTime(ts: number): string {
return new Date(ts).toLocaleString(undefined, {
Expand Down Expand Up @@ -100,40 +90,6 @@ function DurationDisplay({ ms }: { ms: number }) {
return <span className={`font-mono text-[0.7rem] ${color}`}>{formatDuration(ms)}</span>;
}

// -- Copy Button --

function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Copy"
>
{copied ? <Check className="h-3 w-3 text-emerald-400" /> : <Copy className="h-3 w-3" />}
</button>
);
}

// -- Decision Pill Toggle --

function DecisionPills({
Expand Down
11 changes: 1 addition & 10 deletions app/policies/hooks-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,9 @@ import { updatePolicyParamsAction } from "@/app/actions/update-policy-params";
import { useAutoRefresh } from "@/contexts/AutoRefreshContext";
import { useUrlParams } from "@/lib/use-url-params";
import { pageToParam, paramToPage } from "@/lib/url-filter-serializers";
import { formatRelativeTime } from "@/lib/format-duration";
import { Button } from "@/components/ui/button";

// -- Formatters --

function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60_000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}

function formatAbsoluteTime(ts: number): string {
return new Date(ts).toLocaleString(undefined, {
month: "short",
Expand Down
8 changes: 8 additions & 0 deletions lib/format-duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
* This module is intentionally free of Node.js imports so it can be
* safely used in both server and client components.
*/
export function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60_000) return `${Math.max(1, Math.floor(diff / 1000))}s ago`;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}

export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
Expand Down
2 changes: 1 addition & 1 deletion lib/log-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ async function parseFileContent(fileContent: string, source: LogSource): Promise
if (!resultInfo) continue;

const returnDate = new Date(resultInfo.timestamp);
const durationMs = resultInfo.timestampMs - entry.timestampMs;
const durationMs = Math.max(0, resultInfo.timestampMs - entry.timestampMs);
block.result = {
timestamp: resultInfo.timestamp,
timestampFormatted: formatTimestamp(returnDate),
Expand Down
21 changes: 15 additions & 6 deletions lib/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { readdir, stat } from "fs/promises";
import { join, resolve, sep } from "path";
import { getClaudeProjectsPath } from "./paths";
import { runtimeCache } from "./runtime-cache";
import { batchAll } from "./concurrency";
import { logWarn, logError } from "./logger";
import { formatDate } from "./utils";

Expand Down Expand Up @@ -59,10 +60,10 @@ export async function getProjectFolders(): Promise<ProjectFolder[]> {
const entries = await safeReaddir(projectsPath);
if (!entries) return [];

const folders = await Promise.all(
const settled = await batchAll(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
.map((entry) => async () => {
const folderPath = join(projectsPath, entry.name);
const mtime = await getMtime(folderPath, entry.name);
return {
Expand All @@ -72,8 +73,12 @@ export async function getProjectFolders(): Promise<ProjectFolder[]> {
lastModified: mtime,
lastModifiedFormatted: formatDate(mtime),
} as ProjectFolder;
})
}),
16,
);
const folders = settled
.filter((r): r is PromiseFulfilledResult<ProjectFolder> => r.status === "fulfilled")
.map((r) => r.value);

folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
return folders;
Expand Down Expand Up @@ -142,8 +147,8 @@ export async function getSessionFiles(projectPath: string): Promise<SessionFile[
(entry) => entry.isFile() && entry.name.endsWith(".jsonl") && extractSessionId(entry.name)
);

const files = await Promise.all(
jsonlEntries.map(async (entry) => {
const settled = await batchAll(
jsonlEntries.map((entry) => async () => {
const filePath = join(projectPath, entry.name);
const mtime = await getMtime(filePath, entry.name);
return {
Expand All @@ -153,8 +158,12 @@ export async function getSessionFiles(projectPath: string): Promise<SessionFile[
lastModifiedFormatted: formatDate(mtime),
sessionId: extractSessionId(entry.name),
} as SessionFile;
})
}),
16,
);
const files = settled
.filter((r): r is PromiseFulfilledResult<SessionFile> => r.status === "fulfilled")
.map((r) => r.value);

files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
return files;
Expand Down
3 changes: 2 additions & 1 deletion lib/resolve-subagent-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export async function resolveSubagentPath(
return candidatePath;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code !== "ENOENT") continue;
if (code === "ENOENT") continue; // file not found — try next candidate
break; // unexpected error (e.g., EACCES) — stop searching
}
}

Expand Down
5 changes: 0 additions & 5 deletions lib/runtime-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ export function runtimeCache<TArgs extends unknown[], TResult>(
const key = JSON.stringify(args);
const entry = cache.get(key);
if (entry && Date.now() < entry.expiry) {
// LRU: move to end (most recently used) and refresh expiry
if (maxSize) {
cache.delete(key);
cache.set(key, { data: entry.data, expiry: Date.now() + revalidateSeconds * 1000 });
}
return entry.data;
}

Expand Down
Loading