diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2965c7a7..f5430793 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9e77a27c..2688e3ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" } diff --git a/src/commands/discord.ts b/src/commands/discord.ts index ee39c0da..edbece9f 100644 --- a/src/commands/discord.ts +++ b/src/commands/discord.ts @@ -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"; @@ -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; } diff --git a/src/commands/telegram.ts b/src/commands/telegram.ts index d709432a..fd7584cd 100644 --- a/src/commands/telegram.ts +++ b/src/commands/telegram.ts @@ -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"; @@ -1097,10 +1097,8 @@ async function handleMessage(message: TelegramMessage): Promise { 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; } diff --git a/src/sessionFiles.ts b/src/sessionFiles.ts new file mode 100644 index 00000000..ebbc1e2d --- /dev/null +++ b/src/sessionFiles.ts @@ -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; +} diff --git a/src/ui/services/sessions.ts b/src/ui/services/sessions.ts index 7a80ac92..5cf560d7 100644 --- a/src/ui/services/sessions.ts +++ b/src/ui/services/sessions.ts @@ -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; @@ -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 { @@ -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 { @@ -160,7 +154,7 @@ export async function listSessions(): Promise { // 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")) @@ -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[] = []; diff --git a/src/ui/services/usage.ts b/src/ui/services/usage.ts index 6c6ed246..54396b8d 100644 --- a/src/ui/services/usage.ts +++ b/src/ui/services/usage.ts @@ -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; @@ -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): number { return ( tokens.inputTokens * PRICING.input + @@ -40,8 +35,8 @@ async function parseJSONLUsage(sessionId: string): Promise(); diff --git a/tests/session-files.test.ts b/tests/session-files.test.ts new file mode 100644 index 00000000..959462b3 --- /dev/null +++ b/tests/session-files.test.ts @@ -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\)/); + }); +});