Skip to content

Commit 2e67f81

Browse files
committed
feat(ai-chat): let SDK run native Read/Edit/Write, preserve undo
Restructure Phoenix's AI hooks so the Claude Code SDK runs Read, Edit, and Write natively against disk and our PreToolUse + PostToolUse hooks just sync the editor buffer around it. Avoids the SDK's read/mtime tracker tripping on Phoenix-applied edits, which was causing redundant Read+retry cycles after every Edit in the AI panel. PreToolUse: - Read: flush dirty buffer to disk so the SDK reads the live content, return {} so SDK runs native Read and updates its read tracker. - Edit: flush dirty buffer, capture pre-edit content for snapshot tracking, pre-check that old_string still exists in the file (deny with informative reason if the user changed the text since the last Read), otherwise return {}. - Write: flush dirty buffer, capture pre-write content, return {}. PostToolUse (new for Edit and Write): - Skip diff-card painting when input.tool_response indicates the SDK's native tool itself errored (new _isToolResponseError helper). - Edit: try applying old_string -> new_string directly to the open buffer via doc.replaceRange (preserves CodeMirror marks outside the edit region), fall back to refreshDocumentFromDisk only if the buffer no longer contains old_string. - Write: refreshDocumentFromDisk reads new content and pushes it through doc.refreshText. - Trigger aiToolEdit so existing diff-card and snapshot UI keep working. Editor._resetText (used by all refreshText callers, not just AI): - Initial document load still uses setValue + clearHistory. - External-content reload now does an O(n) common prefix/suffix scan and replaceRange's only the differing middle, instead of setValue + clearHistory. Disk-driven reloads (file watchers, git checkout, AI edits) become undoable AND CodeMirror marks at unchanged head/tail survive. markClean still sets the new saved generation so the dirty flag tracks divergence from disk correctly. Also tracks SDK tool_use_id -> our toolCounter so PostToolUse can fire aiToolEdit for the right indicator regardless of phantom partial re-emits.
1 parent 94aa897 commit 2e67f81

2 files changed

Lines changed: 221 additions & 113 deletions

File tree

src-node/claude-code-agent.js

Lines changed: 175 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ let _queuedClarification = null;
7878

7979
const 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" && /<tool_use_error>/i.test(c.text)) {
94+
return true;
95+
}
96+
}
97+
}
98+
}
99+
if (typeof toolResponse === "string" && /<tool_use_error>/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

Comments
 (0)