Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion phoenix-builder-mcp/mcp-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,16 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe
const totalEntries = result.totalEntries || entries.length;
const matchedEntries = result.matchedEntries != null ? result.matchedEntries : entries.length;
const rangeEnd = result.rangeEnd != null ? result.rangeEnd : matchedEntries;
let lines = entries.map(e => `[${e.level}] ${e.message}`);
let lines = entries.map(e => {
let ts = "";
if (e.timestamp) {
// Show HH:MM:SS.mmm for compact display
const d = new Date(e.timestamp);
ts = d.toTimeString().slice(0, 8) + "." +
String(d.getMilliseconds()).padStart(3, "0") + " ";
}
return `[${ts}${e.level}] ${e.message}`;
});
let trimmed = 0;
if (maxChars > 0) {
const trimResult = _trimToCharBudget(lines, maxChars);
Expand Down
77 changes: 56 additions & 21 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ exports.checkAvailability = async function () {
* Called from browser via execPeer("sendPrompt", {prompt, projectPath, sessionAction, model}).
*
* Returns immediately with a requestId. Results are sent as events:
* aiProgress, aiTextStream, aiEditResult, aiError, aiComplete
* aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete
*/
exports.sendPrompt = async function (params) {
const { prompt, projectPath, sessionAction, model } = params;
Expand Down Expand Up @@ -177,7 +177,8 @@ exports.destroySession = async function () {
* Internal: run a Claude SDK query and stream results back to the browser.
*/
async function _runQuery(requestId, prompt, projectPath, model, signal) {
const collectedEdits = [];
let editCount = 0;
let toolCounter = 0;
let queryFn;

try {
Expand All @@ -202,6 +203,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
maxTurns: 10,
allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"],
permissionMode: "acceptEdits",
appendSystemPrompt:
"When modifying an existing file, always prefer the Edit tool " +
"(find-and-replace) instead of the Write tool. The Write tool should ONLY be used " +
"to create brand new files that do not exist yet. For existing files, always use " +
"multiple Edit calls to make targeted changes rather than rewriting the entire " +
"file with Write. This is critical because Write replaces the entire file content " +
"which is slow and loses undo history.",
includePartialMessages: true,
abortController: currentAbortController,
hooks: {
Expand All @@ -211,17 +219,23 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
hooks: [
async (input) => {
console.log("[Phoenix AI] Intercepted Edit tool");
const myToolId = toolCounter; // capture before any await
const edit = {
file: input.tool_input.file_path,
oldText: input.tool_input.old_string,
newText: input.tool_input.new_string
};
collectedEdits.push(edit);
editCount++;
try {
await nodeConnector.execPeer("applyEditToBuffer", edit);
} catch (err) {
console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message);
}
nodeConnector.triggerPeer("aiToolEdit", {
requestId: requestId,
toolId: myToolId,
edit: edit
});
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
Expand Down Expand Up @@ -255,7 +269,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
: line;
return String(offset + i + 1).padStart(6) + "\t" + truncated;
}).join("\n");
formatted = filePath + " (unsaved editor content, " +
formatted = filePath + " (" +
lines.length + " lines total)\n\n" + formatted;
console.log("[Phoenix AI] Serving dirty file content for:", filePath);
return {
Expand All @@ -278,17 +292,23 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
hooks: [
async (input) => {
console.log("[Phoenix AI] Intercepted Write tool");
const myToolId = toolCounter; // capture before any await
const edit = {
file: input.tool_input.file_path,
oldText: null,
newText: input.tool_input.content
};
collectedEdits.push(edit);
editCount++;
try {
await nodeConnector.execPeer("applyEditToBuffer", edit);
} catch (err) {
console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message);
}
nodeConnector.triggerPeer("aiToolEdit", {
requestId: requestId,
toolId: myToolId,
edit: edit
});
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
Expand Down Expand Up @@ -318,7 +338,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
queryOptions.resume = currentSessionId;
}

const _log = (...args) => console.log("[AI]", ...args);

try {
_log("Query start:", JSON.stringify(prompt).slice(0, 80), "cwd=" + (projectPath || "?"));

const result = queryFn({
prompt: prompt,
options: queryOptions
Expand All @@ -331,18 +355,25 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
let activeToolName = null;
let activeToolIndex = null;
let activeToolInputJson = "";
let toolCounter = 0;
let lastToolStreamTime = 0;

// Trace counters (logged at tool/query completion, not per-delta)
let toolDeltaCount = 0;
let toolStreamSendCount = 0;
let textDeltaCount = 0;
let textStreamSendCount = 0;

for await (const message of result) {
// Check abort
if (signal.aborted) {
_log("Aborted");
break;
}

// Capture session_id from first message
if (message.session_id && !currentSessionId) {
currentSessionId = message.session_id;
_log("Session:", currentSessionId);
}

// Handle streaming events
Expand All @@ -356,6 +387,10 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
activeToolIndex = event.index;
activeToolInputJson = "";
toolCounter++;
toolDeltaCount = 0;
toolStreamSendCount = 0;
lastToolStreamTime = 0;
_log("Tool start:", activeToolName, "#" + toolCounter);
nodeConnector.triggerPeer("aiProgress", {
requestId: requestId,
toolName: activeToolName,
Expand All @@ -369,9 +404,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
event.delta?.type === "input_json_delta" &&
event.index === activeToolIndex) {
activeToolInputJson += event.delta.partial_json;
toolDeltaCount++;
const now = Date.now();
if (now - lastToolStreamTime >= TEXT_STREAM_THROTTLE_MS) {
if (activeToolInputJson &&
now - lastToolStreamTime >= TEXT_STREAM_THROTTLE_MS) {
lastToolStreamTime = now;
toolStreamSendCount++;
nodeConnector.triggerPeer("aiToolStream", {
requestId: requestId,
toolId: toolCounter,
Expand All @@ -387,6 +425,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
activeToolName) {
// Final flush of tool stream (bypasses throttle)
if (activeToolInputJson) {
toolStreamSendCount++;
nodeConnector.triggerPeer("aiToolStream", {
requestId: requestId,
toolId: toolCounter,
Expand All @@ -400,6 +439,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
} catch (e) {
// ignore parse errors
}
_log("Tool done:", activeToolName, "#" + toolCounter,
"deltas=" + toolDeltaCount, "sent=" + toolStreamSendCount,
"json=" + activeToolInputJson.length + "ch");
nodeConnector.triggerPeer("aiToolInfo", {
requestId: requestId,
toolName: activeToolName,
Expand All @@ -415,9 +457,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
if (event.type === "content_block_delta" &&
event.delta?.type === "text_delta") {
accumulatedText += event.delta.text;
textDeltaCount++;
const now = Date.now();
if (now - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) {
lastStreamTime = now;
textStreamSendCount++;
nodeConnector.triggerPeer("aiTextStream", {
requestId: requestId,
text: accumulatedText
Expand All @@ -430,19 +474,15 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {

// Flush any remaining accumulated text
if (accumulatedText) {
textStreamSendCount++;
nodeConnector.triggerPeer("aiTextStream", {
requestId: requestId,
text: accumulatedText
});
}

// Send collected edits if any
if (collectedEdits.length > 0) {
nodeConnector.triggerPeer("aiEditResult", {
requestId: requestId,
edits: collectedEdits
});
}
_log("Complete: tools=" + toolCounter, "edits=" + editCount,
"textDeltas=" + textDeltaCount, "textSent=" + textStreamSendCount);

// Signal completion
nodeConnector.triggerPeer("aiComplete", {
Expand All @@ -455,6 +495,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
const isAbort = signal.aborted || /abort/i.test(errMsg);

if (isAbort) {
_log("Cancelled");
// Query was cancelled — clear session so next query starts fresh
currentSessionId = null;
nodeConnector.triggerPeer("aiComplete", {
Expand All @@ -464,13 +505,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) {
return;
}

// If we collected edits before error, send them
if (collectedEdits.length > 0) {
nodeConnector.triggerPeer("aiEditResult", {
requestId: requestId,
edits: collectedEdits
});
}
_log("Error:", errMsg.slice(0, 200));

nodeConnector.triggerPeer("aiError", {
requestId: requestId,
Expand Down
Loading
Loading