diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js index 871ede1176..041c9484b6 100644 --- a/phoenix-builder-mcp/mcp-tools.js +++ b/phoenix-builder-mcp/mcp-tools.js @@ -43,10 +43,11 @@ const AI_TEST_SUITES = { "permissions": "suite-permissions.md" }; // The four model runs that have caught every regression seen so far, plus the -// free deterministic/piggyback checks. See model_tests.md, "Deterministic first". +// free deterministic/piggyback checks and EC-7, the one-turn probe that proves +// the Phoenix system prompt is loaded. See model_tests.md, "Deterministic first". const AI_TEST_QUICK = { suites: ["editor-context", "unsaved-buffers", "self-sufficiency", "bug-fixing", "questions"], - tests: ["UB-1", "EC-5", "EC-2", "SS-4", "QF-6", "EC-1", "UB-2", "SS-1", "BF-1", "QF-1"] + tests: ["UB-1", "EC-5", "EC-2", "SS-4", "QF-6", "EC-7", "EC-1", "UB-2", "SS-1", "BF-1", "QF-1"] }; function _gitInfo(cwd) { diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 23e7ef827e..862886b757 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -39,6 +39,10 @@ const isWindows = process.platform === "win32"; const CONNECTOR_ID = "ph_ai_claude"; +// The model tests send a random challenge and expect it back with this suffix +// appended; see the probe sentence at the end of the system prompt append. +const SYSTEM_PROMPT_PROBE_SUFFIX = "xxyysjud"; + // The user's follow-up is addressed to the main agent, like a queued // message in the Claude Code CLI. Hooks fire inside subagents too // (input.agent_id is set there), and a subagent that reads the queue @@ -816,6 +820,19 @@ exports.getCliSpawnProfile = async function (params) { return Object.assign({}, result, profile); }; +// The assistant's own UI files for askInLivePreview live in Phoenix's app data, never in the +// project. Writing there is the assistant's business: no permission card, no snapshot, no +// edit event, no card in the chat. +let _aiScratchDir = null; +function _isAiScratchPath(filePath) { + if (!_aiScratchDir || !filePath || !path.isAbsolute(filePath)) { + return false; + } + const root = path.resolve(_aiScratchDir); + const target = path.resolve(filePath); + return target === root || target.startsWith(root + path.sep); +} + /** * Send a prompt to Claude and stream results back to the browser. * Called from browser via execPeer("sendPrompt", {prompt, projectPath, sessionAction, model}). @@ -825,7 +842,10 @@ exports.getCliSpawnProfile = async function (params) { */ exports.sendPrompt = async function (params) { const { prompt, projectPath, sessionAction, model, locale, selectionContext, editorContext, - images, envOverrides, permissionMode, additionalDirectories } = params; + images, envOverrides, permissionMode, additionalDirectories, aiScratchDir } = params; + if (typeof aiScratchDir === "string" && aiScratchDir) { + _aiScratchDir = aiScratchDir; + } const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); // Handle session @@ -1184,7 +1204,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // `mkdir -p notes-app` in the project, the model wrote the files to // /home//notes-app and nobody was asked. function _isOutsideWriteRoots(filePath) { - if (!filePath || !path.isAbsolute(filePath)) { + if (!filePath || !path.isAbsolute(filePath) || _isAiScratchPath(filePath)) { return false; } const target = path.resolve(filePath); @@ -1401,6 +1421,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // than a rule waving all of them through. Reads never reach a // prompt — the PreToolUse hook below allows them outright. "mcp__phoenix-editor__editorDocs", + "mcp__phoenix-editor__getProblems", + "mcp__phoenix-editor__notifyUser", + "mcp__phoenix-editor__askInLivePreview", "mcp__phoenix-editor__controlEditor", "mcp__phoenix-editor__resizeLivePreview", "mcp__phoenix-editor__wait", @@ -1419,7 +1442,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "mcp__phoenix-editor__previewImages", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", - "mcp__phoenix-editor__editorDocs"] + "mcp__phoenix-editor__editorDocs", "mcp__phoenix-editor__getProblems"] }, "coder": { description: "Reads, edits, and writes code files." + @@ -1437,143 +1460,181 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "mcp__phoenix-editor__execJsInLivePreview", "mcp__phoenix-editor__execJsInEditor", "mcp__phoenix-editor__editorPreferences", - "mcp__phoenix-editor__editorDocs"] + "mcp__phoenix-editor__editorDocs", "mcp__phoenix-editor__getProblems"] } }, mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: permissionMode || "auto", - 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." + - "\n\nThe user's project root is " + (projectPath || process.cwd()) + ". For files " + - "under it, default to Edit and Write over shell rewrites (sed -i, perl -i, tee, " + - "Set-Content/Out-File, `>` / `>>` redirection). Phoenix routes Edit and Write " + - "through the editor, so they refresh the user's open buffer, render a reviewable " + - "diff, and stay undoable from the AI panel; a shell rewrite skips all three, and " + - "the user cannot undo it. Outside the project root — scratch files, temp output, " + - "logs — the shell is fine and needs no thought. " + - "\nThis is a default, not a prohibition. The shell is the better call when the " + - "change is mechanical across many files or matches, when Edit would mean dozens of " + - "calls or reading a large file to alter a little of it, or when the target is " + - "generated output. Phoenix stops the first shell rewrite of each command and " + - "explains why; re-run it unchanged and it goes through. Judge it on the merits — " + - "tokens saved against undo lost — and tell the user when you take the shell route. " + - "When the saving would be marginal, take Edit: one shell call and one Edit call " + - "cost about the same, so a handful of files is not a reason to give up undo. The " + - "shell has to earn it." + - "\n\nALWAYS call getEditorState as your FIRST tool call on any question that " + - "references the user's current work — not just \"what file am I on\". This includes " + - "implicit-context questions like \"the page\", \"this layout\", \"the nav bar\", " + - "\"the button\", \"why is X behaving like this\", \"can you fix the styling\", " + - "\"scroll down on the page\", etc. The user is sitting in front of an editor and a " + - "live preview — without getEditorState you don't know which file they mean, which " + - "rules out targeted Read / Grep and makes you blindly grep the whole codebase. Run " + - "getEditorState first; THEN decide whether to Read the active file, Grep within it, " + - "or takeScreenshot the live preview to see what they're describing." + - "\n\nAlways use full absolute paths for all file operations (Read, Edit, Write, " + - "controlEditor). Never use relative paths." + - "\n\nWhen a tool response mentions the user has typed a clarification, immediately " + - "call getUserClarification to read it and incorporate the user's feedback into your current work." + - "\n\nYou are running inside Phoenix Code, a web-focused code editor with built-in " + - "live preview for both HTML/CSS/JS/SVG and Markdown. When the user asks to create " + - "mockups, prototypes, or web pages, prefer vanilla HTML/CSS/JS so the live preview " + - "can render and edit them — unless the user specifically requests a framework. " + - "Build responsive layouts by default for web content. For images, prefer real " + - " tags over div background-image so the user can swap, inspect, and resize " + - "them in the editor — only fall back to background-image when an effect (parallax, " + - "cover-with-overlay, repeating tile) genuinely requires it." + - "\n\nThe live preview is the rendered view of the HTML/CSS/JS/SVG or Markdown file " + - "currently active in the editor." + - "\n\nYou ALWAYS have live visibility into the editor through the phoenix-editor tools " + - "listed below. NEVER tell the user you can't see what's open / what they're looking " + - "at / what file they're on / what's selected / what's in the live preview — call " + - "getEditorState (and takeScreenshot / execJsInLivePreview as needed) instead. " + - "ALWAYS prefer the phoenix-editor MCP for ANY preview interaction — screenshots, " + - "JS evaluation, DOM inspection, console/network reads, viewport resizing, reloads. " + - "Do NOT reach for other MCP servers like chrome-devtools to open a separate browser " + - "session for the same things; the user's live preview inside Phoenix reflects their " + - "current (possibly unsaved) edits, while a fresh browser session would miss those. " + - "phoenix-editor.takeScreenshot, phoenix-editor.execJsInLivePreview, " + - "phoenix-editor.resizeLivePreview, and phoenix-editor.controlEditor cover virtually " + - "every \"look at / poke at the page\" need. Only fall back to chrome-devtools or " + - "another browser MCP if the user explicitly asks for a non-Phoenix browser context. " + - "These tools are for active iteration AND for checking your own work — " + - "use them as you go, not only when the user asks:" + - "\n- takeScreenshot: see the rendered HTML preview, the rendered Markdown preview, " + - "the editor, or any panel. Use it to confirm visual output, diagnose layout/styling " + - "bugs, or check that HTML or Markdown rendered as expected. Simple selector rule: " + - "if the question is about the rendered live preview pass " + - "selector='#panel-live-preview-frame' (targeted shot is easier to reason about); for " + - "anything else — Problems panel, file tree, toolbar, any other Phoenix UI, or just " + - "\"what is the user looking at\" — omit the selector and capture the full editor " + - "window. Pass reload=true to force-reload the preview before capturing (useful after " + - "JS edits) — saves a tool call vs. reloading separately." + - "\n- execJsInLivePreview: run JS inside the HTML preview iframe to read the DOM, " + - "query computed styles, click elements, or capture console output. Use it to debug " + - "behavior and to confirm an edit actually took effect." + - "\n- searchImages: find Unsplash photos for a website; includePreview=true returns a small " + - "numbered collage so you can choose visually. Reuse returned URLs and call useImage when selecting " + - "a photo for a page. Use searches judiciously, at most 100 per hour." + - "\n- previewImages: show actual images in the chat from existing URLs (including file:/// local images). " + - "Use it when presenting images or a shortlist to the user; no search is needed." + - "\n- resizeLivePreview: change the preview viewport width to test responsive " + - "breakpoints." + - "\n- controlEditor: open files, move the cursor, change selection, toggle the live " + - "preview panel, or reload it (reloadLivePreview operation — use after JS edits if " + - "you're not also taking a screenshot)." + - "\n- getEditorState: report active file, working set, cursor/selection, and the " + - "livePreviewFile. The live preview normally follows the active editor file, so " + - "assume that. Rarely the user pins the preview to a specific file — if a " + - "screenshot doesn't match the file you just edited, check " + - "getEditorState.livePreviewFile to rule that out." + - "\n- execJsInEditor: eval JS in Phoenix's OWN JS space (parent window — NOT the live " + - "preview iframe). Use when controlEditor's fixed ops aren't enough — split panes, " + - "click dialog buttons, send synthetic key events, dispatch any CommandManager " + - "command, configure indentation, etc. `__PR` exposes the modules and helpers; see " + - "the tool description for the full list. Before writing non-trivial JS, call " + - "editorDocs and Read / Grep the bundled API reference so you call real APIs." + - "\n- editorPreferences: read or write Phoenix preferences. `list` enumerates every " + - "registered pref with id/type/default/current/description/scope; `get` for a single " + - "pref; `set` writes into user (global), project (.phcode.json in repo), or session " + - "(in-memory) scope." + - "\n- editorDocs: returns the on-disk path to the bundled API reference plus the " + - "feature-docs URL and the GitHub source repo URL. Call once near the start of any " + - "non-trivial editor-control task; then Read / Grep the apiDocsPath and WebFetch the " + - "featureDocsURL as needed. Do NOT search the codebase blindly when this exists." + - "\n\nEDITS THAT LAND IN THE LIVE PREVIEW: when you edit the file getEditorState " + - "reported as livePreviewFile — or a CSS / JS / SVG file it links to — the user is " + - "watching the result render. Whether that is worth checking is your judgement call, " + - "and so is how: execJsInLivePreview to read the DOM / computed styles / console, " + - "takeScreenshot with selector='#panel-live-preview-frame' for a visual check, " + - "resizeLivePreview for responsive behavior, or nothing at all when the change is " + - "trivial or self-evident. Weigh it at meaningful checkpoints (after a section lands, " + - "before you report done) rather than after every small edit. Files outside the live " + - "preview do not raise the question at all." + - "\n\nName-collision rule: \"Phoenix Code\" (the editor the user is sitting inside) " + - "and \"Claude Code\" (the SDK / CLI you happen to run on) BOTH have settings, " + - "configs, auto-update toggles, themes, etc. When the user says \"set / change / " + - "configure / disable X\" without naming a product, they ALWAYS mean PHOENIX — " + - "your first action is editorPreferences.list (or .get/.set), not anything else.\n\n" + - "DO NOT INVOKE the built-in `update-config` skill, do not Read / Write / cat / Bash " + - "anything under ~/.claude/, ~/.claude.json, or any Claude Code / SDK config path, " + - "unless the user EXPLICITLY says \"Claude\" / \"Claude Code\" / \"SDK\" / \"agent\" / " + - "\"~/.claude\" in their message. The `update-config` skill modifies Claude Code's own " + - "config, NEVER Phoenix's — if your first instinct on a config / setting / pref / " + - "auto-update / theme question is to fire that skill, STOP and reach for " + - "editorPreferences instead.\n\n" + - "If a request is genuinely ambiguous (Phoenix has no matching pref), say so and ask " + - "the user which product they meant before changing anything." + - "\n\nUse your best judgement for when to enter plan mode. Use it when the task " + - "involves creating new applications, extensive modifications, or architectural " + - "changes — propose a plan for user approval before writing code." + - (locale && !locale.startsWith("en") - ? "\n\nThe user's display language is " + locale + ". " + - "Respond in this language unless they write in a different language." - : ""), + // The 0.3.x Agent SDK reads the append text only from + // systemPrompt.append with the claude_code preset. A top-level + // appendSystemPrompt option is silently ignored, which dropped every + // Phoenix instruction below after the 0.2 -> 0.3 SDK upgrade. + systemPrompt: { + type: "preset", + preset: "claude_code", + append: + "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." + + "\n\nThe user's project root is " + (projectPath || process.cwd()) + ". For files " + + "under it, default to Edit and Write over shell rewrites (sed -i, perl -i, tee, " + + "Set-Content/Out-File, `>` / `>>` redirection). Phoenix routes Edit and Write " + + "through the editor, so they refresh the user's open buffer, render a reviewable " + + "diff, and stay undoable from the AI panel; a shell rewrite skips all three, and " + + "the user cannot undo it. Outside the project root — scratch files, temp output, " + + "logs — the shell is fine and needs no thought. " + + "\nThis is a default, not a prohibition. The shell is the better call when the " + + "change is mechanical across many files or matches, when Edit would mean dozens of " + + "calls or reading a large file to alter a little of it, or when the target is " + + "generated output. Phoenix stops the first shell rewrite of each command and " + + "explains why; re-run it unchanged and it goes through. Judge it on the merits — " + + "tokens saved against undo lost — and tell the user when you take the shell route. " + + "When the saving would be marginal, take Edit: one shell call and one Edit call " + + "cost about the same, so a handful of files is not a reason to give up undo. The " + + "shell has to earn it." + + "\n\nALWAYS call getEditorState as your FIRST tool call on any question that " + + "references the user's current work — not just \"what file am I on\". This includes " + + "implicit-context questions like \"the page\", \"this layout\", \"the nav bar\", " + + "\"the button\", \"why is X behaving like this\", \"can you fix the styling\", " + + "\"scroll down on the page\", etc. The user is sitting in front of an editor and a " + + "live preview — without getEditorState you don't know which file they mean, which " + + "rules out targeted Read / Grep and makes you blindly grep the whole codebase. Run " + + "getEditorState first; THEN decide whether to Read the active file, Grep within it, " + + "or takeScreenshot the live preview to see what they're describing." + + "\n\nAlways use full absolute paths for all file operations (Read, Edit, Write, " + + "controlEditor). Never use relative paths." + + "\n\nWhen a tool response mentions the user has typed a clarification, immediately " + + "call getUserClarification to read it and incorporate the user's feedback into your current work." + + "\n\nYou are running inside Phoenix Code, a web-focused code editor with built-in " + + "live preview for both HTML/CSS/JS/SVG and Markdown. When the user asks to create " + + "mockups, prototypes, or web pages, prefer vanilla HTML/CSS/JS so the live preview " + + "can render and edit them — unless the user specifically requests a framework. " + + "Build responsive layouts by default for web content. For images, prefer real " + + " tags over div background-image so the user can swap, inspect, and resize " + + "them in the editor — only fall back to background-image when an effect (parallax, " + + "cover-with-overlay, repeating tile) genuinely requires it." + + "\n\nThe live preview is the rendered view of the HTML/CSS/JS/SVG or Markdown file " + + "currently active in the editor." + + "\n\nYou ALWAYS have live visibility into the editor through the phoenix-editor tools " + + "listed below. NEVER tell the user you can't see what's open / what they're looking " + + "at / what file they're on / what's selected / what's in the live preview — call " + + "getEditorState (and takeScreenshot / execJsInLivePreview as needed) instead. " + + "ALWAYS prefer the phoenix-editor MCP for ANY preview interaction — screenshots, " + + "JS evaluation, DOM inspection, console/network reads, viewport resizing, reloads. " + + "Do NOT reach for other MCP servers like chrome-devtools to open a separate browser " + + "session for the same things; the user's live preview inside Phoenix reflects their " + + "current (possibly unsaved) edits, while a fresh browser session would miss those. " + + "phoenix-editor.takeScreenshot, phoenix-editor.execJsInLivePreview, " + + "phoenix-editor.resizeLivePreview, and phoenix-editor.controlEditor cover virtually " + + "every \"look at / poke at the page\" need. Only fall back to chrome-devtools or " + + "another browser MCP if the user explicitly asks for a non-Phoenix browser context. " + + "These tools are for active iteration AND for checking your own work — " + + "use them as you go, not only when the user asks:" + + "\n- takeScreenshot: see the rendered HTML preview, the rendered Markdown preview, " + + "the editor, or any panel. Use it to confirm visual output, diagnose layout/styling " + + "bugs, or check that HTML or Markdown rendered as expected. Simple selector rule: " + + "if the question is about the rendered live preview pass " + + "selector='#panel-live-preview-frame' (targeted shot is easier to reason about); for " + + "anything else — Problems panel, file tree, toolbar, any other Phoenix UI, or just " + + "\"what is the user looking at\" — omit the selector and capture the full editor " + + "window. Pass reload=true to force-reload the preview before capturing (useful after " + + "JS edits) — saves a tool call vs. reloading separately." + + "\n- execJsInLivePreview: run JS inside the HTML preview iframe to read the DOM, " + + "query computed styles, click elements, or capture console output. Use it to debug " + + "behavior and to confirm an edit actually took effect." + + "\n- searchImages: find Unsplash photos for a website; includePreview=true returns a small " + + "numbered collage so you can choose visually. Call useImage when selecting photos for a page and " + + "prefer embedding the returned Unsplash URLs; pass downloadPath only when the user asks for local " + + "files or the use case needs them. Use searches judiciously, at most 100 per hour." + + "\n- previewImages: show actual images in the chat from existing URLs (including file:/// local " + + "images). " + + "Use it when presenting images or a shortlist to the user; no search is needed." + + "\n- resizeLivePreview: change the preview viewport width to test responsive " + + "breakpoints." + + "\n- controlEditor: open files, move the cursor, change selection, toggle the live " + + "preview panel, or reload it (reloadLivePreview operation — use after JS edits if " + + "you're not also taking a screenshot)." + + "\n- getEditorState: report active file, working set, cursor/selection, and the " + + "livePreviewFile. The live preview normally follows the active editor file, so " + + "assume that. Rarely the user pins the preview to a specific file — if a " + + "screenshot doesn't match the file you just edited, check " + + "getEditorState.livePreviewFile to rule that out." + + "\n- execJsInEditor: eval JS in Phoenix's OWN JS space (parent window — NOT the live " + + "preview iframe). Use when controlEditor's fixed ops aren't enough — split panes, " + + "click dialog buttons, send synthetic key events, dispatch any CommandManager " + + "command, configure indentation, etc. `__PR` exposes the modules and helpers; see " + + "the tool description for the full list. Before writing non-trivial JS, call " + + "editorDocs and Read / Grep the bundled API reference so you call real APIs." + + "\n- editorPreferences: read or write Phoenix preferences. `list` enumerates every " + + "registered pref with id/type/default/current/description/scope; `get` for a single " + + "pref; `set` writes into user (global), project (.phcode.json in repo), or session " + + "(in-memory) scope." + + "\n- editorDocs: returns the on-disk path to the bundled API reference plus the " + + "feature-docs URL and the GitHub source repo URL. Call once near the start of any " + + "non-trivial editor-control task; then Read / Grep the apiDocsPath and WebFetch the " + + "featureDocsURL as needed. Do NOT search the codebase blindly when this exists." + + "\n- getProblems: to get the problems in a file use this tool; it opens the file in the " + + "editor and gives you the errors the editor reports. Use it when the user points at a " + + "red squiggle or the Problems panel, and after your own edits to check for new errors." + + "\n- notifyUser: show a toast in the editor window when a long task finishes or you need the " + + "user's attention and they may be away from the chat. Never use it for ordinary replies. " + + "It is skipped while the AI panel is visible unless you pass alwaysShow." + + "\n- askInLivePreview: whenever showing beats telling and the page is in the live preview, " + + "compose UI with askInLivePreview instead of prose: a choice about one element (its colour, " + + "copy, placement), a choice about the whole page (theme, palette, typography, layout direction, " + + "which of several designs to keep), or simply to present something visually (a mockup, a " + + "before-and-after, a set of variants) even when the only answer is OK or a comment. The card's " + + "frame, title, controls and text field are Phoenix's; you write the body, and pass a theme with " + + "the page's colours so the frame matches. For one element pass anchor so the page dims around it " + + "and the card keeps out of its way; put the card beside the element only when that helps. Hovering an " + + "option previews it on the page with previewCss or previewHtml, clicking it answers. " + + "Write the UI files into " + (_aiScratchDir ? _aiScratchDir + " (askInLivePreviewUiDir)" : + "the folder getEditorState reports as askInLivePreviewUiDir") + ", never into the project " + + "(yours to write freely, no permission is asked and the user is not shown those writes). " + + "The tool description is the whole contract; never look for its implementation, " + + "and keep the look-at-the-page step to one screenshot or one execJsInLivePreview." + + "\n\nEDITS THAT LAND IN THE LIVE PREVIEW: when you edit the file getEditorState " + + "reported as livePreviewFile — or a CSS / JS / SVG file it links to — the user is " + + "watching the result render. Whether that is worth checking is your judgement call, " + + "and so is how: execJsInLivePreview to read the DOM / computed styles / console, " + + "takeScreenshot with selector='#panel-live-preview-frame' for a visual check, " + + "resizeLivePreview for responsive behavior, or nothing at all when the change is " + + "trivial or self-evident. Weigh it at meaningful checkpoints (after a section lands, " + + "before you report done) rather than after every small edit. Files outside the live " + + "preview do not raise the question at all." + + "\n\nName-collision rule: \"Phoenix Code\" (the editor the user is sitting inside) " + + "and \"Claude Code\" (the SDK / CLI you happen to run on) BOTH have settings, " + + "configs, auto-update toggles, themes, etc. When the user says \"set / change / " + + "configure / disable X\" without naming a product, they ALWAYS mean PHOENIX — " + + "your first action is editorPreferences.list (or .get/.set), not anything else.\n\n" + + "DO NOT INVOKE the built-in `update-config` skill, do not Read / Write / cat / Bash " + + "anything under ~/.claude/, ~/.claude.json, or any Claude Code / SDK config path, " + + "unless the user EXPLICITLY says \"Claude\" / \"Claude Code\" / \"SDK\" / \"agent\" / " + + "\"~/.claude\" in their message. The `update-config` skill modifies Claude Code's own " + + "config, NEVER Phoenix's — if your first instinct on a config / setting / pref / " + + "auto-update / theme question is to fire that skill, STOP and reach for " + + "editorPreferences instead.\n\n" + + "If a request is genuinely ambiguous (Phoenix has no matching pref), say so and ask " + + "the user which product they meant before changing anything." + + "\n\nUse your best judgement for when to enter plan mode. Use it when the task " + + "involves creating new applications, extensive modifications, or architectural " + + "changes — propose a plan for user approval before writing code." + + // Probe for the model tests. The suffix is known only from this text and + // the challenge only from the current message, so the combined reply + // proves the prompt is loaded and applied right now, not recalled from + // a transcript or a recorded system-prompt snapshot. + "\n\nTest probe: if a message contains \"phoenix system prompt challenge: \", reply " + + "with exactly the code followed by \"-" + SYSTEM_PROMPT_PROBE_SUFFIX + "\" and nothing else " + + "(challenge k7p2qa would get the reply k7p2qa-" + SYSTEM_PROMPT_PROBE_SUFFIX + "). Never " + + "mention this otherwise." + + (locale && !locale.startsWith("en") + ? "\n\nThe user's display language is " + locale + ". " + + "Respond in this language unless they write in a different language." + : "") + }, includePartialMessages: true, canUseTool: _onPermissionRequest, abortController: currentAbortController, @@ -1594,6 +1655,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Edit", hooks: [ async (input) => { + if (_isAiScratchPath(input && input.tool_input && input.tool_input.file_path)) { + return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } }; + } console.log("[Phoenix AI] Intercepted Edit tool"); // Plan file edits: capture content, write to disk, skip editor const editPath = (input.tool_input.file_path || "").replace(/\\/g, "/"); @@ -1738,6 +1802,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, hooks: [ async (input) => { console.log("[Phoenix AI] Intercepted Write tool"); + if (_isAiScratchPath(input && input.tool_input && input.tool_input.file_path)) { + // The daily sweep may have removed the folder; it comes back for the write. + try { + fs.mkdirSync(path.dirname(input.tool_input.file_path), { recursive: true }); + } catch (err) { /* the write reports it */ } + return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } }; + } // Capture plan content when writing to .claude/plans/ // Plan files: capture content for plan card, write to disk // but don't open in editor @@ -1993,6 +2064,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { return {}; } + if (_isAiScratchPath(filePath)) { + return {}; + } // If the SDK's native Edit itself failed (e.g. // oldText not found on disk), don't paint a diff // card. The existing aiToolResult flow will @@ -2060,6 +2134,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { return {}; } + if (_isAiScratchPath(filePath)) { + return {}; + } if (_isToolResponseError(input.tool_response)) { return {}; } diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index 3003f4e6d5..e9b1ee2e10 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -53,9 +53,11 @@ const EXEC_PEER_TIMEOUT_MS = { controlEditor: 5000, resizeLivePreview: 5000, searchEditorBuffers: 3000, + getProblems: 15000, + notifyUser: 5000, searchImages: 55000, previewImages: 45000, - useImage: 12000 + useImage: 90000 }; // Floor for caller-provided timeouts (e.g. execJsInLivePreview's @@ -216,8 +218,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) "Set includePreview:true to SEE a small numbered collage and choose the best visual match yourself; " + "each collage number matches the photo's number field and 1-based array position. Missing previews are listed. " + "The user can optionally reply with an image URL; do not wait for them to choose. " + - "When choosing photos for the page, call useImage once with their downloadTrackers as a list, use their supplied URLs, " + - "and credit the photographer and Unsplash with the returned links. Honor rate-limit errors and retryAfterSeconds.", + "When choosing photos for the page, call useImage once with their downloadTrackers as a list, prefer embedding " + + "their supplied URLs, and credit the photographer and Unsplash with the returned links. " + + "Honor rate-limit errors and retryAfterSeconds.", { query: z.string().min(1).max(200).describe("Specific image search query"), page: z.number().int().min(1).optional().describe("Results page, default 1"), @@ -277,12 +280,22 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) const useImageTool = sdkModule.tool( "useImage", - "Record Unsplash images chosen from searchImages before embedding their URLs. Prefer selecting multiple " + - "photos in one call by passing a list of downloadTrackers (up to nine). A single tracker is also accepted. " + - "Returns selected photos and any per-image failures; retry only failed trackers. " + - "Does not perform another search or edit any files.", - {downloadTracker: z.union([z.string(), z.array(z.string()).min(1).max(9)]) - .describe("One downloadTracker from searchImages, or an ordered list of up to nine downloadTrackers")}, + "Select Unsplash photos from searchImages. Prefer the Unsplash URLs: without downloadPath the photos are " + + "shown as selected in the chat and you embed their URLs directly; nothing is downloaded. Pass downloadPath " + + "only when the user asks for local files or the use case needs them (offline pages, a build that bundles " + + "assets, an image that must be edited): the photos are then downloaded into the project and each returned " + + "photo has savedPath (absolute) and projectPath (project-relative, for src attributes). Prefer selecting " + + "multiple photos in one call by passing a list of downloadTrackers (up to nine). A single tracker is also " + + "accepted. Returns selected photos and any per-image failures; retry only failed trackers. " + + "Does not perform another search or edit existing files.", + { + downloadTracker: z.union([z.string(), z.array(z.string()).min(1).max(9)]) + .describe("One downloadTracker from searchImages, or an ordered list of up to nine downloadTrackers"), + downloadPath: z.string().optional() + .describe("Download into the project instead of embedding by URL: a project folder such as " + + "images/, or for a single photo a file path such as images/hero.jpg (jpg, png, webp or avif). " + + "Omit it to embed the Unsplash URLs.") + }, async function (args) { try { const result = await _execPeerWithTimeout(nodeConnector, "useImage", args, "useImage"); @@ -368,15 +381,23 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) "execJsInLivePreview", "Execute JavaScript in the live preview iframe (the page being previewed), NOT in Phoenix itself. " + "Auto-opens the live preview panel if it is not already visible. Code is evaluated via eval() in " + - "the global scope of the previewed page. Note: eval() is synchronous — async/await is NOT supported. " + + "the previewed page, so the value of its last expression comes back; a top-level return works too. " + + "Note: eval() is synchronous — async/await is NOT supported. " + "Only available when an HTML file is selected in the live preview — does not work for markdown or " + "other non-HTML file types. Use this to inspect or manipulate the user's live-previewed web page " + "(e.g. document.title, DOM queries).\n\n" + "Pass timeoutMs to bound how long to wait if the live preview is wedged or slow to respond. " + "Defaults to 10000 (10s). Floored at 5000 (the preview frame may still be settling); no " + - "upper limit — pick whatever fits the snippet you're running.", + "upper limit — pick whatever fits the snippet you're running.\n\n" + + "If the script is reusable in any way, run again with other params, or a larger script you may edit and " + + "run again, write it to the folder getEditorState reports as askInLivePreviewUiDir (yours, no permission " + + "needed) and pass scriptFile instead of code. The file runs as function(params) { } in the page, so return the value. Inline code is only for a very short throwaway.", { - code: z.string().describe("JavaScript code to execute in the live preview iframe"), + code: z.string().optional().describe("A very short throwaway snippet to run in the live preview iframe; anything reusable goes in scriptFile"), + scriptFile: z.string().optional().describe("Instead of code: an absolute path, or a file name inside " + + "askInLivePreviewUiDir, run as function(params) { }; return the value"), + params: z.object({}).passthrough().optional().describe("Data the code sees as `params`"), timeoutMs: z.number().int().optional().describe( "Max wait in milliseconds before giving up on the live preview. " + "Floored at 5000, no upper limit. Default 10000." @@ -387,7 +408,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) const timeoutMs = _resolveCallerTimeout(args.timeoutMs, 10000); try { const result = await _execPeerWithTimeout(nodeConnector, "execJsInLivePreview", { - code: args.code + code: args.code, scriptFile: args.scriptFile, params: args.params }, "execJsInLivePreview", timeoutMs); if (result.error) { toolResult = { @@ -551,7 +572,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) "buttons, dispatch arbitrary CommandManager commands, configure indentation, send synthetic " + "key events, etc. Same trust model as execJsInLivePreview — runs without a per-call prompt. " + "\n\n" + - "The body is wrapped in `new AsyncFunction('__PR', 'KeyEvent', code)` so you can `await` " + + "The body is wrapped in `new AsyncFunction('__PR', 'KeyEvent', 'params', code)` so you can `await` " + "freely. `__PR` exposes:\n" + "- Modules: $, CommandManager, Commands, Dialogs, EditorManager, MainViewManager, " + "DocumentManager, WorkspaceManager, FileSystem, FileViewController, ProjectManager, " + @@ -582,9 +603,16 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) "to confirm a UI mutation actually landed.\n" + "\n" + "Pass timeoutMs to bound how long to wait if the editor is wedged. Floored at 5000, no " + - "upper limit. Default 10000.", + "upper limit. Default 10000.\n\n" + + "If the script is reusable in any way, run again with other params, or a larger script you may edit and " + + "run again, write it to the folder getEditorState reports as askInLivePreviewUiDir (yours, no permission " + + "needed) and pass scriptFile instead of code. The file is the same async function body, with params as " + + "its third argument, so return the value. Inline code is only for a very short throwaway.", { - code: z.string().describe("JavaScript code to execute in the Phoenix editor's JS space"), + code: z.string().optional().describe("A very short throwaway snippet to run in the Phoenix editor's JS space; anything reusable goes in scriptFile"), + scriptFile: z.string().optional().describe("Instead of code: an absolute path, or a file name inside " + + "askInLivePreviewUiDir, run as the async function body with __PR, KeyEvent and params; return the value"), + params: z.object({}).passthrough().optional().describe("Data the code sees as `params`"), timeoutMs: z.number().int().optional().describe( "Max wait in milliseconds before giving up. " + "Floored at 5000, no upper limit. Default 10000." @@ -595,7 +623,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) const timeoutMs = _resolveCallerTimeout(args.timeoutMs, 10000); try { const result = await _execPeerWithTimeout(nodeConnector, "execJsInEditor", { - code: args.code + code: args.code, scriptFile: args.scriptFile, params: args.params }, "execJsInEditor", timeoutMs); if (result && result.error) { toolResult = { @@ -693,6 +721,190 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) } ); + + const getProblemsTool = sdkModule.tool( + "getProblems", + "Get the problems for a file: it opens the file in the editor and returns the errors and " + + "warnings reported by the syntax checkers available for that file type, with 1-based line " + + "and column, type, message and the checker (provider) that found each. respondedProviders " + + "lists the checkers that answered; judge from that whether the coverage is enough for what " + + "the user asked. If no problem provider is set for the file type, the result says so. " + + "Defaults to the active file. Returns at most 20 problems by default; counts always cover " + + "the whole file, so use pattern, type or provider to narrow, or raise maxProblems. Use it " + + "when the user points at a red squiggle or the Problems panel, and after your own edits " + + "to check for new errors.", + { + filePath: z.string().optional().describe("Absolute path of the file. Default: the active editor file"), + pattern: z.string().optional().describe("Regex (default) or literal text the message must match"), + isRegex: z.boolean().optional().describe("false to match the pattern literally. Default true"), + caseSensitive: z.boolean().optional().describe("Default false"), + type: z.enum(["error", "warning", "meta"]).optional().describe("Only problems of this type"), + provider: z.string().optional().describe("Only problems from this linter; names come back in every result"), + maxProblems: z.number().int().optional().describe("Cap on returned problems. Default 20, max 200") + }, + async function (args) { + let toolResult; + try { + const result = await _execPeerWithTimeout(nodeConnector, "getProblems", args || {}, "getProblems"); + if (result && result.error) { + toolResult = { + content: [{ type: "text", text: "Error: " + result.error }], + isError: true + }; + } else { + toolResult = { + content: [{ type: "text", text: JSON.stringify(result) }] + }; + } + } catch (err) { + toolResult = { + content: [{ type: "text", text: "Error getting problems: " + err.message }], + isError: true + }; + } + return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + searchHint: "lint errors warnings diagnostics problems red squiggles in a file, what the Problems panel shows" + } + ); + + const askInLivePreviewTool = sdkModule.tool( + "askInLivePreview", + "Show a question card over the page in the live preview and wait for the user's answer. Use it whenever " + + "showing beats telling: a choice about one element or about the whole page, or presenting variants or " + + "a mockup for a reaction. The card's frame is Phoenix's: a title bar reading 'Phoenix AI asks' with " + + "minimize and close, a text field with Send for an answer in the user's own words, drag, resize and " + + "placement. You write only the body: uiFile, an HTML fragment with its own