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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"name": "claudeclaw",
"source": "./",
"description": "Cron-like daemon that runs Claude prompts on a schedule",
"version": "1.0.39",
"version": "1.0.40",
"keywords": [
"cron",
"heartbeat",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "claudeclaw",
"version": "1.0.39",
"version": "1.0.40",
"description": "Cron-like daemon that runs Claude prompts on a schedule"
}
7 changes: 3 additions & 4 deletions src/commands/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resetSession, resetFallbackSession, peekSession } from "../sessions";
import { listThreadSessions, removeThreadSession, peekThreadSession } from "../sessionManager";
import { readFile } from "node:fs/promises";
import { existsSync, realpathSync, statSync } from "node:fs";
import { findSessionJsonlPath } from "../sessionFiles";
import { homedir } from "node:os";
import { transcribeAudioToText } from "../whisper";
import { resolveSkillPrompt } from "../skills";
Expand Down Expand Up @@ -1188,10 +1189,8 @@ async function handleInteractionCreate(token: string, interaction: DiscordIntera
await respondToInteraction(interaction, { content: "No active session." });
return;
}
const home = homedir();
const projectSlug = process.cwd().replace(/\//g, "-");
const jsonlPath = `${home}/.claude/projects/${projectSlug}/${session.sessionId}.jsonl`;
if (!existsSync(jsonlPath)) {
const jsonlPath = findSessionJsonlPath(session.sessionId);
if (!jsonlPath) {
await respondToInteraction(interaction, { content: "Conversation file not found." });
return;
}
Expand Down
10 changes: 4 additions & 6 deletions src/commands/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { getSettings, loadSettings } from "../config";
import { transcribeAudioToText } from "../whisper";
import { resetSession, resetFallbackSession, peekSession } from "../sessions";
import { peekThreadSession, removeThreadSession } from "../sessionManager";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { readFile } from "node:fs/promises";
import { findSessionJsonlPath } from "../sessionFiles";
import { resolveSkillPrompt, listSkills } from "../skills";
import { mkdir } from "node:fs/promises";
import { extname, join } from "node:path";
Expand Down Expand Up @@ -1097,10 +1097,8 @@ async function handleMessage(message: TelegramMessage): Promise<void> {
await sendMessage(config.token, chatId, "No active session.", threadId);
return;
}
const home = homedir();
const projectSlug = process.cwd().replace(/\//g, "-");
const jsonlPath = `${home}/.claude/projects/${projectSlug}/${session.sessionId}.jsonl`;
if (!existsSync(jsonlPath)) {
const jsonlPath = findSessionJsonlPath(session.sessionId);
if (!jsonlPath) {
await sendMessage(config.token, chatId, "Conversation file not found.", threadId);
return;
}
Expand Down
41 changes: 41 additions & 0 deletions src/sessionFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** Must match Claude Code's JSONL directory sanitizer (slashes, backslashes, dots → dashes). */
export function sanitizeProjectSlug(cwd: string): string {
return cwd.replace(/[/\\.]/g, "-");
}

export function getClaudeProjectDir(cwd: string = process.cwd()): string {
return join(homedir(), ".claude", "projects", sanitizeProjectSlug(cwd));
}

/**
* Resolve the Claude Code transcript JSONL for a session id.
* Tries the cwd-derived project dir first, then scans ~/.claude/projects.
*/
export function findSessionJsonlPath(sessionId: string, cwd: string = process.cwd()): string | null {
if (!UUID_RE.test(sessionId)) return null;

const direct = join(getClaudeProjectDir(cwd), `${sessionId}.jsonl`);
if (existsSync(direct)) return direct;

const projectsRoot = join(homedir(), ".claude", "projects");
if (!existsSync(projectsRoot)) return null;

let newest: { path: string; mtimeMs: number } | null = null;
for (const entry of readdirSync(projectsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = join(projectsRoot, entry.name, `${sessionId}.jsonl`);
if (!existsSync(candidate)) continue;
const mtimeMs = statSync(candidate).mtimeMs;
if (!newest || mtimeMs > newest.mtimeMs) {
newest = { path: candidate, mtimeMs };
}
}

return newest?.path ?? null;
}
18 changes: 6 additions & 12 deletions src/ui/services/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { join, basename } from "node:path";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { getAgentsDir } from "../../config";
import { findSessionJsonlPath, getClaudeProjectDir } from "../../sessionFiles";

export interface SessionInfo {
id: string;
Expand All @@ -25,12 +25,6 @@ export interface ChatMessage {
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DISCORD_SNOWFLAKE_RE = /^\d{17,19}$/;

// Must match Claude Code's JSONL directory sanitizer (slashes, backslashes, dots → dashes).
function getProjectDir(): string {
const sanitized = process.cwd().replace(/[/\\.]/g, "-");
return join(homedir(), ".claude", "projects", sanitized);
}

function extractUserText(line: string): string {
if (!line.trim()) return "";
try {
Expand Down Expand Up @@ -61,8 +55,8 @@ function extractUserText(line: string): string {
// Single file read to get both the first and last user message (for sidebar preview).
async function peekMessages(sessionId: string): Promise<{ first: string; last: string }> {
if (!UUID_RE.test(sessionId)) return { first: "", last: "" };
const filePath = join(getProjectDir(), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return { first: "", last: "" };
const filePath = findSessionJsonlPath(sessionId);
if (!filePath) return { first: "", last: "" };
let first = "";
let last = "";
try {
Expand Down Expand Up @@ -160,7 +154,7 @@ export async function listSessions(): Promise<SessionInfo[]> {

// Orphan JSONL sessions not tracked by any session file (up to 20 most recent)
try {
const projectDir = getProjectDir();
const projectDir = getClaudeProjectDir();
const files = (await readdir(projectDir)).filter(f => f.endsWith(".jsonl"));
const candidates = files
.map(f => basename(f, ".jsonl"))
Expand Down Expand Up @@ -201,8 +195,8 @@ export async function readSessionMessages(
// Validate UUID shape before constructing file path (prevent path traversal).
if (!UUID_RE.test(sessionId)) return { messages: [], total: 0 };

const filePath = join(getProjectDir(), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return { messages: [], total: 0 };
const filePath = findSessionJsonlPath(sessionId);
if (!filePath) return { messages: [], total: 0 };

const content = await readFile(filePath, "utf-8");
const all: ChatMessage[] = [];
Expand Down
11 changes: 3 additions & 8 deletions src/ui/services/usage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { findSessionJsonlPath } from "../../sessionFiles";

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

Expand All @@ -22,11 +22,6 @@ export interface SessionUsage {
lastUsedAt: string;
}

function getProjectDir(): string {
const sanitized = process.cwd().replace(/[/\\.]/g, "-");
return join(homedir(), ".claude", "projects", sanitized);
}

function calcCost(tokens: Pick<SessionUsage, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens">): number {
return (
tokens.inputTokens * PRICING.input +
Expand All @@ -40,8 +35,8 @@ async function parseJSONLUsage(sessionId: string): Promise<Pick<SessionUsage, "i
const zero = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
if (!UUID_RE.test(sessionId)) return zero;

const filePath = join(getProjectDir(), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return zero;
const filePath = findSessionJsonlPath(sessionId);
if (!filePath) return zero;

const result = { ...zero };
const seenIds = new Set<string>();
Expand Down
79 changes: 79 additions & 0 deletions tests/session-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
findSessionJsonlPath,
getClaudeProjectDir,
sanitizeProjectSlug,
} from "../src/sessionFiles.ts";

const SESSION_ID = "11111111-1111-4111-8111-111111111111";

let fakeHome = "";
let previousHome: string | undefined;

beforeEach(() => {
fakeHome = join(tmpdir(), `claudeclaw-session-files-${Date.now()}`);
mkdirSync(join(fakeHome, ".claude", "projects"), { recursive: true });
previousHome = process.env.HOME;
process.env.HOME = fakeHome;
});

afterEach(() => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
rmSync(fakeHome, { recursive: true, force: true });
});

describe("sessionFiles", () => {
it("sanitizeProjectSlug matches Claude Code directory rules", () => {
assert.equal(sanitizeProjectSlug("/home/claw/newsletter"), "-home-claw-newsletter");
assert.equal(sanitizeProjectSlug("C:\\Users\\claw\\project"), "C:-Users-claw-project");
});

it("getClaudeProjectDir uses sanitized cwd under HOME", () => {
const cwd = "/home/claw/my.project";
assert.equal(
getClaudeProjectDir(cwd),
join(fakeHome, ".claude", "projects", sanitizeProjectSlug(cwd)),
);
});

it("findSessionJsonlPath prefers cwd project dir", () => {
const cwd = "/home/claw/newsletter";
const projectDir = join(fakeHome, ".claude", "projects", sanitizeProjectSlug(cwd));
mkdirSync(projectDir, { recursive: true });
const expected = join(projectDir, `${SESSION_ID}.jsonl`);
writeFileSync(expected, '{"type":"user"}\n', "utf8");

const otherDir = join(fakeHome, ".claude", "projects", "-other-project");
mkdirSync(otherDir, { recursive: true });
writeFileSync(join(otherDir, `${SESSION_ID}.jsonl`), '{"type":"user"}\n', "utf8");

assert.equal(findSessionJsonlPath(SESSION_ID, cwd), expected);
});

it("findSessionJsonlPath scans projects when cwd slug misses", () => {
const cwd = "/home/claw/wrong-launch-dir";
const actualDir = join(fakeHome, ".claude", "projects", "-home-claw-real-project");
mkdirSync(actualDir, { recursive: true });
const expected = join(actualDir, `${SESSION_ID}.jsonl`);
writeFileSync(expected, '{"type":"user"}\n', "utf8");

assert.equal(findSessionJsonlPath(SESSION_ID, cwd), expected);
});

it("findSessionJsonlPath rejects non-uuid session ids", () => {
assert.equal(findSessionJsonlPath("../escape"), null);
});
});

describe("telegram command imports", () => {
it("imports existsSync for voice directive filtering", () => {
const src = readFileSync(new URL("../src/commands/telegram.ts", import.meta.url), "utf8");
assert.match(src, /import \{ existsSync \} from "node:fs";/);
assert.match(src, /existsSync\(p\)/);
});
});
Loading