diff --git a/src/acp-agent.ts b/src/acp-agent.ts index e34fc9f4..3013e197 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -9,6 +9,8 @@ import { ForkSessionResponse, InitializeRequest, InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, ndJsonStream, NewSessionRequest, NewSessionResponse, @@ -19,6 +21,7 @@ import { RequestError, ResumeSessionRequest, ResumeSessionResponse, + SessionInfo, SessionModelState, SessionNotification, SetSessionModelRequest, @@ -65,6 +68,40 @@ import { fileURLToPath } from "node:url"; export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); +/** + * Decode a Claude project path encoding back to the original filesystem path. + * Claude encodes paths by replacing path separators with dashes: + * - Unix: "/Users/morse/project" -> "-Users-morse-project" + * - Windows: "C:\Users\morse\project" -> "C-Users-morse-project" + */ +function decodeProjectPath(encodedPath: string): string { + // Check if this looks like a Windows path (starts with drive letter pattern like "C-") + const windowsDriveMatch = encodedPath.match(/^([A-Za-z])-/); + if (windowsDriveMatch) { + // Windows path: "C-Users-morse-project" -> "C:\Users\morse\project" + const driveLetter = windowsDriveMatch[1]; + const rest = encodedPath.slice(2); // Skip "C-" + return `${driveLetter}:\\${rest.replace(/-/g, "\\")}`; + } + + // Unix path: "-Users-morse-project" -> "/Users/morse/project" + return encodedPath.replace(/-/g, "/"); +} + +const MAX_TITLE_LENGTH = 128; + +function sanitizeTitle(text: string): string { + // Replace newlines and collapse whitespace + const sanitized = text + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (sanitized.length <= MAX_TITLE_LENGTH) { + return sanitized; + } + return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…"; +} + /** * Logger interface for customizing logging output */ @@ -193,6 +230,7 @@ export class ClaudeAcpAgent implements Agent { }, sessionCapabilities: { fork: {}, + list: {}, resume: {}, }, }, @@ -248,6 +286,149 @@ export class ClaudeAcpAgent implements Agent { return response; } + /** + * List Claude Code sessions by parsing JSONL files + * Sessions are stored in ~/.claude/projects// + * Implements the draft session/list RFD spec + */ + async unstable_listSessions(params: ListSessionsRequest): Promise { + // Note: We load all sessions into memory for sorting, so pagination here is for + // API response size limits rather than memory efficiency. This matches the RFD spec. + const PAGE_SIZE = 50; + const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects"); + + try { + await fs.promises.access(claudeDir); + } catch { + return { sessions: [] }; + } + + // Collect all sessions across all project directories + const allSessions: SessionInfo[] = []; + + try { + const projectDirs = await fs.promises.readdir(claudeDir); + + for (const encodedPath of projectDirs) { + const projectDir = path.join(claudeDir, encodedPath); + const stat = await fs.promises.stat(projectDir); + if (!stat.isDirectory()) continue; + + // Decode the path based on platform: + // - Unix: "-Users-morse-project" -> "/Users/morse/project" + // - Windows: "C-Users-morse-project" -> "C:\Users\morse\project" + const decodedCwd = decodeProjectPath(encodedPath); + + // Skip if filtering by cwd and this doesn't match + if (params.cwd && decodedCwd !== params.cwd) continue; + + const files = await fs.promises.readdir(projectDir); + // Filter to user session files only. Skip agent-*.jsonl files which contain + // internal agent metadata and system logs, not user-visible conversation sessions. + const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); + + for (const file of jsonlFiles) { + const filePath = path.join(projectDir, file); + try { + const content = await fs.promises.readFile(filePath, "utf-8"); + const lines = content.trim().split("\n").filter(Boolean); + + const firstLine = lines[0]; + if (!firstLine) continue; + + // Parse first line to get session info + const firstEntry = JSON.parse(firstLine); + const sessionId = firstEntry.sessionId || file.replace(".jsonl", ""); + + // Find first user message for title + let title: string | undefined; + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "user" && entry.message?.content) { + const msgContent = entry.message.content; + if (typeof msgContent === "string") { + title = sanitizeTitle(msgContent); + break; + } + if (Array.isArray(msgContent) && msgContent.length > 0) { + const first = msgContent[0]; + const text = + typeof first === "string" + ? first + : first && typeof first === "object" && typeof first.text === "string" + ? first.text + : undefined; + if (text) { + title = sanitizeTitle(text); + break; + } + } + } + } catch { + // Skip malformed lines + } + } + + // Get file modification time as updatedAt + const fileStat = await fs.promises.stat(filePath); + const updatedAt = fileStat.mtime.toISOString(); + + allSessions.push({ + sessionId, + cwd: decodedCwd, + title: title ?? null, + updatedAt, + }); + } catch (err) { + this.logger.error( + `[unstable_listSessions] Failed to parse session file: ${filePath}`, + err, + ); + } + } + } + } catch (err) { + this.logger.error("[unstable_listSessions] Failed to list sessions", err); + return { sessions: [] }; + } + + // Sort by updatedAt descending (most recent first) + allSessions.sort((a, b) => { + const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; + const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; + return timeB - timeA; + }); + + // Handle pagination with cursor + let startIndex = 0; + if (params.cursor) { + try { + const decoded = Buffer.from(params.cursor, "base64").toString("utf-8"); + const cursorData = JSON.parse(decoded); + startIndex = cursorData.offset ?? 0; + } catch { + // Invalid cursor, start from beginning + } + } + + const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE); + const hasMore = startIndex + PAGE_SIZE < allSessions.length; + + const response: ListSessionsResponse = { + sessions: pageOfSessions, + }; + + if (hasMore) { + const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString( + "base64", + ); + response.nextCursor = nextCursor; + } + + return response; + } + async authenticate(_params: AuthenticateRequest): Promise { throw new Error("Method not implemented."); } diff --git a/src/tests/list-sessions.test.ts b/src/tests/list-sessions.test.ts new file mode 100644 index 00000000..47a5fff2 --- /dev/null +++ b/src/tests/list-sessions.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { AgentSideConnection } from "@agentclientprotocol/sdk"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; + +describe("unstable_listSessions", () => { + let tempDir: string; + let originalClaudeConfigDir: string | undefined; + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + + // Helper to create a mock AgentSideConnection + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + // Helper to encode a path like Claude does: + // - Unix: "/Users/test" -> "-Users-test" + // - Windows: "C:\Users\test" -> "C-Users-test" + function encodePath(cwd: string): string { + // Handle Windows paths + if (/^[A-Za-z]:\\/.test(cwd)) { + return cwd.replace(/\\/g, "-").replace(":", ""); + } + // Unix paths + return cwd.replace(/\//g, "-"); + } + + // Helper to write a session file + function writeSessionFile( + cwd: string, + sessionId: string, + options: { + userMessage?: string; + userMessageArray?: boolean; + mtime?: Date; + malformed?: boolean; + isAgentFile?: boolean; + } = {}, + ): void { + const encodedPath = encodePath(cwd); + const projectDir = path.join(tempDir, "projects", encodedPath); + fs.mkdirSync(projectDir, { recursive: true }); + + const filename = options.isAgentFile ? `agent-${sessionId}.jsonl` : `${sessionId}.jsonl`; + const filePath = path.join(projectDir, filename); + + if (options.malformed) { + fs.writeFileSync(filePath, "not valid json\n{also bad"); + return; + } + + const lines: string[] = []; + + // First line with sessionId + lines.push(JSON.stringify({ sessionId, type: "init" })); + + // User message if provided + if (options.userMessage) { + const content = options.userMessageArray + ? [{ type: "text", text: options.userMessage }] + : options.userMessage; + lines.push(JSON.stringify({ type: "user", message: { content } })); + } + + // Assistant response + lines.push(JSON.stringify({ type: "assistant", message: { content: "Hello!" } })); + + fs.writeFileSync(filePath, lines.join("\n")); + + // Set modification time if specified + if (options.mtime) { + fs.utimesSync(filePath, options.mtime, options.mtime); + } + } + + beforeEach(async () => { + // Create temp directory to isolate tests from real filesystem. + // We set process.env.CLAUDE_CONFIG_DIR before importing the module so that + // CLAUDE_CONFIG_DIR is evaluated with our temp directory. + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-test-")); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tempDir; + + // Reset modules to pick up the new CLAUDE_CONFIG_DIR env var + vi.resetModules(); + + // Dynamic import after setting env var + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + + // Create agent instance + agent = new ClaudeAcpAgent(createMockClient()); + }); + + afterEach(() => { + // Restore env and cleanup + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns empty array when projects directory does not exist", async () => { + const result = await agent.unstable_listSessions({}); + expect(result.sessions).toEqual([]); + expect(result.nextCursor).toBeUndefined(); + }); + + it("returns empty array when projects directory is empty", async () => { + fs.mkdirSync(path.join(tempDir, "projects"), { recursive: true }); + + const result = await agent.unstable_listSessions({}); + expect(result.sessions).toEqual([]); + }); + + it("parses session files and returns correct metadata", async () => { + const cwd = "/Users/test/myproject"; + const sessionId = "sess-123"; + const mtime = new Date("2025-01-15T10:30:00Z"); + + writeSessionFile(cwd, sessionId, { + userMessage: "Hello, can you help me?", + mtime, + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + sessionId: "sess-123", + cwd: "/Users/test/myproject", + title: "Hello, can you help me?", + }); + expect(result.sessions[0]!.updatedAt).toBeDefined(); + }); + + it("extracts title from array-style user message content", async () => { + writeSessionFile("/Users/test/project", "sess-456", { + userMessage: "Fix the bug in auth", + userMessageArray: true, + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.title).toBe("Fix the bug in auth"); + }); + + it("truncates long titles to 128 characters with ellipsis", async () => { + const longMessage = "A".repeat(150); + writeSessionFile("/Users/test/project", "sess-789", { + userMessage: longMessage, + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.title).toBe("A".repeat(127) + "…"); + }); + + it("replaces newlines with spaces in titles", async () => { + const messageWithNewlines = "First line\nSecond line\r\nThird line"; + writeSessionFile("/Users/test/project", "sess-newlines", { + userMessage: messageWithNewlines, + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.title).toBe("First line Second line Third line"); + }); + + it("collapses multiple whitespace characters in titles", async () => { + const messageWithWhitespace = "Hello world \n\n test"; + writeSessionFile("/Users/test/project", "sess-whitespace", { + userMessage: messageWithWhitespace, + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.title).toBe("Hello world test"); + }); + + it("filters sessions by cwd parameter", async () => { + writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "Project A" }); + writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "Project B" }); + + const result = await agent.unstable_listSessions({ cwd: "/Users/test/projectone" }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]!.sessionId).toBe("sess-a"); + }); + + it("returns all sessions when cwd is not specified", async () => { + writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "A" }); + writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "B" }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(2); + }); + + it("sorts sessions by updatedAt descending (most recent first)", async () => { + writeSessionFile("/Users/test/project", "old-session", { + userMessage: "Old", + mtime: new Date("2025-01-01T00:00:00Z"), + }); + writeSessionFile("/Users/test/project", "new-session", { + userMessage: "New", + mtime: new Date("2025-01-15T00:00:00Z"), + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.sessionId).toBe("new-session"); + expect(result.sessions[1]!.sessionId).toBe("old-session"); + }); + + it("skips files starting with agent-", async () => { + writeSessionFile("/Users/test/project", "regular-session", { userMessage: "Regular" }); + writeSessionFile("/Users/test/project", "hidden-session", { + userMessage: "Hidden", + isAgentFile: true, + }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]!.sessionId).toBe("regular-session"); + }); + + it("skips malformed session files gracefully", async () => { + writeSessionFile("/Users/test/project", "good-session", { userMessage: "Good" }); + writeSessionFile("/Users/test/project", "bad-session", { malformed: true }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]!.sessionId).toBe("good-session"); + }); + + it("uses filename as sessionId when not in file content", async () => { + const cwd = "/Users/test/project"; + const encodedPath = encodePath(cwd); + const projectDir = path.join(tempDir, "projects", encodedPath); + fs.mkdirSync(projectDir, { recursive: true }); + + // Write file without sessionId in content + const filePath = path.join(projectDir, "fallback-id.jsonl"); + fs.writeFileSync(filePath, JSON.stringify({ type: "init" }) + "\n"); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.sessionId).toBe("fallback-id"); + }); + + it("returns null title when no user message exists", async () => { + const cwd = "/Users/test/project"; + const encodedPath = encodePath(cwd); + const projectDir = path.join(tempDir, "projects", encodedPath); + fs.mkdirSync(projectDir, { recursive: true }); + + // Write file with only init and assistant message + const filePath = path.join(projectDir, "no-user.jsonl"); + fs.writeFileSync( + filePath, + [ + JSON.stringify({ sessionId: "no-user", type: "init" }), + JSON.stringify({ type: "assistant", message: { content: "Hi" } }), + ].join("\n"), + ); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions[0]!.title).toBeNull(); + }); + + describe("pagination", () => { + it("returns nextCursor when there are more results", async () => { + // Create 60 sessions (more than PAGE_SIZE of 50) + for (let i = 0; i < 60; i++) { + writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, { + userMessage: `Session ${i}`, + mtime: new Date(Date.now() - i * 1000), // Each older by 1 second + }); + } + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(50); + expect(result.nextCursor).toBeDefined(); + }); + + it("does not return nextCursor when all results fit in one page", async () => { + for (let i = 0; i < 10; i++) { + writeSessionFile("/Users/test/project", `sess-${i}`, { userMessage: `Session ${i}` }); + } + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(10); + expect(result.nextCursor).toBeUndefined(); + }); + + it("returns next page when cursor is provided", async () => { + // Create 60 sessions + for (let i = 0; i < 60; i++) { + writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, { + userMessage: `Session ${i}`, + mtime: new Date(Date.now() - i * 1000), + }); + } + + // Get first page + const firstPage = await agent.unstable_listSessions({}); + expect(firstPage.sessions).toHaveLength(50); + expect(firstPage.nextCursor).toBeDefined(); + + // Get second page + const secondPage = await agent.unstable_listSessions({ cursor: firstPage.nextCursor }); + expect(secondPage.sessions).toHaveLength(10); + expect(secondPage.nextCursor).toBeUndefined(); + }); + + it("handles invalid cursor gracefully (starts from beginning)", async () => { + writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" }); + + const result = await agent.unstable_listSessions({ cursor: "invalid-base64!" }); + + expect(result.sessions).toHaveLength(1); + }); + + it("handles malformed cursor JSON gracefully", async () => { + writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" }); + + // Valid base64 but not valid JSON + const badCursor = Buffer.from("not json").toString("base64"); + const result = await agent.unstable_listSessions({ cursor: badCursor }); + + expect(result.sessions).toHaveLength(1); + }); + }); + + describe("Windows path support", () => { + it("decodes Windows-style paths correctly", async () => { + // Simulate a Windows path encoded as "C-Users-test-project" + const windowsCwd = "C:\\Users\\test\\project"; + writeSessionFile(windowsCwd, "win-session", { userMessage: "Windows test" }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]!.cwd).toBe("C:\\Users\\test\\project"); + expect(result.sessions[0]!.sessionId).toBe("win-session"); + }); + + it("filters Windows paths by cwd correctly", async () => { + writeSessionFile("C:\\Users\\test\\projectone", "win-a", { userMessage: "A" }); + writeSessionFile("C:\\Users\\test\\projecttwo", "win-b", { userMessage: "B" }); + + const result = await agent.unstable_listSessions({ cwd: "C:\\Users\\test\\projectone" }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]!.sessionId).toBe("win-a"); + }); + + it("handles mixed Unix and Windows paths", async () => { + writeSessionFile("/Users/test/unixproject", "unix-session", { userMessage: "Unix" }); + writeSessionFile("C:\\Users\\test\\winproject", "win-session", { userMessage: "Windows" }); + + const result = await agent.unstable_listSessions({}); + + expect(result.sessions).toHaveLength(2); + const cwds = result.sessions.map((s) => s.cwd); + expect(cwds).toContain("/Users/test/unixproject"); + expect(cwds).toContain("C:\\Users\\test\\winproject"); + }); + }); +});