@@ -24,7 +24,6 @@ import {
2424 ResumeSessionRequest ,
2525 ResumeSessionResponse ,
2626 SessionConfigOption ,
27- SessionInfo ,
2827 SessionModelState ,
2928 SessionNotification ,
3029 SetSessionConfigOptionRequest ,
@@ -42,6 +41,8 @@ import {
4241import { SettingsManager } from "./settings.js" ;
4342import {
4443 CanUseTool ,
44+ getSessionMessages ,
45+ listSessions ,
4546 McpServerConfig ,
4647 ModelInfo ,
4748 Options ,
@@ -70,15 +71,8 @@ import {
7071} from "@anthropic-ai/claude-agent-sdk" ;
7172import * as fs from "node:fs" ;
7273import * as path from "node:path" ;
73- import * as readline from "node:readline" ;
7474import * 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" ;
8276import {
8377 toolInfoFromToolUse ,
8478 planEntries ,
@@ -118,11 +112,7 @@ type SDKMessageTemp =
118112export 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
127117function 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-
167146type 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