@@ -78,6 +78,30 @@ let _queuedClarification = null;
7878
7979const nodeConnector = global . createNodeConnector ( CONNECTOR_ID , exports ) ;
8080
81+ /**
82+ * Detect whether a PostToolUse `tool_response` represents an error result.
83+ * Used to suppress diff-card painting when the SDK's native Edit/Write itself
84+ * failed (e.g. oldText not found on disk). The shape of tool_response is
85+ * `unknown` per the SDK types — handle the common variants defensively.
86+ */
87+ function _isToolResponseError ( toolResponse ) {
88+ if ( ! toolResponse ) { return false ; }
89+ if ( typeof toolResponse === "object" ) {
90+ if ( toolResponse . is_error === true || toolResponse . isError === true ) { return true ; }
91+ if ( Array . isArray ( toolResponse . content ) ) {
92+ for ( const c of toolResponse . content ) {
93+ if ( c && typeof c . text === "string" && / < t o o l _ u s e _ e r r o r > / i. test ( c . text ) ) {
94+ return true ;
95+ }
96+ }
97+ }
98+ }
99+ if ( typeof toolResponse === "string" && / < t o o l _ u s e _ e r r o r > / i. test ( toolResponse ) ) {
100+ return true ;
101+ }
102+ return false ;
103+ }
104+
81105/**
82106 * Lazily import the ESM @anthropic-ai/claude-code module.
83107 */
@@ -582,91 +606,67 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
582606 }
583607 } ;
584608 }
585- const myToolId = toolCounter ; // capture before any await
586- const edit = {
587- file : input . tool_input . file_path ,
588- oldText : input . tool_input . old_string ,
589- newText : input . tool_input . new_string ,
590- replaceAll : input . tool_input . replace_all === true
591- } ;
592- editCount ++ ;
593- let editResult ;
609+ // New flow: flush dirty buffer to disk so SDK reads
610+ // the latest content, capture pre- edit content for
611+ // snapshot tracking, then return {} so SDK runs
612+ // native Edit on disk. Its mtime/read tracker stays
613+ // consistent and the next Edit won't trip the
614+ // "modified since read" safety check.
615+ const filePath = input . tool_input . file_path ;
616+ const oldString = input . tool_input . old_string ;
617+ let captured = { content : "" } ;
594618 try {
595- editResult = await nodeConnector . execPeer ( "applyEditToBuffer" , edit ) ;
619+ await nodeConnector . execPeer ( "saveBufferToDisk" , { filePath } ) ;
620+ captured = await nodeConnector . execPeer (
621+ "captureFileContent" , { filePath } ) || captured ;
596622 } catch ( err ) {
597- console . warn ( "[Phoenix AI] Failed to apply edit to buffer:" , err . message ) ;
598- editResult = { applied : false , error : err . message } ;
623+ console . warn ( "[Phoenix AI] Edit prep failed:" , filePath , err . message ) ;
599624 }
600- nodeConnector . triggerPeer ( "aiToolEdit" , {
601- requestId : requestId ,
602- toolId : myToolId ,
603- edit : edit
604- } ) ;
605- let reason ;
606- if ( editResult && editResult . applied === false ) {
607- reason = "Edit FAILED: " + ( editResult . error || "unknown error" ) ;
608- } else {
609- reason = "Edit applied successfully via Phoenix editor." ;
610- if ( editResult && editResult . isLivePreviewRelated ) {
611- reason += " The edited file is part of the active live preview." +
612- " Reload when ready with execJsInLivePreview: `location.reload()`" ;
625+ // Pre-check: if the text to replace is no longer in
626+ // the file (user typed/changed it since the last
627+ // Read), deny with an informative reason instead of
628+ // letting the SDK fail with a generic "oldText not
629+ // found". Phoenix sees the buffer state the SDK
630+ // can't, so this is a more useful failure.
631+ if ( oldString && ( captured . content || "" ) . indexOf ( oldString ) === - 1 ) {
632+ let reason = "Edit FAILED: the text you wanted to replace is not " +
633+ "present in the file. It may have been modified by the user " +
634+ "or by another tool since you last read it. Read the file again " +
635+ "to see the current content before retrying." ;
636+ if ( _queuedClarification ) {
637+ reason += CLARIFICATION_HINT ;
613638 }
639+ return {
640+ hookSpecificOutput : {
641+ hookEventName : "PreToolUse" ,
642+ permissionDecision : "deny" ,
643+ permissionDecisionReason : reason
644+ }
645+ } ;
614646 }
615- if ( _queuedClarification ) {
616- reason += CLARIFICATION_HINT ;
617- }
618- return {
619- hookSpecificOutput : {
620- hookEventName : "PreToolUse" ,
621- permissionDecision : "deny" ,
622- permissionDecisionReason : reason
623- }
624- } ;
647+ editCount ++ ;
648+ return { } ;
625649 }
626650 ]
627651 } ,
628652 {
629653 matcher : "Read" ,
630654 hooks : [
631655 async ( input ) => {
632- if ( ! input || ! input . tool_input ) {
633- return { } ;
634- }
635- const filePath = input . tool_input . file_path ;
636- if ( ! filePath ) {
656+ if ( ! input || ! input . tool_input || ! input . tool_input . file_path ) {
637657 return { } ;
638658 }
659+ // Flush dirty buffer to disk so the SDK's native
660+ // Read sees what the user is actually looking at.
661+ // Returning {} lets the SDK run native Read so its
662+ // read-tracker updates — required to avoid "file
663+ // not read yet" rejections on subsequent edits.
639664 try {
640- const result = await nodeConnector . execPeer ( "getFileContent" , { filePath } ) ;
641- if ( result && result . isDirty && result . content !== null ) {
642- const MAX_LINES = 2000 ;
643- const MAX_LINE_LENGTH = 2000 ;
644- const lines = result . content . split ( "\n" ) ;
645- const offset = input . tool_input . offset || 0 ;
646- const limit = input . tool_input . limit || MAX_LINES ;
647- const selected = lines . slice ( offset , offset + limit ) ;
648- let formatted = selected . map ( ( line , i ) => {
649- const truncated = line . length > MAX_LINE_LENGTH
650- ? line . slice ( 0 , MAX_LINE_LENGTH ) + "..."
651- : line ;
652- return String ( offset + i + 1 ) . padStart ( 6 ) + "\t" + truncated ;
653- } ) . join ( "\n" ) ;
654- formatted = filePath + " (" +
655- lines . length + " lines total)\n\n" + formatted ;
656- console . log ( "[Phoenix AI] Serving dirty file content for:" , filePath ) ;
657- if ( _queuedClarification ) {
658- formatted += CLARIFICATION_HINT ;
659- }
660- return {
661- hookSpecificOutput : {
662- hookEventName : "PreToolUse" ,
663- permissionDecision : "deny" ,
664- permissionDecisionReason : formatted
665- }
666- } ;
667- }
665+ await nodeConnector . execPeer ( "saveBufferToDisk" ,
666+ { filePath : input . tool_input . file_path } ) ;
668667 } catch ( err ) {
669- console . warn ( "[Phoenix AI] Failed to check dirty state:" , filePath , err . message ) ;
668+ console . warn ( "[Phoenix AI] Read prep failed:" ,
669+ input . tool_input . file_path , err . message ) ;
670670 }
671671 return { } ;
672672 }
@@ -708,45 +708,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
708708 }
709709 } ;
710710 }
711- const myToolId = toolCounter ; // capture before any await
712- const edit = {
713- file : input . tool_input . file_path ,
714- oldText : null ,
715- newText : input . tool_input . content
716- } ;
717- editCount ++ ;
718- let writeResult ;
711+ // Mirror Edit: flush dirty buffer, capture pre-write
712+ // content, return {} so SDK writes natively.
713+ const filePath = input . tool_input . file_path ;
719714 try {
720- writeResult = await nodeConnector . execPeer ( "applyEditToBuffer" , edit ) ;
715+ await nodeConnector . execPeer ( "saveBufferToDisk" , { filePath } ) ;
716+ await nodeConnector . execPeer ( "captureFileContent" , { filePath } ) ;
721717 } catch ( err ) {
722- console . warn ( "[Phoenix AI] Failed to apply write to buffer:" , err . message ) ;
723- writeResult = { applied : false , error : err . message } ;
724- }
725- nodeConnector . triggerPeer ( "aiToolEdit" , {
726- requestId : requestId ,
727- toolId : myToolId ,
728- edit : edit
729- } ) ;
730- let reason ;
731- if ( writeResult && writeResult . applied === false ) {
732- reason = "Write FAILED: " + ( writeResult . error || "unknown error" ) ;
733- } else {
734- reason = "Write applied successfully via Phoenix editor." ;
735- if ( writeResult && writeResult . isLivePreviewRelated ) {
736- reason += " The written file is part of the active live preview." +
737- " Reload when ready with execJsInLivePreview: `location.reload()`" ;
738- }
739- }
740- if ( _queuedClarification ) {
741- reason += CLARIFICATION_HINT ;
718+ console . warn ( "[Phoenix AI] Write prep failed:" , filePath , err . message ) ;
742719 }
743- return {
744- hookSpecificOutput : {
745- hookEventName : "PreToolUse" ,
746- permissionDecision : "deny" ,
747- permissionDecisionReason : reason
748- }
749- } ;
720+ editCount ++ ;
721+ return { } ;
750722 }
751723 ]
752724 } ,
@@ -834,6 +806,104 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
834806 }
835807 ]
836808 }
809+ ] ,
810+ PostToolUse : [
811+ {
812+ matcher : "Edit" ,
813+ hooks : [
814+ async ( input , toolUseID ) => {
815+ const filePath = input && input . tool_input && input . tool_input . file_path ;
816+ if ( ! filePath ) { return { } ; }
817+ // Plan files don't go through the editor
818+ if ( filePath . replace ( / \\ / g, "/" ) . includes ( "/.claude/plans/" ) ) {
819+ return { } ;
820+ }
821+ // If the SDK's native Edit itself failed (e.g.
822+ // oldText not found on disk), don't paint a diff
823+ // card. The existing aiToolResult flow will
824+ // classify the indicator from the tool_result.
825+ if ( _isToolResponseError ( input . tool_response ) ) {
826+ return { } ;
827+ }
828+ const editPayload = {
829+ file : filePath ,
830+ oldText : input . tool_input . old_string ,
831+ newText : input . tool_input . new_string ,
832+ replaceAll : input . tool_input . replace_all === true
833+ } ;
834+ // 1. Prefer applying the edit directly to the open
835+ // buffer via doc.replaceRange — preserves
836+ // CodeMirror marks outside the edit region (live
837+ // preview HTML element marks). Falls back to a
838+ // full refreshDocumentFromDisk if no doc is open
839+ // or the buffer no longer contains old_string
840+ // (e.g. user typed since save).
841+ let result = { } ;
842+ try {
843+ result = await nodeConnector . execPeer (
844+ "applyEditToOpenBufferOnly" , editPayload ) || { } ;
845+ } catch ( err ) {
846+ console . warn ( "[Phoenix AI] applyEditToOpenBufferOnly failed:" , filePath , err . message ) ;
847+ }
848+ if ( ! result . applied ) {
849+ try {
850+ result = await nodeConnector . execPeer (
851+ "refreshDocumentFromDisk" , { filePath } ) || result ;
852+ } catch ( err ) {
853+ console . warn ( "[Phoenix AI] Edit refresh fallback failed:" , filePath , err . message ) ;
854+ }
855+ }
856+ // 2. Trigger aiToolEdit so the AI panel renders the
857+ // diff card and the snapshot store records it.
858+ const counterId = _toolUseIdToCounter [ toolUseID ] ;
859+ if ( counterId !== undefined ) {
860+ editPayload . isLivePreviewRelated = ! ! result . isLivePreviewRelated ;
861+ nodeConnector . triggerPeer ( "aiToolEdit" , {
862+ requestId : requestId ,
863+ toolId : counterId ,
864+ edit : editPayload
865+ } ) ;
866+ }
867+ return { } ;
868+ }
869+ ]
870+ } ,
871+ {
872+ matcher : "Write" ,
873+ hooks : [
874+ async ( input , toolUseID ) => {
875+ const filePath = input && input . tool_input && input . tool_input . file_path ;
876+ if ( ! filePath ) { return { } ; }
877+ if ( filePath . replace ( / \\ / g, "/" ) . includes ( "/.claude/plans/" ) ) {
878+ return { } ;
879+ }
880+ if ( _isToolResponseError ( input . tool_response ) ) {
881+ return { } ;
882+ }
883+ let refreshResult = { } ;
884+ try {
885+ refreshResult = await nodeConnector . execPeer (
886+ "refreshDocumentFromDisk" , { filePath } ) || { } ;
887+ } catch ( err ) {
888+ console . warn ( "[Phoenix AI] Write refresh failed:" , filePath , err . message ) ;
889+ }
890+ const counterId = _toolUseIdToCounter [ toolUseID ] ;
891+ if ( counterId !== undefined ) {
892+ nodeConnector . triggerPeer ( "aiToolEdit" , {
893+ requestId : requestId ,
894+ toolId : counterId ,
895+ edit : {
896+ file : filePath ,
897+ oldText : null ,
898+ newText : input . tool_input . content ,
899+ isLivePreviewRelated : ! ! refreshResult . isLivePreviewRelated
900+ }
901+ } ) ;
902+ }
903+ return { } ;
904+ }
905+ ]
906+ }
837907 ]
838908 }
839909 } ;
0 commit comments