Skip to content

Commit 5ede2b7

Browse files
authored
Use history methods from the SDK (agentclientprotocol#352)
1 parent 988a741 commit 5ede2b7

4 files changed

Lines changed: 32 additions & 1210 deletions

File tree

src/acp-agent.ts

Lines changed: 32 additions & 287 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
ResumeSessionRequest,
2525
ResumeSessionResponse,
2626
SessionConfigOption,
27-
SessionInfo,
2827
SessionModelState,
2928
SessionNotification,
3029
SetSessionConfigOptionRequest,
@@ -42,6 +41,8 @@ import {
4241
import { SettingsManager } from "./settings.js";
4342
import {
4443
CanUseTool,
44+
getSessionMessages,
45+
listSessions,
4546
McpServerConfig,
4647
ModelInfo,
4748
Options,
@@ -70,15 +71,8 @@ import {
7071
} from "@anthropic-ai/claude-agent-sdk";
7172
import * as fs from "node:fs";
7273
import * as path from "node:path";
73-
import * as readline from "node:readline";
7474
import * as os from "node:os";
75-
import {
76-
encodeProjectPath,
77-
nodeToWebReadable,
78-
nodeToWebWritable,
79-
Pushable,
80-
unreachable,
81-
} from "./utils.js";
75+
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
8276
import {
8377
toolInfoFromToolUse,
8478
planEntries,
@@ -118,11 +112,7 @@ type SDKMessageTemp =
118112
export const CLAUDE_CONFIG_DIR =
119113
process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
120114

121-
function sessionFilePath(cwd: string, sessionId: string): string {
122-
return path.join(CLAUDE_CONFIG_DIR, "projects", encodeProjectPath(cwd), `${sessionId}.jsonl`);
123-
}
124-
125-
const MAX_TITLE_LENGTH = 128;
115+
const MAX_TITLE_LENGTH = 256;
126116

127117
function sanitizeTitle(text: string): string {
128118
// Replace newlines and collapse whitespace
@@ -153,17 +143,6 @@ type Session = {
153143
configOptions: SessionConfigOption[];
154144
};
155145

156-
type SessionHistoryEntry = {
157-
type?: string;
158-
isSidechain?: boolean;
159-
sessionId?: string;
160-
message?: {
161-
role?: string;
162-
content?: unknown;
163-
model?: string;
164-
};
165-
};
166-
167146
type BackgroundTerminal =
168147
| {
169148
handle: TerminalHandle;
@@ -374,52 +353,7 @@ export class ClaudeAcpAgent implements Agent {
374353
return response;
375354
}
376355

377-
/**
378-
* Find a session file by ID, first checking the given cwd's project directory,
379-
* then falling back to scanning all project directories.
380-
* Returns the absolute file path if found, or null if not found.
381-
*/
382-
private async findSessionFile(sessionId: string, cwd: string): Promise<string | null> {
383-
const fileName = `${sessionId}.jsonl`;
384-
385-
// Fast path: check the expected location based on cwd
386-
const expectedPath = sessionFilePath(cwd, sessionId);
387-
try {
388-
await fs.promises.access(expectedPath);
389-
return expectedPath;
390-
} catch {
391-
// Not found at expected path, scan all project directories
392-
}
393-
394-
const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
395-
try {
396-
const projectDirs = await fs.promises.readdir(claudeDir);
397-
for (const encodedPath of projectDirs) {
398-
const projectDir = path.join(claudeDir, encodedPath);
399-
const stat = await fs.promises.stat(projectDir);
400-
if (!stat.isDirectory()) continue;
401-
402-
const candidatePath = path.join(projectDir, fileName);
403-
try {
404-
await fs.promises.access(candidatePath);
405-
return candidatePath;
406-
} catch {
407-
continue;
408-
}
409-
}
410-
} catch {
411-
// projects directory doesn't exist or isn't readable
412-
}
413-
414-
return null;
415-
}
416-
417356
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
418-
const filePath = await this.findSessionFile(params.sessionId, params.cwd);
419-
if (!filePath) {
420-
throw new Error("Session not found");
421-
}
422-
423357
const response = await this.createSession(
424358
{
425359
cwd: params.cwd,
@@ -431,7 +365,7 @@ export class ClaudeAcpAgent implements Agent {
431365
},
432366
);
433367

434-
await this.replaySessionHistory(params.sessionId, filePath);
368+
await this.replaySessionHistory(params.sessionId);
435369

436370
// Send available commands after replay so it doesn't interleave with history
437371
setTimeout(() => {
@@ -445,167 +379,22 @@ export class ClaudeAcpAgent implements Agent {
445379
};
446380
}
447381

448-
/**
449-
* List Claude Code sessions by parsing JSONL files
450-
* Sessions are stored in ~/.claude/projects/<path-encoded>/
451-
* Implements the draft session/list RFD spec
452-
*/
453382
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
454-
// Note: We load all sessions into memory for sorting, so pagination here is for
455-
// API response size limits rather than memory efficiency. This matches the RFD spec.
456-
const PAGE_SIZE = 50;
457-
const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
458-
459-
try {
460-
await fs.promises.access(claudeDir);
461-
} catch {
462-
return { sessions: [] };
463-
}
464-
465-
// Collect all sessions across all project directories
466-
const allSessions: SessionInfo[] = [];
467-
const encodedCwdFilter = params.cwd ? encodeProjectPath(params.cwd) : null;
468-
469-
try {
470-
const projectDirs = await fs.promises.readdir(claudeDir);
471-
472-
for (const encodedPath of projectDirs) {
473-
const projectDir = path.join(claudeDir, encodedPath);
474-
const stat = await fs.promises.stat(projectDir);
475-
if (!stat.isDirectory()) continue;
476-
477-
// Path encoding is not always reversible (hyphens can be separators or literals),
478-
// so only use encoded value as a coarse pre-filter.
479-
if (encodedCwdFilter && encodedPath !== encodedCwdFilter) continue;
480-
481-
const files = await fs.promises.readdir(projectDir);
482-
// Filter to user session files only. Skip agent-*.jsonl files which contain
483-
// internal agent metadata and system logs, not user-visible conversation sessions.
484-
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
485-
486-
for (const file of jsonlFiles) {
487-
const filePath = path.join(projectDir, file);
488-
try {
489-
const content = await fs.promises.readFile(filePath, "utf-8");
490-
const lines = content.trim().split("\n").filter(Boolean);
491-
492-
const sessionId = file.replace(".jsonl", "");
493-
let parsedAnyEntry = false;
494-
let sessionCwd: string | undefined;
495-
496-
// Find first user message for title
497-
let title: string | undefined;
498-
for (const line of lines) {
499-
try {
500-
const entry = JSON.parse(line);
501-
parsedAnyEntry = true;
502-
if (entry.isSidechain === true) {
503-
continue;
504-
}
505-
const entrySessionId =
506-
typeof entry.sessionId === "string" ? entry.sessionId : undefined;
507-
if (typeof entry.sessionId === "string" && entry.sessionId !== entrySessionId) {
508-
continue;
509-
}
510-
if (typeof entry.cwd === "string") {
511-
sessionCwd = entry.cwd;
512-
}
513-
if (!title && entry.type === "user" && entry.message?.content) {
514-
const msgContent = entry.message.content;
515-
if (typeof msgContent === "string") {
516-
title = sanitizeTitle(msgContent);
517-
}
518-
if (Array.isArray(msgContent) && msgContent.length > 0) {
519-
const first = msgContent[0];
520-
const text =
521-
typeof first === "string"
522-
? first
523-
: first && typeof first === "object" && typeof first.text === "string"
524-
? first.text
525-
: undefined;
526-
if (text) {
527-
title = sanitizeTitle(text);
528-
}
529-
}
530-
}
531-
532-
// Continue scanning until we have both fields, since cwd can appear
533-
// in later entries even after the first user title-bearing message.
534-
if (title && sessionCwd) {
535-
break;
536-
}
537-
} catch {
538-
// Skip malformed lines
539-
}
540-
}
541-
if (!parsedAnyEntry) continue;
542-
543-
// SessionInfo.cwd is currently required. For entries that do not
544-
// include an explicit cwd in the session JSONL (typically metadata-only files),
545-
// we skip them instead of decoding folder names because path encoding is lossy.
546-
if (!sessionCwd) continue;
547-
548-
// Even after encoded-path pre-filtering, verify per-entry cwd to disambiguate
549-
// collisions such as "/a-b" and "/a/b" that map to the same encoded folder name.
550-
if (params.cwd && sessionCwd !== params.cwd) continue;
551-
552-
// Get file modification time as updatedAt
553-
const fileStat = await fs.promises.stat(filePath);
554-
const updatedAt = fileStat.mtime.toISOString();
555-
556-
allSessions.push({
557-
sessionId,
558-
cwd: sessionCwd,
559-
title: title ?? null,
560-
updatedAt,
561-
});
562-
} catch (err) {
563-
this.logger.error(
564-
`[unstable_listSessions] Failed to parse session file: ${filePath}`,
565-
err,
566-
);
567-
}
568-
}
569-
}
570-
} catch (err) {
571-
this.logger.error("[unstable_listSessions] Failed to list sessions", err);
572-
return { sessions: [] };
573-
}
574-
575-
// Sort by updatedAt descending (most recent first)
576-
allSessions.sort((a, b) => {
577-
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
578-
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
579-
return timeB - timeA;
580-
});
581-
582-
// Handle pagination with cursor
583-
let startIndex = 0;
584-
if (params.cursor) {
585-
try {
586-
const decoded = Buffer.from(params.cursor, "base64").toString("utf-8");
587-
const cursorData = JSON.parse(decoded);
588-
startIndex = cursorData.offset ?? 0;
589-
} catch {
590-
// Invalid cursor, start from beginning
591-
}
383+
const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
384+
const sessions = [];
385+
386+
for (const session of sdk_sessions) {
387+
if (!session.cwd) continue;
388+
sessions.push({
389+
sessionId: session.sessionId,
390+
cwd: session.cwd,
391+
title: sanitizeTitle(session.summary),
392+
updatedAt: new Date(session.lastModified).toISOString(),
393+
});
592394
}
593-
594-
const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE);
595-
const hasMore = startIndex + PAGE_SIZE < allSessions.length;
596-
597-
const response: ListSessionsResponse = {
598-
sessions: pageOfSessions,
395+
return {
396+
sessions,
599397
};
600-
601-
if (hasMore) {
602-
const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString(
603-
"base64",
604-
);
605-
response.nextCursor = nextCursor;
606-
}
607-
608-
return response;
609398
}
610399

611400
async authenticate(_params: AuthenticateRequest): Promise<void> {
@@ -895,67 +684,23 @@ export class ClaudeAcpAgent implements Agent {
895684
}
896685
}
897686

898-
private async replaySessionHistory(sessionId: string, filePath: string): Promise<void> {
687+
private async replaySessionHistory(sessionId: string): Promise<void> {
899688
const toolUseCache: ToolUseCache = {};
900-
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
901-
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
689+
const messages = await getSessionMessages(sessionId);
902690

903-
try {
904-
for await (const line of reader) {
905-
const trimmed = line.trim();
906-
if (!trimmed) {
907-
continue;
908-
}
909-
910-
let entry: SessionHistoryEntry;
911-
try {
912-
entry = JSON.parse(trimmed) as SessionHistoryEntry;
913-
} catch {
914-
continue;
915-
}
916-
917-
if (entry.type !== "user" && entry.type !== "assistant") {
918-
continue;
919-
}
920-
921-
if (entry.isSidechain) {
922-
continue;
923-
}
924-
925-
if (entry.sessionId && entry.sessionId !== sessionId) {
926-
continue;
927-
}
928-
929-
const message = entry.message;
930-
if (!message) {
931-
continue;
932-
}
933-
934-
const role =
935-
message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null;
936-
if (!role) {
937-
continue;
938-
}
939-
940-
const content = message.content;
941-
if (typeof content !== "string" && !Array.isArray(content)) {
942-
continue;
943-
}
944-
945-
for (const notification of toAcpNotifications(
946-
content,
947-
role,
948-
sessionId,
949-
toolUseCache,
950-
this.client,
951-
this.logger,
952-
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
953-
)) {
954-
await this.client.sessionUpdate(notification);
955-
}
691+
for (const message of messages) {
692+
for (const notification of toAcpNotifications(
693+
// @ts-expect-error - untyped in SDK but we handle all of these
694+
message.message,
695+
message.type,
696+
sessionId,
697+
toolUseCache,
698+
this.client,
699+
this.logger,
700+
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
701+
)) {
702+
await this.client.sessionUpdate(notification);
956703
}
957-
} finally {
958-
reader.close();
959704
}
960705
}
961706

0 commit comments

Comments
 (0)