diff --git a/.eslintignore b/.eslintignore index c5dee2a504..506208f040 100644 --- a/.eslintignore +++ b/.eslintignore @@ -25,3 +25,6 @@ test/**/node_modules/**/*.js test/virtual-server-test.js test/spec/ESLintExtensionTest-files + +# AI panel model-test fixtures: deliberately broken pages the tests operate on, not code +src/extensionsIntegrated/phoenix-pro/unit-tests/ai_model_tests/fixtures/** diff --git a/CLAUDE.md b/CLAUDE.md index 0f095c4618..ddffdd6772 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,12 @@ - No trailing whitespace. - Use `const` and `let` instead of `var`. +## Public API docs +- Core modules meant for extension authors must start with `// @INCLUDE_IN_API_DOCS`. Omit it for + internal modules — the marker is what makes a module public, so don't add it by reflex. +- If you add or change that marker, verify the docs still build with `npm run createJSDocs` (note it + also stages `docs/`). + ## Build artifacts — do not hand-edit - **`src/cacheManifest.json`** is a generated build artifact (gitignored, produced by `gulpfile.js/index.js`). It lists files + hashes for the service-worker cache. Never hand-edit or commit it — it is regenerated by the build, so edits are overwritten and won't be tracked anyway. When you add/remove/rename source files, just let the build regenerate it. @@ -48,6 +54,9 @@ Use `exec_js` to run JS in the Phoenix browser runtime. jQuery `$()` is global. **Check logs:** `get_browser_console_logs` with `filter` regex (e.g. `"AI UI"`, `"error"`) and `tail` — includes both browser console and Node.js (PhNode) logs. Use `get_terminal_logs` for Electron process output (only available if Phoenix was launched via `start_phoenix`). +## AI model tests (behavioural tests of the AI panel) +When asked to "run the AI test suite" / "run the model tests" / "run EC-1 and UB-2": call `run_ai_test_suite` (phoenix-builder MCP) with `suite` (`quick` | `all` | a suite name), or `tests` for specific IDs, or `resumeRunId` to continue. It installs the fixture, opens a run record, and returns the briefing plus the test documents from `src/extensionsIntegrated/phoenix-pro/unit-tests/ai_model_tests/`. You are the runner and the judge — follow them exactly, deterministic checks first. After **every** test call `ai_test_progress` and tell the user one progress line. If the user says stop: `ai_test_progress({ runId, stop: true })`, then save. Finish with `save_ai_test_report`, then `compare_ai_test_reports({})`, and tell the user the report path, PASS/FAIL counts, any regressions, and the Observations section. + ## Writing Tests - **Never use `awaits(number)`** (fixed-time waits) in tests — they cause flaky failures. Always use `awaitsFor(condition)` to wait for a specific condition to become true. - Use `editor.*` APIs (e.g. `editor.document.getText()`, `editor.getCursorPos()`, `editor.setSelection()`) instead of accessing `editor._codeMirror` directly. diff --git a/README.md b/README.md index 2424bb4750..674679ef2b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Welcome to Phoenix! -**Website: https://phcode.io** +**Website: https://phcode.dev** Phoenix is a modern open-source and [free software](https://www.gnu.org/philosophy/free-sw.en.html) text editor designed to make coding as simple and fun as playing a video game. diff --git a/docs/API-Reference/language/CSSUtils.md b/docs/API-Reference/language/CSSUtils.md index df76ffeb94..82244a9d04 100644 --- a/docs/API-Reference/language/CSSUtils.md +++ b/docs/API-Reference/language/CSSUtils.md @@ -33,6 +33,43 @@ value of the specified property url for import **Kind**: global constant + + +## \_RE\_PAREN\_SEMI ⇒ [Array.<SelectorInfo>](#SelectorInfo) +Extracts all CSS selectors from the given text +Returns an array of SelectorInfo. Each SelectorInfo is an object with the following properties: + selector: the text of the selector (note: comma separated selector groups like + "h1, h2" are broken into separate selectors) + ruleStartLine: line in the text where the rule (including preceding comment) appears + ruleStartChar: column in the line where the rule (including preceding comment) starts + selectorStartLine: line in the text where the selector appears + selectorStartChar: column in the line where the selector starts + selectorEndLine: line where the selector ends + selectorEndChar: column where the selector ends + selectorGroupStartLine: line where the comma-separated selector group (e.g. .foo, .bar, .baz) + starts that this selector (e.g. .baz) is part of. Particularly relevant for + groups that are on multiple lines. + selectorGroupStartChar: column in line where the selector group starts. + selectorGroup: the entire selector group containing this selector, or undefined if there + is only one selector in the rule. + declListStartLine: line where the declaration list for the rule starts + declListStartChar: column in line where the declaration list for the rule starts + declListEndLine: line where the declaration list for the rule ends + declListEndChar: column in the line where the declaration list for the rule ends + level: the level of the current selector including any containing @media block in the + nesting level count. Use this property with caution since it is primarily for internal + parsing use. For example, two sibling selectors may have different levels if one + of them is nested inside an @media block and it should not be used for sibling info. + parentSelectors: all ancestor selectors separated with '/' if the current selector is a nested one + +**Kind**: global constant +**Returns**: [Array.<SelectorInfo>](#SelectorInfo) - Array with objects specifying selectors. + +| Param | Type | Description | +| --- | --- | --- | +| text | string | CSS text to extract from | +| documentMode | string | language mode of the document that text belongs to, default to css if undefined. | + ## isCSSPreprocessorFile(filePath) ⇒ boolean @@ -80,43 +117,6 @@ in info. | info | [SelectorInfo](#SelectorInfo) | | | [useGroup] | boolean | true to append selectorGroup instead of selector | - - -## extractAllSelectors(text, documentMode) ⇒ [Array.<SelectorInfo>](#SelectorInfo) -Extracts all CSS selectors from the given text -Returns an array of SelectorInfo. Each SelectorInfo is an object with the following properties: - selector: the text of the selector (note: comma separated selector groups like - "h1, h2" are broken into separate selectors) - ruleStartLine: line in the text where the rule (including preceding comment) appears - ruleStartChar: column in the line where the rule (including preceding comment) starts - selectorStartLine: line in the text where the selector appears - selectorStartChar: column in the line where the selector starts - selectorEndLine: line where the selector ends - selectorEndChar: column where the selector ends - selectorGroupStartLine: line where the comma-separated selector group (e.g. .foo, .bar, .baz) - starts that this selector (e.g. .baz) is part of. Particularly relevant for - groups that are on multiple lines. - selectorGroupStartChar: column in line where the selector group starts. - selectorGroup: the entire selector group containing this selector, or undefined if there - is only one selector in the rule. - declListStartLine: line where the declaration list for the rule starts - declListStartChar: column in line where the declaration list for the rule starts - declListEndLine: line where the declaration list for the rule ends - declListEndChar: column in the line where the declaration list for the rule ends - level: the level of the current selector including any containing @media block in the - nesting level count. Use this property with caution since it is primarily for internal - parsing use. For example, two sibling selectors may have different levels if one - of them is nested inside an @media block and it should not be used for sibling info. - parentSelectors: all ancestor selectors separated with '/' if the current selector is a nested one - -**Kind**: global function -**Returns**: [Array.<SelectorInfo>](#SelectorInfo) - Array with objects specifying selectors. - -| Param | Type | Description | -| --- | --- | --- | -| text | string | CSS text to extract from | -| documentMode | string | language mode of the document that text belongs to, default to css if undefined. | - ## findMatchingRules(selector, htmlDocument) ⇒ $.Promise diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 2da39623b6..14ca1b12c0 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -457,7 +457,7 @@ const ALLOWED_EXTENSIONS_TO_CACHE = ["js", "html", "htm", "xml", "xhtml", "mjs", "png", "svg", "jpg", "jpeg", "gif", "ico", "webp", "mustache", "md", "markdown"]; const DISALLOWED_EXTENSIONS_TO_CACHE = ["map", "nuspec", "partial", "pre", "post", - "webmanifest", "rb", "ts"]; + "webmanifest", "rb", "ts", "sh"]; // Ceiling for the PWA service worker cache, in KB. Dev builds ship unminified sources and keep the // phoenix-pro sources in dist, so they are legitimately larger than prod - dev gets the looser diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js index 55bf8bf853..b24c9897e6 100644 --- a/gulpfile.js/validate-build.js +++ b/gulpfile.js/validate-build.js @@ -23,9 +23,10 @@ const fs = require('fs'); const glob = require('glob'); -// Size limits for development builds (in MB) +// Size limits for development builds (in MB). Same margin policy as the prod limits +// below: bump only enough to restore the headroom, so real size jumps still get caught. const DEV_MAX_FILE_SIZE_MB = 6; -const DEV_MAX_TOTAL_SIZE_MB = 100; +const DEV_MAX_TOTAL_SIZE_MB = 105; // dev dist is ~100 MB + 5 MB margin // Custom size limits for known large files (size in MB) For development builds const LARGE_FILE_LIST_DEV = { 'dist/thirdparty/no-minify/language-worker.js.map': 10, diff --git a/package.json b/package.json index dfba29beb5..84fdb94257 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "phoenix", - "version": "5.5.2-0", - "apiVersion": "5.5.2", + "version": "5.5.3-0", + "apiVersion": "5.5.3", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" diff --git a/phoenix-builder-mcp/README.md b/phoenix-builder-mcp/README.md index 9d088b8535..6e27c64787 100644 --- a/phoenix-builder-mcp/README.md +++ b/phoenix-builder-mcp/README.md @@ -97,6 +97,18 @@ Reloads the Phoenix app. Prompts to save unsaved files before reloading. ### `force_reload_phoenix` Force-reloads the Phoenix app without saving unsaved changes. +### `run_ai_test_suite` +Starts (or resumes) the AI panel model tests and hands the session everything it needs: installs the fixture project, gathers git revisions / CLI version / connected instance, opens a run record, and returns the runner briefing plus the test documents from `src/extensionsIntegrated/phoenix-pro/unit-tests/ai_model_tests/`. The session is the runner and the judge. `suite`: `quick` (default), `all`, or a suite name; or `tests: ["EC-1","UB-2"]` for specific tests; or `resumeRunId` to continue a stopped run. Ask Claude: *"run the AI test suite"*, *"run just the plan-mode tests"*, *"run EC-1 and UB-2"*. + +### `ai_test_progress` +Called by the runner after every test to record the result; also answers *"how far along is it?"* (`{ runId }`), lists runs (`{}`), and stops a run (`{ runId, stop: true }`). Progress lives in `reports/runs/.json`, which you can open at any time. + +### `save_ai_test_report` +Writes the finished report to `reports/latest.md` inside the suite folder, overwriting the previous run (git history keeps earlier runs; `baseline.md` is never touched). A stopped run is saved with a Partial section listing the unrun tests. + +### `compare_ai_test_reports` +Diffs two reports test by test — by default `latest.md` against `baseline.md`, or `against: "previous"` for the last committed run — and flags regressions, quality drops, and slower runs using the thresholds in `model_tests.md`. + ## Typical Claude Code workflow ``` diff --git a/phoenix-builder-mcp/mcp-tools.js b/phoenix-builder-mcp/mcp-tools.js index 070ea24a91..871ede1176 100644 --- a/phoenix-builder-mcp/mcp-tools.js +++ b/phoenix-builder-mcp/mcp-tools.js @@ -1,4 +1,9 @@ import { z } from "zod"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; const DEFAULT_MAX_CHARS = 10000; @@ -15,6 +20,146 @@ function _trimToCharBudget(lines, maxChars) { return { lines: lines.slice(startIdx), trimmed: startIdx }; } +// ---- AI model test suite --------------------------------------------------- +// The suites are markdown procedures run by a Claude session against the +// connected Phoenix instance; the session is the runner and the judge. These +// tools do the deterministic parts — install the fixture, gather the +// environment, hand over the documents, file the report — so that "run the AI +// test suite" is a single request. + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const AI_TESTS_DIR = path.join(REPO_ROOT, "src", "extensionsIntegrated", "phoenix-pro", + "unit-tests", "ai_model_tests"); +const AI_TESTS_FIXTURE_DEST = path.join(os.homedir(), "Documents", + "Phoenix Code Experimental Build", "ai-model-tests", "taskboard"); +const AI_TEST_SUITES = { + "editor-context": "suite-editor-context.md", + "tool-discovery": "suite-tool-discovery.md", + "unsaved-buffers": "suite-unsaved-buffers.md", + "self-sufficiency": "suite-self-sufficiency.md", + "bug-fixing": "suite-bug-fixing.md", + "questions": "suite-questions.md", + "plan-mode": "suite-plan-mode.md", + "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". +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"] +}; + +function _gitInfo(cwd) { + try { + const rev = execSync("git rev-parse --short HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); + const branch = execSync("git branch --show-current", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); + const dirty = execSync("git status --porcelain", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() ? " (uncommitted changes)" : ""; + return `${rev} on ${branch}${dirty}`; + } catch (e) { + return "unknown"; + } +} + +function _claudeCliVersion() { + try { + return execSync("claude --version", { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).toString().trim(); + } catch (e) { + return "unknown (read it from the panel's logs)"; + } +} + +// Two committed files only: baseline.md (the reference, edited by hand) and +// latest.md (overwritten every run — git history is the archive). Anything +// else in the folder is listed but not special. +const AI_TEST_REPORT_LATEST = "latest.md"; +const AI_TEST_REPORT_BASELINE = "baseline.md"; +function _listReports() { + const dir = path.join(AI_TESTS_DIR, "reports"); + if (!fs.existsSync(dir)) { return []; } + return fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort(); +} +// The committed version of a report file, for `against: "previous"`. +function _gitHeadVersion(fileName) { + const proDir = path.join(REPO_ROOT, "src", "extensionsIntegrated", "phoenix-pro"); + const rel = path.posix.join("unit-tests", "ai_model_tests", "reports", fileName); + try { + return execSync(`git show HEAD:${rel}`, { cwd: proDir, stdio: ["ignore", "pipe", "ignore"] }).toString(); + } catch (e) { + return null; + } +} + +function _countTests(markdown) { + return (markdown.match(/^## [A-Z]{2}-\d+/gm) || []).length; +} + +function _todayStamp() { + return new Date().toISOString().slice(0, 10); +} + +const AI_TEST_RUNS_DIR = path.join(AI_TESTS_DIR, "reports", "runs"); + +function _runPath(runId) { + return path.join(AI_TEST_RUNS_DIR, runId.replace(/[^a-zA-Z0-9-]+/g, "-") + ".json"); +} +function _saveRun(run) { + fs.mkdirSync(AI_TEST_RUNS_DIR, { recursive: true }); + fs.writeFileSync(_runPath(run.runId), JSON.stringify(run, null, 2) + "\n", "utf8"); +} +function _loadRun(runId) { + const p = _runPath(runId); + return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null; +} +function _listRuns() { + if (!fs.existsSync(AI_TEST_RUNS_DIR)) { return []; } + return fs.readdirSync(AI_TEST_RUNS_DIR).filter(f => f.endsWith(".json")).map(f => f.slice(0, -5)).sort().reverse(); +} +function _countResults(results) { + const c = { PASS: 0, FAIL: 0, FLAKY: 0, BLOCKED: 0, good: 0, acceptable: 0, poor: 0 }; + for (const r of results) { + if (c[r.invariants] !== undefined) { c[r.invariants]++; } + if (c[r.judgement] !== undefined) { c[r.judgement]++; } + } + return c; +} +// Every "## XX-n — title" heading across the suite files, keyed by id. +function _loadTestCatalog() { + const catalog = {}; + for (const [suite, file] of Object.entries(AI_TEST_SUITES)) { + const full = path.join(AI_TESTS_DIR, file); + if (!fs.existsSync(full)) { continue; } + for (const m of fs.readFileSync(full, "utf8").matchAll(/^## ([A-Z]{2}-\d+)\s*—\s*(.+)$/gm)) { + catalog[m[1]] = { suite, file, title: m[2].trim() }; + } + } + return catalog; +} +// Rows of a report's "## Results" table, keyed by test id. Tolerates the +// baseline's range rows ("EC-2..EC-6") by keying them as written. +function _parseResults(markdown) { + const out = {}; + const sec = markdown.split(/^## Results/m)[1]; + if (!sec) { return out; } + for (const line of sec.split("\n")) { + if (!line.startsWith("|") || /^\|\s*-/.test(line) || /^\|\s*Suite/.test(line)) { continue; } + const cells = line.split("|").slice(1, -1).map(c => c.trim()); + if (cells.length < 4) { continue; } + const id = cells[1]; + if (!/^[A-Z]{2}-\d/.test(id)) { continue; } + const num = v => { const m = String(v || "").match(/[\d.]+/); return m ? parseFloat(m[0]) : null; }; + // Result cell is "PASS · poor" (or older reports: separate Invariants + // and Judgement columns). Detect which by whether column 3 looks like a + // judgement word. + const parts = (cells[2] || "").split(/\s*[·/]\s*/); + const separate = /^(good|acceptable|poor|n\/a|—|-)$/i.test(cells[3] || ""); + const inv = parts[0].split(/\s/)[0].toUpperCase(); + const jud = (separate ? cells[3] : (parts[1] || "")).split(/\s/)[0].toLowerCase(); + const off = separate ? 4 : 3; + out[id] = { inv, jud, tools: num(cells[off]), turns: num(cells[off + 1]), timeS: num(cells[off + 2]), cost: num(cells[off + 3]) }; + } + return out; +} + export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) { server.tool( "start_phoenix", @@ -512,4 +657,313 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe }; } ); + + server.tool( + "run_ai_test_suite", + "Start (or resume) the AI panel model tests. You (the calling session) are the runner and the judge: " + + "this installs the fixture, gathers the environment, opens a run record for progress tracking, and " + + "returns the runner briefing plus the test documents to follow step by step. Scope: suite = 'quick' " + + "(default, ~4 model runs + free checks) | 'all' (~20 model runs) | one of " + + Object.keys(AI_TEST_SUITES).join(", ") + "; or tests = explicit IDs like ['EC-1','UB-2'] to run only " + + "those. resumeRunId continues an earlier run's remaining tests. After every test call ai_test_progress; " + + "when done (or told to stop) call save_ai_test_report and tell the user where it is.", + { + suite: z.string().optional().describe("quick | all | " + Object.keys(AI_TEST_SUITES).join(" | ")), + tests: z.array(z.string()).optional().describe("Explicit test IDs to run, e.g. [\"EC-1\",\"UB-2\"]. Overrides suite."), + resumeRunId: z.string().optional().describe("Run id from a previous briefing; runs only its remaining tests") + }, + async ({ suite, tests, resumeRunId }) => { + if (!fs.existsSync(path.join(AI_TESTS_DIR, "model_tests.md"))) { + return { content: [{ type: "text", text: "Test suite not found at " + AI_TESTS_DIR + + ". Is phoenix-pro checked out inside this phoenix repo?" }], isError: true }; + } + const catalog = _loadTestCatalog(); // { "EC-1": { suite, file, title }, ... } + + let which, planned, run; + if (resumeRunId) { + run = _loadRun(resumeRunId); + if (!run) { + return { content: [{ type: "text", text: "No run record '" + resumeRunId + "'. Known runs: " + + _listRuns().join(", ") }], isError: true }; + } + const done = new Set(run.results.map(r => r.test)); + planned = run.planned.filter(t => !done.has(t)); + which = run.scope; + run.status = "running"; + run.resumedAt = new Date().toISOString(); + } else if (tests && tests.length) { + const unknown = tests.filter(t => !catalog[t.toUpperCase()]); + if (unknown.length) { + return { content: [{ type: "text", text: "Unknown test id(s): " + unknown.join(", ") + + ". Known: " + Object.keys(catalog).join(", ") }], isError: true }; + } + planned = tests.map(t => t.toUpperCase()); + which = "selected"; + } else { + which = (suite || "quick").trim().toLowerCase(); + if (which !== "quick" && which !== "all" && !AI_TEST_SUITES[which]) { + return { content: [{ type: "text", text: "Unknown suite '" + which + "'. Use quick, all, or one of: " + + Object.keys(AI_TEST_SUITES).join(", ") }], isError: true }; + } + planned = which === "quick" ? AI_TEST_QUICK.tests.slice() + : Object.keys(catalog).filter(id => which === "all" || catalog[id].suite === which); + } + if (!run) { + run = { + runId: _todayStamp() + "-" + new Date().toTimeString().slice(0, 5).replace(":", "") + "-" + which, + scope: which, planned, results: [], status: "running", + startedAt: new Date().toISOString(), env: {} + }; + } + + // H0: install the fixture. Deterministic, so do it here rather than ask the runner. + const fixtureSrc = path.join(AI_TESTS_DIR, "fixtures", "taskboard"); + fs.mkdirSync(AI_TESTS_FIXTURE_DEST, { recursive: true }); + for (const f of fs.readdirSync(fixtureSrc)) { + fs.copyFileSync(path.join(fixtureSrc, f), path.join(AI_TESTS_FIXTURE_DEST, f)); + } + + const instances = wsControlServer.getConnectedInstances(); + const electron = instances.filter(i => i.startsWith("phoenix-electron-")); + const proDir = path.join(REPO_ROOT, "src", "extensionsIntegrated", "phoenix-pro"); + run.env = { phoenix: _gitInfo(REPO_ROOT), phoenixPro: _gitInfo(proDir), claudeCli: _claudeCliVersion(), instance: electron[0] || null }; + _saveRun(run); + + const suiteFiles = [...new Set(planned.map(id => catalog[id].file))]; + const docs = suiteFiles.map(file => ({ file, md: fs.readFileSync(path.join(AI_TESTS_DIR, file), "utf8") })); + const index = fs.readFileSync(path.join(AI_TESTS_DIR, "model_tests.md"), "utf8"); + const reports = _listReports(); + const baseline = reports.includes(AI_TEST_REPORT_BASELINE) ? AI_TEST_REPORT_BASELINE : null; + const reportName = AI_TEST_REPORT_LATEST; + + const briefing = [ + "# AI model tests — runner briefing", + "", + "You are the runner and the judge. Follow the documents below exactly. Do every step as written,", + "check every invariant, apply every judgement rubric, and write one or two sentences of reasoning", + "per test. Run the deterministic checks first, and stop if one fails.", + "", + "## Progress, stopping, and the user", + `- Run id: **${run.runId}**. After EVERY test, call ai_test_progress({ runId, test, invariants, judgement,`, + " tools, turns, timeMs, cost, reasoning }). It returns done/total and what remains.", + "- After every test, tell the user one line: ` — n/total done`.", + `- The run record the user can open at any time: ${_runPath(run.runId)}`, + "- If the user says stop: finish the step you are in, call ai_test_progress({ runId, stop: true }),", + " then save_ai_test_report with what you have — the report is marked partial and lists what was not run.", + " It can be resumed later with run_ai_test_suite({ resumeRunId }).", + "", + "## What will run", + `- Scope: **${which}** — ${planned.length} test(s)${resumeRunId ? " remaining" : ""}: ${planned.join(", ")}`, + ...suiteFiles.map(f => ` - ${f}`), + which === "quick" ? "- Quick means ONLY the listed tests. Skip everything else in those documents." : "", + which === "selected" ? "- Selected means ONLY the listed tests. Skip everything else in those documents." : "", + "", + "## Environment (put this at the top of the report)", + "- Runner model: ", + "- Model under test: ", + `- phoenix: ${run.env.phoenix}`, + `- phoenix-pro: ${run.env.phoenixPro}`, + `- Claude CLI: ${run.env.claudeCli}`, + `- Connected instances: ${instances.length ? instances.join(", ") : "none — start Phoenix first"}`, + electron.length ? `- Use instance: ${electron[0]}` : "- No phoenix-electron-* instance is connected. Ask the user to open the desktop app, then call get_phoenix_status.", + "", + "## Where things are", + `- Fixture installed (H0 done for you): ${AI_TESTS_FIXTURE_DEST}`, + `- In the documents, replace with: ${AI_TESTS_FIXTURE_DEST}`, + `- Transcript folder for H10: ~/.claude/projects/${AI_TESTS_FIXTURE_DEST.replace(/[\/ ]/g, "-")}`, + `- Test documents: ${AI_TESTS_DIR}`, + `- Reports folder: ${path.join(AI_TESTS_DIR, "reports")}`, + `- Compare against: ${baseline ? path.join(AI_TESTS_DIR, "reports", baseline) : "no baseline found — this run becomes the baseline"}`, + `- Reports in the folder: ${reports.length ? reports.join(", ") : "none"} (earlier runs live in git history of ${reportName})`, + `- Your report will be saved as: ${reportName} (overwrites the previous run) — call save_ai_test_report({ content, runId: "${run.runId}" }).`, + " Then call compare_ai_test_reports({}) to diff it against the baseline — or { against: \"previous\" } for the last committed run — and relay the result.", + "", + "---", + "", + "# Document 1 of " + (docs.length + 1) + ": model_tests.md (rules, harness, report format)", + "", + index, + ...docs.flatMap((d, i) => ["", "---", "", `# Document ${i + 2} of ${docs.length + 1}: ${d.file}`, "", d.md]) + ].filter(l => l !== null).join("\n"); + + return { content: [{ type: "text", text: briefing }] }; + } + ); + + server.tool( + "ai_test_progress", + "Record one test's result during an AI model test run, or read a run's progress, or stop it. With test + " + + "result fields: appends the result and returns done/total/remaining. With only runId: returns current " + + "progress (use this to answer 'how far along is it?'). With stop: true: marks the run stopped and returns " + + "what was not run. With no runId at all: lists known runs.", + { + runId: z.string().optional().describe("Run id from the briefing"), + test: z.string().optional().describe("Test id, e.g. EC-1"), + invariants: z.enum(["PASS", "FAIL", "FLAKY", "BLOCKED"]).optional(), + judgement: z.enum(["good", "acceptable", "poor", "n/a"]).optional(), + tools: z.number().optional(), turns: z.number().optional(), + timeMs: z.number().optional(), cost: z.number().optional(), + reasoning: z.string().optional().describe("One or two sentences: what the AI did and why that verdict"), + stop: z.boolean().optional().describe("Mark the run stopped (user asked to stop)") + }, + async (args) => { + if (!args.runId) { + const runs = _listRuns().map(id => { const r = _loadRun(id); return `${id}: ${r.status}, ${r.results.length}/${r.planned.length}`; }); + return { content: [{ type: "text", text: runs.length ? "Known runs:\n" + runs.join("\n") : "No runs recorded." }] }; + } + const run = _loadRun(args.runId); + if (!run) { + return { content: [{ type: "text", text: "No run record '" + args.runId + "'. Known: " + _listRuns().join(", ") }], isError: true }; + } + if (args.test) { + const id = args.test.toUpperCase(); + run.results = run.results.filter(r => r.test !== id); // a rerun replaces + run.results.push({ + test: id, invariants: args.invariants || "BLOCKED", judgement: args.judgement || "n/a", + tools: args.tools, turns: args.turns, timeMs: args.timeMs, cost: args.cost, + reasoning: args.reasoning || "", at: new Date().toISOString() + }); + } + if (args.stop) { + run.status = "stopped"; + run.stoppedAt = new Date().toISOString(); + } else if (run.results.length >= run.planned.length && run.planned.every(t => run.results.some(r => r.test === t))) { + run.status = "complete"; + run.completedAt = new Date().toISOString(); + } + _saveRun(run); + const done = run.planned.filter(t => run.results.some(r => r.test === t)); + const remaining = run.planned.filter(t => !done.includes(t)); + const counts = _countResults(run.results); + return { content: [{ type: "text", text: JSON.stringify({ + runId: run.runId, status: run.status, done: done.length, total: run.planned.length, + remaining, counts, record: _runPath(run.runId), + progressLine: `${done.length}/${run.planned.length} done · PASS ${counts.PASS} FAIL ${counts.FAIL} FLAKY ${counts.FLAKY} BLOCKED ${counts.BLOCKED}` + + (remaining.length ? ` · next: ${remaining[0]}` : " · all done") + }, null, 2) }] }; + } + ); + + server.tool( + "save_ai_test_report", + "File an AI model test report as reports/latest.md, overwriting the previous run (git history keeps " + + "earlier runs; baseline.md is never touched). If runId is given and the run was stopped, the report is " + + "marked partial and the unrun tests are appended. Returns the saved path and the baseline to compare " + + "against — relay the path to the user.", + { + content: z.string().describe("The full report markdown, following the skeleton in model_tests.md"), + runId: z.string().optional().describe("Run id from the briefing; links the report to its progress record"), + label: z.string().optional().describe("Ignored for the filename (always latest.md); kept for compatibility") + }, + async ({ content, runId }) => { + const dir = path.join(AI_TESTS_DIR, "reports"); + fs.mkdirSync(dir, { recursive: true }); + let body = content.endsWith("\n") ? content : content + "\n"; + const run = runId ? _loadRun(runId) : null; + let partial = false; + if (run) { + const notRun = run.planned.filter(t => !run.results.some(r => r.test === t)); + if (run.status === "stopped" || notRun.length) { + partial = true; + body += `\n## Partial run\nStopped ${run.stoppedAt || "before completion"}. Not run: ${notRun.join(", ") || "none"}.\n` + + `Resume with run_ai_test_suite({ resumeRunId: "${run.runId}" }).\n`; + } + } + const full = path.join(dir, AI_TEST_REPORT_LATEST); + const hadPrevious = fs.existsSync(full); + fs.writeFileSync(full, body, "utf8"); + if (run) { + // A finished run's progress record has done its job; only a + // stopped (resumable) run keeps its file. Keeps runs/ from piling up. + if (partial) { run.report = full; _saveRun(run); } + else if (fs.existsSync(_runPath(run.runId))) { fs.unlinkSync(_runPath(run.runId)); } + } + const hasBaseline = fs.existsSync(path.join(dir, AI_TEST_REPORT_BASELINE)); + return { content: [{ type: "text", text: JSON.stringify({ + saved: full, + partial, + overwrotePrevious: hadPrevious, + compareAgainst: hasBaseline ? path.join(dir, AI_TEST_REPORT_BASELINE) : null, + tellTheUser: "Report saved to " + full + (partial ? " (partial run)" : "") + + (hadPrevious ? "; the previous run is in git history" : "") + + (hasBaseline ? "; compare with compare_ai_test_reports({}) or { against: \"previous\" }" : "") + "." + }, null, 2) }] }; + } + ); + + server.tool( + "compare_ai_test_reports", + "Diff two AI model test reports test by test: invariant changes (REGRESSION / fixed), judgement changes " + + "(quality drop / improved), and tool/turn/time deltas with the thresholds from model_tests.md (tools " + + ">1.5x or time >2x = 'slower'). Defaults: report = latest.md, against = baseline.md. against: 'previous' " + + "compares latest.md with its last committed version (git HEAD). File names or absolute paths also accepted.", + { + report: z.string().optional().describe("Report to evaluate. Default: latest.md"), + against: z.string().optional().describe("Reference: 'baseline' (default), 'previous' (git HEAD of latest.md), a file name, or a path") + }, + async ({ report, against }) => { + const dir = path.join(AI_TESTS_DIR, "reports"); + const reports = _listReports(); + const resolve = (n, fallback) => { + const pick = n || fallback; + if (!pick) { return null; } + const full = path.isAbsolute(pick) ? pick : path.join(dir, pick); + return fs.existsSync(full) ? full : null; + }; + const a = resolve(report, AI_TEST_REPORT_LATEST); + let b, bLabel; + if ((against || "").toLowerCase() === "previous") { + const prev = _gitHeadVersion(AI_TEST_REPORT_LATEST); + if (!prev) { + return { content: [{ type: "text", text: "No committed version of " + AI_TEST_REPORT_LATEST + + " in git HEAD to compare against." }], isError: true }; + } + b = path.join(os.tmpdir(), "ai-model-tests-previous-latest.md"); fs.writeFileSync(b, prev, "utf8"); + bLabel = AI_TEST_REPORT_LATEST + " @ git HEAD"; + } else { + b = resolve((against || "").toLowerCase() === "baseline" ? null : against, AI_TEST_REPORT_BASELINE); + bLabel = b ? path.basename(b) : null; + } + if (!a || !b) { + return { content: [{ type: "text", text: "Need two reports. Have: " + (reports.join(", ") || "none") + + (a ? "" : " — report not found") + (b ? "" : " — reference not found") }], isError: true }; + } + const A = _parseResults(fs.readFileSync(a, "utf8")), B = _parseResults(fs.readFileSync(b, "utf8")); + const ids = [...new Set([...Object.keys(B), ...Object.keys(A)])].sort(); + const rows = [], flags = { regression: [], fixed: [], qualityDrop: [], improved: [], slower: [], faster: [], onlyInReport: [], onlyInReference: [] }; + const rank = { good: 2, acceptable: 1, poor: 0 }; + for (const id of ids) { + const x = A[id], y = B[id]; + if (!x) { flags.onlyInReference.push(id); rows.push(`| ${id} | — | ${y.inv}/${y.jud} | not in report |`); continue; } + if (!y) { flags.onlyInReport.push(id); rows.push(`| ${id} | ${x.inv}/${x.jud} | — | new |`); continue; } + const notes = []; + if (y.inv === "PASS" && x.inv === "FAIL") { flags.regression.push(id); notes.push("**REGRESSION**"); } + if (y.inv === "FAIL" && x.inv === "PASS") { flags.fixed.push(id); notes.push("fixed"); } + if (rank[x.jud] !== undefined && rank[y.jud] !== undefined) { + if (rank[x.jud] < rank[y.jud]) { flags.qualityDrop.push(id); notes.push("quality drop"); } + if (rank[x.jud] > rank[y.jud]) { flags.improved.push(id); notes.push("improved"); } + } + if (x.tools && y.tools && x.tools > 1.5 * y.tools) { flags.slower.push(id); notes.push(`tools ${y.tools}→${x.tools}`); } + if (x.timeS && y.timeS && x.timeS > 2 * y.timeS) { if (!flags.slower.includes(id)) { flags.slower.push(id); } notes.push(`time ${y.timeS}s→${x.timeS}s`); } + if (x.timeS && y.timeS && x.timeS < 0.5 * y.timeS && x.inv === "PASS") { flags.faster.push(id); notes.push(`faster ${y.timeS}s→${x.timeS}s`); } + rows.push(`| ${id} | ${x.inv}/${x.jud} | ${y.inv}/${y.jud} | ${notes.join(", ") || "same"} |`); + } + const out = [ + `# Comparison: ${path.basename(a)} vs ${bLabel}`, + "", + `Regressions: ${flags.regression.length ? flags.regression.join(", ") : "none"}`, + `Quality drops: ${flags.qualityDrop.length ? flags.qualityDrop.join(", ") : "none"}`, + `Slower (tools >1.5x or time >2x): ${flags.slower.length ? flags.slower.join(", ") : "none"}`, + `Fixed: ${flags.fixed.join(", ") || "none"} · Improved judgement: ${flags.improved.join(", ") || "none"} · Faster: ${flags.faster.join(", ") || "none"}`, + flags.onlyInReport.length ? `Only in report (no reference): ${flags.onlyInReport.join(", ")}` : "", + flags.onlyInReference.length ? `Not run this time: ${flags.onlyInReference.join(", ")}` : "", + "", + "| Test | report (inv/judg) | reference (inv/judg) | change |", + "| --- | --- | --- | --- |", + ...rows + ].filter(Boolean).join("\n"); + return { content: [{ type: "text", text: out }] }; + } + ); + } diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index c81b7ae484..3fd48f7f55 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -26,15 +26,30 @@ * edit/write interception, and session management. */ -const { execSync, spawn } = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const { createEditorMcpServer } = require("./mcp-editor-tools"); +const CliLocator = require("./cli-locator"); const isWindows = process.platform === "win32"; const CONNECTOR_ID = "ph_ai_claude"; +// 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 +// consumes it — the main agent then finds it empty and never sees what +// the user asked. Subagents also lack the conversation context to apply +// it sensibly. So they get neither the hint nor the tool; the main agent +// reads it as soon as it regains control. +function _clarificationHintFor(hookInput) { + if (!_queuedClarification || (hookInput && hookInput.agent_id)) { + return ""; + } + return CLARIFICATION_HINT; +} + const CLARIFICATION_HINT = " IMPORTANT: The user has typed a follow-up clarification while you were working." + " Call the getUserClarification tool to read it before proceeding."; @@ -133,29 +148,19 @@ let editorMcpServer = null; // Streaming throttle const TEXT_STREAM_THROTTLE_MS = 50; -// Pending question resolver — used by AskUserQuestion hook -let _questionResolve = null; - -// Pending plan resolver — used by ExitPlanMode stream interception -let _planResolve = null; - -// Pending bash confirmation resolver — used by Bash PreToolUse hook (Edit Mode) -let _bashConfirmResolve = null; - -// Pending plan-mode write confirmation resolver — set when an Edit/Write -// fires in plan mode and we're awaiting the user's "Allow & Switch to Edit -// Mode" / "Stay in Plan Mode" choice from the browser. -let _planModeConfirmResolve = null; - -// Stores rejection feedback when user rejects a plan -let _planRejectionFeedback = null; +// Pending browser answers (question, plan, toolConfirm, planModeConfirm +// cards), keyed by card kind and then by confirm id. The SDK runs +// PreToolUse hooks and permission prompts for parallel tool calls +// concurrently, so several cards can be up at once — a single resolver +// slot per kind kept only the last one, and clicking any earlier card did +// nothing. Each card carries its confirmId back in the answer; an answer +// without one resolves the oldest card of that kind. +const _pendingAnswers = {}; +let _confirmSeq = 0; // Stores the last plan content written to .claude/plans/ let _lastPlanContent = null; -// Flag set when user approves a plan -let _planApproved = false; - // Queued clarification from the user (typed while AI is streaming) // Shape: { text: string, images: [{mediaType, base64Data}] } or null let _queuedClarification = null; @@ -172,6 +177,181 @@ let _runtimePermissionMode = "auto"; const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); +// Tools whose permission request in Plan Mode means "the model wants to +// start editing user files" — they share the plan-mode write-confirm card. +const FILE_WRITE_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]; + +// The preferences tool both reads and writes, so it cannot carry a static +// readOnlyHint the way getEditorState does — whether a call is harmless +// depends on its `operation`. +const EDITOR_PREFS_TOOL = "mcp__phoenix-editor__editorPreferences"; + +function _isPreferenceRead(input) { + const op = input && input.operation; + return op === "get" || op === "list"; +} + +// Handed to the model right after the user approves a plan. The CLI leaves +// plan mode on approval and the model carries on in the same turn, so this +// is where "proceed" gets spelled out for Phoenix. +const PLAN_APPROVED_HINT = "The user approved the plan. Proceed with the " + + "implementation now, in this same turn. After building, verify by using " + + "execJsInLivePreview to check the result and takeScreenshot to confirm it " + + "looks correct."; + +/** + * Register a card that waits for a browser answer through one of the + * answer* peers. Returns {id, promise}: send `id` to the browser as + * confirmId, then await `promise`. It resolves with the browser's payload, + * or null when the query is cancelled while the card is still up. + */ +function _registerAnswer(kind, signal) { + const id = ++_confirmSeq; + const bucket = _pendingAnswers[kind] || (_pendingAnswers[kind] = new Map()); + const promise = new Promise((resolve) => { + if (signal.aborted) { + resolve(null); + return; + } + const onAbort = () => { + bucket.delete(id); + resolve(null); + }; + bucket.set(id, (response) => { + signal.removeEventListener("abort", onAbort); + bucket.delete(id); + resolve(response); + }); + signal.addEventListener("abort", onAbort, { once: true }); + }); + return { id: id, promise: promise }; +} + +/** + * Deliver a browser answer to the matching pending card (by confirmId, else + * the oldest card of that kind). Returns false if nothing was waiting. + */ +function _resolveAnswer(kind, params) { + const bucket = _pendingAnswers[kind]; + if (!bucket || !bucket.size) { + return false; + } + let resolve; + if (params && params.confirmId !== undefined) { + resolve = bucket.get(params.confirmId); + } + if (!resolve) { + resolve = bucket.values().next().value; + } + resolve(params || {}); + return true; +} + +function _clearPendingAnswers() { + Object.keys(_pendingAnswers).forEach((kind) => _pendingAnswers[kind].clear()); +} + +/** + * Ask the user to allow or deny a tool call (the Allow/Deny card). Used by + * the Edit Mode Bash hook and for every permission request the CLI hands to + * canUseTool that no more specific card covers. Resolves true on Allow, + * false on Deny or when the query is aborted while the card is up. + */ +async function _askToolConfirm(requestId, toolName, toolInput, signal) { + const pending = _registerAnswer("toolConfirm", signal); + nodeConnector.triggerPeer("aiBashConfirm", { + requestId: requestId, + confirmId: pending.id, + toolName: toolName, + command: toolName === "Bash" ? ((toolInput && toolInput.command) || "") : "", + toolInput: toolInput || {} + }); + const response = await pending.promise; + return !!(response && response.allowed); +} + +/** + * Ask the user (via the browser's plan-mode write-confirm card) whether an + * Edit/Write on a user file may go through while the panel is in Plan Mode. + * Resolves true for "Allow & Switch to Auto", false for "Stay in Plan + * Mode" or when the query is aborted while the card is up. + */ +async function _askPlanModeWriteConfirm(requestId, toolName, filePath, signal) { + const pending = _registerAnswer("planModeConfirm", signal); + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + confirmId: pending.id, + toolName: toolName, + filePath: filePath + }); + const response = await pending.promise; + return !!(response && response.approved); +} + +/** + * Show the AskUserQuestion card in the browser and wait for the answers. + * Resolves the browser's {answers} payload, or null on abort. + */ +async function _askUserQuestions(requestId, questions, signal) { + const pending = _registerAnswer("question", signal); + nodeConnector.triggerPeer("aiQuestion", { + requestId: requestId, + confirmId: pending.id, + questions: questions + }); + return pending.promise; +} + +/** + * Format AskUserQuestion answers as readable text for the model. + */ +function _formatAnswers(answer) { + let answerText = ""; + if (answer && answer.answers) { + Object.keys(answer.answers).forEach(function (q) { + answerText += "Q: " + q + "\nA: " + answer.answers[q] + "\n\n"; + }); + } + return answerText.trim(); +} + +/** + * Render the editor context the panel sent into the line prepended to the + * prompt. The panel assembles it because that is where the data and the + * user's context chips already live; this only turns it into prose. Returns + * "" when the panel sent nothing, i.e. the user dismissed those chips. + */ +function _buildEditorContextLine(ctx) { + if (!ctx || (!ctx.activeFile && !ctx.livePreviewFile)) { + return ""; + } + const parts = ["Editor state (auto-supplied, no tool call needed):"]; + if (ctx.activeFile) { + parts.push("the user is editing " + ctx.activeFile + "."); + if (ctx.unsaved) { + // Read and Edit are buffer-safe here (the agent flushes the buffer + // first); only Grep sees stale disk. Saying "stale on disk" without that + // steered the model off Edit onto the editor API — no edit card, no undo. + parts.push("Unsaved (Read and Edit see the unsaved text as normal; Grep does not, so " + + "use searchEditorBuffers to search these): " + ctx.unsaved + "."); + } + } + if (ctx.livePreviewFile) { + parts.push(ctx.livePreviewFile === ctx.activeFile + ? "The live preview is showing that same file." + : "The live preview is showing " + ctx.livePreviewFile + "."); + } + // Say plainly when the lists are complete. Left merely to infer it, the + // agent calls getEditorState to check — the exact lookup this line is + // here to save. + parts.push(ctx.truncated + ? "Trust this over searching for it yourself; call getEditorState for the names cut " + + "from a list, or if you need the cursor, the selection or a fresher view." + : "That is the complete set. Trust it over searching or double-checking; call " + + "getEditorState only if you need the cursor, the selection or a fresher view."); + return parts.join(" "); +} + /** * Detect whether a PostToolUse `tool_response` represents an error result. * Used to suppress diff-card painting when the SDK's native Edit/Write itself @@ -383,325 +563,258 @@ async function getQueryFn() { } /** - * Build ordered candidate paths on Windows, split into two tiers: - * - `native`: real PE binaries dropped by claude.ai/install.ps1 or the - * desktop installer. No node/cli.js shim chain to break, so file - * existence is enough confidence — we skip the `--version` validation. - * - `fallback`: PATH discovery via `where`, npm shim. Broken installs - * are common here (orphan `.cmd` whose cli.js got deleted, extensionless - * POSIX scripts Windows can't execute), so every candidate is verified - * with `claude --version` before we return it. - */ -function _winClaudeCandidates() { - const userHome = process.env.USERPROFILE || process.env.HOME || ""; - const native = [ - path.join(userHome, ".local", "bin", "claude.exe"), - path.join(process.env.LOCALAPPDATA || "", "Programs", "claude", "claude.exe") - ]; - const fallback = []; - - // PATH discovery — filter to executable extensions (drop extensionless - // POSIX scripts and .ps1, both of which our spawn path can't use), - // and prefer .exe over .cmd/.bat shims when both resolve. - try { - const allPaths = execSync("where claude", { encoding: "utf8" }) - .trim() - .split("\r\n") - .filter(p => p && !p.includes("node_modules") && /\.(exe|cmd|bat)$/i.test(p)); - const exes = allPaths.filter(p => /\.exe$/i.test(p)); - const others = allPaths.filter(p => !/\.exe$/i.test(p)); - fallback.push(...exes, ...others); - } catch { /* where not on PATH or returned nothing */ } - - // Explicit npm shim in case `where` wasn't reachable. - fallback.push(path.join(process.env.APPDATA || "", "npm", "claude.cmd")); - - return { native, fallback }; -} - -/** - * Build candidate nvm-installed claude paths. The previously hardcoded - * `process.version` was the Node that Phoenix ships, not the Node the user - * has selected in nvm — which mismatched in practice for ~every nvm user. + * Best-effort AI-generated title for a session. * - * Strategy: prefer the version named in `~/.nvm/alias/default` (or whatever - * `$NVM_DIR` points at). Fall back to enumerating installed versions, newest - * first, so we still find claude when the default alias is a label like - * `lts/*` or `node` that we don't expand here. + * The CLI writes an `ai-title` entry into the session JSONL on the first + * turn — a few words describing what the conversation is actually about. + * The SDK surfaces it as `SDKSessionInfo.summary`, preferring a user-set + * custom title and falling back to the raw first prompt when neither + * exists. We only report a title that beats that fallback, so the panel + * keeps its own first-message title when the CLI has nothing better + * (older CLI builds, or a session that ended before the title landed). + * + * @param {string} sessionId + * @param {string} projectPath + * @return {Promise} short title, or null when unavailable */ -function _nvmClaudeCandidates(home) { - const nvmRoot = process.env.NVM_DIR || path.join(home, ".nvm"); - const versionsDir = path.join(nvmRoot, "versions", "node"); - const candidates = []; +async function _getAISessionTitle(sessionId, projectPath) { + if (!sessionId) { + return null; + } try { - const aliasFile = path.join(nvmRoot, "alias", "default"); - if (fs.existsSync(aliasFile)) { - const alias = fs.readFileSync(aliasFile, "utf8").trim(); - if (/^v?\d/.test(alias)) { - const v = alias.startsWith("v") ? alias : "v" + alias; - candidates.push(path.join(versionsDir, v, "bin", "claude")); - } + if (!queryModule) { + queryModule = await import("@anthropic-ai/claude-agent-sdk"); } - } catch { /* nvm not installed or unreadable */ } - try { - if (fs.existsSync(versionsDir)) { - const versions = fs.readdirSync(versionsDir) - .filter(v => /^v\d/.test(v)) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); - for (const v of versions) { - candidates.push(path.join(versionsDir, v, "bin", "claude")); - } + if (typeof queryModule.getSessionInfo !== "function") { + return null; + } + const info = await queryModule.getSessionInfo(sessionId, + projectPath ? { dir: projectPath } : undefined); + const title = info && info.summary ? info.summary.trim() : ""; + if (!title || title === (info.firstPrompt || "").trim()) { + return null; } - } catch { /* ignore */ } - return candidates; + return title; + } catch (e) { + console.log("[Phoenix AI] Session title lookup failed:", e.message); + return null; + } } /** - * Build ordered candidate paths on macOS/Linux. See _winClaudeCandidates for - * the native/fallback rationale. + * Resolve the user's globally installed Claude CLI, honouring the path + * override configured in AI Settings. Kept as a local function so the SDK + * path below reads the same as it always has; the search itself now lives + * in cli-locator.js, shared with the other CLIs the panel can drive. + * Pass `{ force: true }` to invalidate the cache after a spawn failure. + * @return {Promise} absolute path, or null when not found */ -function _posixClaudeCandidates() { - const home = process.env.HOME || ""; - const native = [ - path.join(home, ".local", "bin", "claude") // claude.ai/install.sh default - ]; - const fallback = []; - - // PATH discovery. Matters most on macOS when Phoenix is launched from - // Finder/Dock — that PATH is the minimal `/usr/bin:/bin:/usr/sbin:/sbin`, - // so `which` may miss user-managed dirs and the known locations below - // are what saves us. - try { - const allPaths = execSync("which -a claude 2>/dev/null || which claude", { encoding: "utf8" }) - .trim() - .split("\n") - .filter(p => p && !p.includes("node_modules")); - fallback.push(...allPaths); - } catch { /* which not available */ } - - fallback.push( - "/usr/local/bin/claude", // System-wide / Intel Mac Homebrew - "/usr/bin/claude", // Distro package - ..._nvmClaudeCandidates(home), // npm global via nvm - "/opt/homebrew/bin/claude", // Homebrew on Apple Silicon - "/home/linuxbrew/.linuxbrew/bin/claude" // Linuxbrew - ); - - return { native, fallback }; +function findGlobalClaudeCli(opts) { + return CliLocator.locateCli("claude", opts).then(function (result) { + return result.path; + }); } -/** - * Existence + executability check. On Windows executability is derived from - * extension/PATHEXT not a file attribute, so existsSync is the right test; - * on posix we want the +x bit. - */ -function _canAccess(p) { - if (!p) { return false; } - try { - if (isWindows) { - return fs.existsSync(p); - } - fs.accessSync(p, fs.constants.X_OK); - return true; - } catch { - return false; - } -} +// Brand names for the messages below. Not translatable and never shown +// raw — the browser maps errorCode to a localized string; these only reach +// logs and metrics. +const CLI_DISPLAY_NAMES = { claude: "Claude Code CLI", codex: "Codex CLI" }; /** - * Spawn claude with argv and resolve to { stdout, stderr, status, error }. - * Async so callers don't block the event loop while claude runs — `auth - * status` can take up to 10 s, `--version` up to 3 s, and the integrated - * terminal and file watchers share this Node process. - * - * For .exe/posix binaries: shell-less spawn, paths-with-spaces and special - * chars pass through verbatim. For Windows .cmd/.bat shims: shell:true - * (Node refuses to spawn batch files without it per CVE-2024-27980 - * hardening) plus manual command-name quoting (Node intentionally does NOT - * escape the command name under shell:true). - * - * Mimics the spawnSync result shape so callers read .status/.error/.stdout - * unchanged. `opts.timeout` (ms) kills the process with SIGKILL on expiry - * and surfaces an Error with message "timeout". + * Human-readable summary of why a CLI could not be resolved. The browser + * localizes from `errorCode`; this string is for logs, metrics, and the + * existing `_renderUnavailableUI(result.error)` path. */ -function _spawnClaude(claudePath, args, opts) { - return new Promise(function (resolve) { - const isCmdShim = isWindows && /\.(cmd|bat)$/i.test(claudePath); - const spawnCmd = isCmdShim ? `"${claudePath}"` : claudePath; - const spawnOpts = isCmdShim ? Object.assign({ shell: true }, opts) : opts; - const encoding = (opts && opts.encoding) || "utf8"; - const timeoutMs = (opts && opts.timeout) || 0; - let child; - try { - child = spawn(spawnCmd, args, spawnOpts); - } catch (err) { - resolve({ stdout: "", stderr: "", status: null, error: err }); - return; - } - let stdout = ""; - let stderr = ""; - let settled = false; - let timer = null; - function finish(result) { - if (settled) { return; } - settled = true; - if (timer) { clearTimeout(timer); } - resolve(result); - } - if (child.stdout) { - child.stdout.setEncoding(encoding); - child.stdout.on("data", function (chunk) { stdout += chunk; }); - } - if (child.stderr) { - child.stderr.setEncoding(encoding); - child.stderr.on("data", function (chunk) { stderr += chunk; }); - } - child.on("error", function (err) { - finish({ stdout, stderr, status: null, error: err }); - }); - child.on("close", function (code) { - finish({ stdout, stderr, status: code, error: null }); - }); - if (timeoutMs > 0) { - timer = setTimeout(function () { - try { child.kill("SIGKILL"); } catch { /* already exited */ } - finish({ stdout, stderr, status: null, error: new Error("timeout") }); - }, timeoutMs); - } - }); +function _cliErrorMessage(cliId, located) { + const name = CLI_DISPLAY_NAMES[cliId] || cliId; + switch (located.errorCode) { + case CliLocator.ERROR_CODES.OVERRIDE_MISSING: + return "Configured " + name + " path not found: " + (located.override && located.override.path); + case CliLocator.ERROR_CODES.OVERRIDE_NOT_EXECUTABLE: + return "Configured " + name + " path is not executable: " + (located.override && located.override.path); + case CliLocator.ERROR_CODES.OVERRIDE_INVALID: + return "Configured " + name + " path is not a working " + name; + case CliLocator.ERROR_CODES.OVERRIDE_TIMEOUT: + return "Configured " + name + " path did not respond in time"; + case CliLocator.ERROR_CODES.OVERRIDE_REJECTED: + return "Configured " + name + " path contains unsupported characters"; + default: + return name + " not found"; + } } /** - * Validate that a fallback candidate actually runs. Catches broken installs - * the existence check misses — e.g. an npm `.cmd` shim whose referenced - * cli.js was deleted by a half-completed uninstall. `claude --version` is - * fast (~200 ms healthy) and outputs a version string starting with a digit. + * Ask claude whether the user is signed in. Only claude has a + * machine-readable answer (`claude auth status` prints JSON); codex's login + * lives behind a browser/TTY flow, so its terminal shows that itself. + * @return {Promise<{loggedIn: boolean, claudePath: string|null}>} claudePath + * is re-resolved when the cached binary turns out to be gone */ -async function _validateClaudeBinary(claudePath) { +async function _probeClaudeLogin(claudePath) { + let loggedIn = false; + let result; try { - const result = await _spawnClaude(claudePath, ["--version"], { + result = await CliLocator.spawnCli(claudePath, ["auth", "status"], { encoding: "utf8", - timeout: 3000 + timeout: 10000 }); - return !result.error && result.status === 0 && /^\d/.test((result.stdout || "").trim()); - } catch { - return false; + // Spawn-level failure (ENOENT/EACCES — e.g. user uninstalled + // mid-session) means the cached binary is unusable. Invalidate + // and re-discover once. Distinct from "binary ran but exited + // non-zero", which we still treat as "not logged in". + if (result.error && result.status === null) { + const relocated = await CliLocator.locateCli("claude", { force: true }); + if (!relocated.path) { + return { loggedIn: false, claudePath: null }; + } + claudePath = relocated.path; + result = await CliLocator.spawnCli(claudePath, ["auth", "status"], { + encoding: "utf8", + timeout: 10000 + }); + } + if (result.status === 0 && result.stdout) { + const authStatus = JSON.parse(result.stdout); + loggedIn = authStatus.loggedIn === true; + } + } catch (e) { + // auth status failed — treat as not logged in } -} - -// undefined = not yet probed; null = probed, nothing works; string = resolved path -let _cachedClaudePath; -let _cachedAt = 0; -// In-flight discovery promise so concurrent callers share one walk of the -// fallback chain instead of each spawning their own --version probes. -let _inFlightDiscovery = null; -// Negative results expire so a fresh `claude` install completes during a -// session can be detected on the next checkAvailability (the install-poll -// flow depends on this). Positive results are cached indefinitely — the -// self-heal in checkAvailability handles the mid-session-uninstall case -// by passing { force: true } when a cached path stops spawning. -const NULL_CACHE_TTL_MS = 15000; - -function _setCache(p) { - _cachedClaudePath = p; - _cachedAt = Date.now(); - return p; + return { loggedIn: loggedIn, claudePath: claudePath }; } /** - * Resolve the user's globally installed Claude CLI. Walks a fallback chain: - * native candidates first (existence is enough), then PATH/known-location - * candidates, each validated by spawning `--version` so broken shims get - * skipped instead of returned. Pass `{ force: true }` to invalidate the - * cache after a runtime spawn failure. + * Whether one of the CLIs the AI panel can drive is installed and usable. + * Called from browser via execPeer("checkCliAvailability", {cli}). + * + * @param {Object} opts - `{cli}` "claude" (default) or "codex"; + * `{refresh}` bypasses the cached miss — the install/login poll loops + * pass it because they are explicitly waiting on state to change; + * `{overridePath}` resolves against that path for this call only, so + * the settings UI can test a path without committing it; + * `{probeLogin}` defaults to true for claude, false for codex. + * @return {Promise} `{cli, available, path, source, version, loggedIn, + * loginProbeSupported, error, errorCode, override, searchedPaths}` */ -function findGlobalClaudeCli(opts) { - const force = !!(opts && opts.force); - if (!force && _cachedClaudePath !== undefined) { - const fresh = _cachedClaudePath !== null - || (Date.now() - _cachedAt) < NULL_CACHE_TTL_MS; - if (fresh) { - return Promise.resolve(_cachedClaudePath); +exports.checkCliAvailability = async function (opts) { + const cliId = (opts && opts.cli) || "claude"; + const canProbeLogin = cliId === "claude"; + const probeLogin = (opts && opts.probeLogin !== undefined) ? !!opts.probeLogin : canProbeLogin; + try { + const locateOpts = {}; + if (opts && opts.refresh) { + locateOpts.force = true; } - } - if (!force && _inFlightDiscovery) { - return _inFlightDiscovery; - } - const discovery = (async function () { - const { native, fallback } = isWindows ? _winClaudeCandidates() : _posixClaudeCandidates(); - for (const p of native) { - if (_canAccess(p)) { - console.log("[Phoenix AI] Found native Claude CLI at:", p); - return _setCache(p); - } + if (opts && opts.overridePath !== undefined) { + locateOpts.override = opts.overridePath; } - for (const p of fallback) { - if (_canAccess(p) && await _validateClaudeBinary(p)) { - console.log("[Phoenix AI] Validated Claude CLI at:", p); - return _setCache(p); - } + const located = await CliLocator.locateCli(cliId, locateOpts); + const base = { + cli: cliId, + loginProbeSupported: canProbeLogin, + source: located.source, + version: located.version, + override: located.override, + searchedPaths: located.searchedPaths + }; + if (!located.path) { + return Object.assign(base, { + available: false, + path: null, + error: _cliErrorMessage(cliId, located), + errorCode: located.errorCode + }); } - console.log("[Phoenix AI] Global Claude CLI not found"); - return _setCache(null); - })(); - if (!force) { - _inFlightDiscovery = discovery; - discovery.finally(function () { - if (_inFlightDiscovery === discovery) { - _inFlightDiscovery = null; + let cliPath = located.path; + let loggedIn; + if (probeLogin && canProbeLogin) { + const login = await _probeClaudeLogin(cliPath); + if (!login.claudePath) { + return Object.assign(base, { + available: false, + path: null, + error: _cliErrorMessage(cliId, { errorCode: CliLocator.ERROR_CODES.NOT_FOUND }), + errorCode: CliLocator.ERROR_CODES.NOT_FOUND + }); } + cliPath = login.claudePath; + loggedIn = login.loggedIn; + } + return Object.assign(base, { + available: true, + path: cliPath, + loggedIn: loggedIn, + error: null, + errorCode: null }); + } catch (err) { + return { + cli: cliId, + available: false, + path: null, + loginProbeSupported: canProbeLogin, + error: err.message, + errorCode: CliLocator.ERROR_CODES.NOT_FOUND + }; } - return discovery; -} +}; /** * Check whether Claude CLI is available. * Called from browser via execPeer("checkAvailability"). + * + * Kept as its own peer on top of checkCliAvailability: several browser call + * sites read `claudePath` and the login state, and that legacy key belongs + * on a claude-shaped result rather than becoming a lie on a codex one. */ exports.checkAvailability = async function (opts) { - try { - // Poll loops (install/login screens) pass { refresh: true } because - // they're explicitly waiting on state changes — the cached null - // would otherwise make detection lag by up to NULL_CACHE_TTL_MS. - const refresh = !!(opts && opts.refresh); - let claudePath = await findGlobalClaudeCli(refresh ? { force: true } : undefined); - if (!claudePath) { - return { available: false, claudePath: null, error: "Claude Code CLI not found" }; - } - // Check if user is logged in - let loggedIn = false; - let result; - try { - result = await _spawnClaude(claudePath, ["auth", "status"], { - encoding: "utf8", - timeout: 10000 - }); - // Spawn-level failure (ENOENT/EACCES — e.g. user uninstalled - // mid-session) means the cached binary is unusable. Invalidate - // and re-discover once. Distinct from "binary ran but exited - // non-zero", which we still treat as "not logged in". - if (result.error && result.status === null) { - claudePath = await findGlobalClaudeCli({ force: true }); - if (!claudePath) { - return { available: false, claudePath: null, error: "Claude Code CLI not found" }; - } - result = await _spawnClaude(claudePath, ["auth", "status"], { - encoding: "utf8", - timeout: 10000 - }); - } - if (result.status === 0 && result.stdout) { - const authStatus = JSON.parse(result.stdout); - loggedIn = authStatus.loggedIn === true; - } - } catch (e) { - // auth status failed — treat as not logged in - } - return { available: true, claudePath: claudePath, loggedIn: loggedIn }; - } catch (err) { - return { available: false, claudePath: null, error: err.message }; + const result = await exports.checkCliAvailability( + Object.assign({}, opts, { cli: "claude", probeLogin: true })); + result.claudePath = result.path; + return result; +}; + +/** + * Record the CLI executable paths the user configured in AI Settings. + * Called from browser via execPeer("setCliPathOverrides", {claude, codex}). + * An empty string clears an override and restores auto-detection. + */ +exports.setCliPathOverrides = async function (params) { + const applied = CliLocator.setOverrides(params || {}); + console.log("[Phoenix AI] CLI path overrides:", JSON.stringify(applied)); + return { applied: applied }; +}; + +/** + * Test one CLI path without disturbing the cache — for the settings UI, so + * it never has to reimplement what counts as a working CLI. + * Called from browser via execPeer("validateCliPath", {cli, path}). + */ +exports.validateCliPath = async function (params) { + const cliId = (params && params.cli) || "claude"; + return CliLocator.validateCliPath(cliId, (params && params.path) || ""); +}; + +/** + * How to spawn a CLI in a PTY: availability plus the command/args the + * terminal should use. Callers must not spawn `path` directly — on Windows + * an npm-installed CLI resolves to a `.cmd` shim, which node-pty cannot + * execute (CreateProcess runs .exe/.com only), so it has to go through + * `cmd.exe /c`. + * Called from browser via execPeer("getCliSpawnProfile", {cli}). + */ +exports.getCliSpawnProfile = async function (params) { + const cliId = (params && params.cli) || "claude"; + const result = await exports.checkCliAvailability({ + cli: cliId, + probeLogin: false, + overridePath: params && params.overridePath + }); + if (!result.available) { + return Object.assign({}, result, { command: null, args: [] }); } + const profile = CliLocator.getSpawnProfile(result.path); + return Object.assign({}, result, { command: profile.command, args: profile.args }); }; /** @@ -712,7 +825,8 @@ exports.checkAvailability = async function (opts) { * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete */ exports.sendPrompt = async function (params) { - const { prompt, projectPath, sessionAction, model, locale, selectionContext, images, envOverrides, permissionMode, additionalDirectories } = params; + const { prompt, projectPath, sessionAction, model, locale, selectionContext, editorContext, + images, envOverrides, permissionMode, additionalDirectories } = params; const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); // Handle session @@ -731,6 +845,12 @@ exports.sendPrompt = async function (params) { currentAbortController = new AbortController(); + // Prepend what the user is looking at. The panel knows the active file, + // the unsaved buffers and the live preview target for certain, so stating + // them costs one line and removes the reason to go hunting: without it + // the model opens by grepping the project for a file already on screen. + const editorContextLine = _buildEditorContextLine(editorContext); + // Prepend selection context to the prompt if available let enrichedPrompt = prompt; if (selectionContext) { @@ -753,6 +873,9 @@ exports.sendPrompt = async function (params) { " to read the selected content if needed." + previewSnippet + "\n" + prompt; } } + if (editorContextLine) { + enrichedPrompt = editorContextLine + "\n\n" + enrichedPrompt; + } // Run the query asynchronously — don't await here so we return requestId immediately _runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images, envOverrides, permissionMode, additionalDirectories) @@ -772,11 +895,8 @@ exports.cancelQuery = async function () { currentAbortController = null; // Keep currentSessionId so the next prompt resumes the same SDK session. // Aborts leave an interrupt marker in the session log, not a corrupted state. - // Clear any pending question or plan - _questionResolve = null; - _planResolve = null; - _bashConfirmResolve = null; - _planModeConfirmResolve = null; + // Drop any cards still waiting for an answer + _clearPendingAnswers(); _queuedClarification = null; return { success: true }; } @@ -788,10 +908,7 @@ exports.cancelQuery = async function () { * Called from browser via execPeer("answerQuestion", {answers}). */ exports.answerQuestion = async function (params) { - if (_questionResolve) { - _questionResolve(params); - _questionResolve = null; - } + _resolveAnswer("question", params); return { success: true }; }; @@ -800,10 +917,7 @@ exports.answerQuestion = async function (params) { * Called from browser via execPeer("answerPlan", {approved, feedback}). */ exports.answerPlan = async function (params) { - if (_planResolve) { - _planResolve(params); - _planResolve = null; - } + _resolveAnswer("plan", params); return { success: true }; }; @@ -812,10 +926,7 @@ exports.answerPlan = async function (params) { * Called from browser via execPeer("answerBashConfirm", {allowed}). */ exports.answerBashConfirm = async function (params) { - if (_bashConfirmResolve) { - _bashConfirmResolve(params); - _bashConfirmResolve = null; - } + _resolveAnswer("toolConfirm", params); return { success: true }; }; @@ -824,10 +935,7 @@ exports.answerBashConfirm = async function (params) { * Called from browser via execPeer("answerPlanModeWriteConfirm", {approved}). */ exports.answerPlanModeWriteConfirm = async function (params) { - if (_planModeConfirmResolve) { - _planModeConfirmResolve(params); - _planModeConfirmResolve = null; - } + _resolveAnswer("planModeConfirm", params); return { success: true }; }; @@ -865,9 +973,7 @@ exports.resumeSession = async function (params) { currentAbortController.abort(); currentAbortController = null; } - _questionResolve = null; - _planResolve = null; - _bashConfirmResolve = null; + _clearPendingAnswers(); _queuedClarification = null; currentSessionId = params.sessionId; return { success: true }; @@ -876,6 +982,35 @@ exports.resumeSession = async function (params) { /** * Destroy the current session (clear session ID). */ +/** + * AI titles for sessions already recorded in the panel's history. + * + * Lets the history list upgrade entries whose stored title is still the + * first user message truncated mid-sentence — the CLI's own title has + * been sitting in those transcripts all along. Sessions with nothing + * better than the raw first prompt are simply left out of the result. + * + * @param {{projectPath: string, sessionIds: Array}} params + * @return {Promise} map of sessionId -> title + */ +exports.getSessionTitles = async function (params) { + const { projectPath, sessionIds } = params || {}; + const titles = {}; + if (!Array.isArray(sessionIds)) { + return titles; + } + // Sequential: each lookup parses one session transcript, and these can + // be large. A history list is at most a few dozen entries, and this + // runs behind an already-rendered dropdown, so latency is not critical. + for (const sessionId of sessionIds) { + const title = await _getAISessionTitle(sessionId, projectPath); + if (title) { + titles[sessionId] = title; + } + } + return titles; +}; + exports.destroySession = async function () { if (currentAbortController) { currentAbortController.abort(); @@ -907,12 +1042,16 @@ exports.queueClarification = async function (params) { }; /** - * Get and clear the queued clarification (text + images). - * Called by the getUserClarification MCP tool. + * Get and clear the queued clarification (text + images). Called by the + * getUserClarification MCP tool; tells the panel so the queue bubble + * becomes a sent message. */ exports.getAndClearClarification = async function () { const result = _queuedClarification; _queuedClarification = null; + if (result && (result.text || result.images.length)) { + nodeConnector.triggerPeer("aiClarificationRead", { text: result.text || "" }); + } return result || { text: null, images: [] }; }; @@ -938,6 +1077,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a // tool_result block can be mapped back to its indicator on the browser. const _toolUseIdToCounter = {}; + // tool_use id → SDK tool name, so a tool_result can be interpreted in the + // light of which tool produced it (see the AskUserQuestion note below). + const _toolUseIdToName = {}; // Set true once the user clicks "Allow & Switch to Edit Mode" on a // plan-mode write confirmation. Subsequent Edit/Write attempts in the same // turn skip the prompt and use the cached "allow" decision so a multi-edit @@ -1035,6 +1177,43 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // rather than seeing a literal []. Each sendPrompt rebuilds this // list, so adding/removing in the UI takes effect on the next turn. const _cwdForValidation = projectPath || process.cwd(); + // Where Edit/Write may land without asking: the project, any extra + // directories the user attached, and scratch space. Anything else gets + // the permission card — the same "write outside the working directory?" + // check Claude Code makes on its own, which the Write/Edit entries in + // allowedTools would otherwise skip. Seen in the wild: after + // `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)) { + return false; + } + const target = path.resolve(filePath); + const roots = [_cwdForValidation, os.tmpdir(), "/tmp"].concat(validatedExtraDirs || []); + return !roots.some(function (root) { + const r = path.resolve(root); + return target === r || target.startsWith(r + path.sep); + }); + } + async function _denyUnlessOutsideWriteAllowed(toolName, toolInput, promptSignal) { + const filePath = toolInput && toolInput.file_path; + if (_runtimePermissionMode === "bypassPermissions" || !_isOutsideWriteRoots(filePath)) { + return null; + } + _log("Write outside project roots:", filePath); + const allowed = await _askToolConfirm(requestId, toolName, toolInput, promptSignal); + if (allowed) { + return null; + } + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User declined to write outside the project folder (" + + filePath + "). Keep project files under " + _cwdForValidation + "." + } + }; + } const validatedExtraDirs = (Array.isArray(additionalDirectories) ? additionalDirectories.filter(function (p) { if (typeof p !== "string" || !path.isAbsolute(p)) { return false; } @@ -1043,6 +1222,133 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, }) : []); + // Permission-prompt handler (SDK canUseTool). Passing it makes the SDK + // launch the CLI with --permission-prompt-tool, and that is what keeps + // ExitPlanMode, EnterPlanMode and AskUserQuestion in the model's tool + // list: the CLI drops every tool that needs user interaction from a + // non-interactive session that has no prompt tool. Without it the model + // in Plan Mode writes the plan file and then has nothing to propose it + // with — it ends the turn, or (worse, after a subagent hands back + // research) keeps circling looking for a way out of plan mode. + // + // The PreToolUse hooks below still run first and settle most calls; + // the CLI only sends here what its permission pipeline marks "ask". + // Tools flagged requiresUserInteraction (ExitPlanMode, AskUserQuestion) + // always land here regardless of allowedTools. + async function _onPermissionRequest(toolName, input, opts) { + const promptSignal = (opts && opts.signal) || signal; + // Why the CLI is asking. In Auto this is the classifier deciding it + // wants a human, which is the whole point of the mode — logging it + // tells a genuine ask apart from a silent auto-allow. + const askParts = ["Permission ask:", toolName, "mode=" + _runtimePermissionMode]; + if (opts && opts.decisionReason) { + askParts.push("reason=" + opts.decisionReason); + } + if (opts && opts.blockedPath) { + askParts.push("blockedPath=" + opts.blockedPath); + } + _log.apply(null, askParts); + if (toolName === "ExitPlanMode") { + return _onExitPlanModeRequest(input, promptSignal); + } + if (toolName === "AskUserQuestion") { + // Normally intercepted by the PreToolUse hook; kept as a + // fallback so a question can never dead-end in a deny. + const questions = (input && input.questions) || []; + const answer = await _askUserQuestions(requestId, questions, promptSignal); + if (!answer) { + return { behavior: "deny", message: "Question cancelled." }; + } + return { + behavior: "allow", + updatedInput: Object.assign({}, input, { answers: answer.answers || {} }) + }; + } + if (FILE_WRITE_TOOLS.indexOf(toolName) !== -1 && _runtimePermissionMode === "plan") { + // Plan mode entered mid-turn via EnterPlanMode: the Edit/Write + // hooks saw the query-start mode and passed the call through, + // so the CLI's own plan-mode block asks us. Same card as the + // hook path, same one-shot approval for the rest of the turn. + if (_planExitApprovedThisTurn) { + return { behavior: "allow", updatedInput: input }; + } + const filePath = (input && input.file_path) || ""; + const approved = await _askPlanModeWriteConfirm( + requestId, toolName, filePath, promptSignal); + if (!approved) { + return { + behavior: "deny", + message: "User chose to stay in Plan Mode. Use the ExitPlanMode " + + "tool to propose your changes for approval before editing." + }; + } + _planExitApprovedThisTurn = true; + _runtimePermissionMode = "auto"; + return { behavior: "allow", updatedInput: input }; + } + // Anything else the CLI wants a human decision on: Bash or a + // non-read-only MCP tool in Plan Mode, a classifier ask in Auto, a + // tool outside allowedTools. With no prompt tool the CLI used to + // deny these on its own and nothing ever reached the panel — the + // user just saw the model give up. Put up the card. + const allowed = await _askToolConfirm(requestId, toolName, input, promptSignal); + if (allowed) { + return { behavior: "allow", updatedInput: input }; + } + return { behavior: "deny", message: "User denied permission for " + toolName + "." }; + } + + // ExitPlanMode permission prompt: render the plan card in the browser and + // block the tool until the user decides. Approve → "allow": the CLI leaves + // plan mode and the model keeps going in this turn (PLAN_APPROVED_HINT + // rides in on the PostToolUse hook). Revise → "deny" with the feedback: + // the model stays in plan mode, reworks the plan and calls ExitPlanMode + // again, which lands right back here with a fresh card. + async function _onExitPlanModeRequest(input, promptSignal) { + const planText = (input && input.plan) || _lastPlanContent || ""; + _lastPlanContent = null; + if (!planText) { + _log("ExitPlanMode with no plan content"); + return { + behavior: "deny", + message: "No plan content found. Write the plan to your plan file " + + "(or pass it in the plan argument) and call ExitPlanMode again." + }; + } + _log("ExitPlanMode plan (" + planText.length + "ch), waiting for user"); + const pending = _registerAnswer("plan", promptSignal); + nodeConnector.triggerPeer("aiPlanProposed", { + requestId: requestId, + confirmId: pending.id, + plan: planText + }); + const response = await pending.promise; + if (!response) { + _log("Plan review cancelled"); + return { behavior: "deny", message: "Plan review cancelled by the user." }; + } + if (!response.approved) { + _log("Plan rejected by user, asking for a revision"); + const feedback = response.feedback || "Please revise the plan."; + return { + behavior: "deny", + message: "The user rejected the plan and wants changes: " + feedback + + "\nStay in plan mode, revise the plan based on this feedback, and " + + "call ExitPlanMode again to propose the updated plan for approval." + }; + } + _log("Plan approved by user, continuing in this turn"); + _planExitApprovedThisTurn = true; + // The browser pushes the restored UI mode via setPermissionMode + // before answering; only fill in if it hasn't. Auto (classifier + // approved) is the landing mode after a plan — it suits the + // implementation phase better than manual Edit Mode confirms. + if (_runtimePermissionMode === "plan") { + _runtimePermissionMode = "auto"; + } + return { behavior: "allow", updatedInput: input }; + } + const queryOptions = { cwd: projectPath || process.cwd(), additionalDirectories: validatedExtraDirs.length ? validatedExtraDirs : undefined, @@ -1062,18 +1368,36 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, _hookErrorTimer = setTimeout(_flushHookError, HOOK_ERROR_FLUSH_MS); } }, + // Permission allow-rules, not a tool availability list. Bash is + // deliberately absent so that nothing here can pre-approve a shell + // command: every one is judged by the permission pipeline, and in + // Auto that means the SDK's classifier, whose "ask" verdicts reach + // canUseTool below as the panel's Allow/Deny card. The CLI happens + // to ignore a Bash allow rule anyway ("Ignoring dangerous permission + // Bash(*) from cliArg (bypasses classifier)"), so leaving it out + // simply stops the list from implying otherwise. Edit Mode still + // uses the manual confirm in the Bash PreToolUse hook below, and + // Allow Everything (bypassPermissions) skips permission checks. allowedTools: [ - "Read", "Edit", "Write", "Glob", "Grep", "Bash", + "Read", "Edit", "Write", "Glob", "Grep", "AskUserQuestion", "Task", "Agent", + // Background-subagent plumbing: lets the main agent relay a + // user follow-up to a running subagent (SendMessage), read its + // output, or stop it — the CLI's own way of steering subagents. + "SendMessage", "TaskOutput", "TaskStop", "TodoRead", "TodoWrite", "TaskCreate", "TaskUpdate", "TaskList", "TaskGet", "WebFetch", "WebSearch", "EnterPlanMode", "ExitPlanMode", "mcp__phoenix-editor__getEditorState", + "mcp__phoenix-editor__searchEditorBuffers", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", "mcp__phoenix-editor__execJsInEditor", - "mcp__phoenix-editor__editorPreferences", + // editorPreferences is absent for the same reason as Bash: it can + // write, so in Auto the classifier should weigh each call rather + // 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__controlEditor", "mcp__phoenix-editor__resizeLivePreview", @@ -1088,6 +1412,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, " files to answer questions. Do not modify files.", tools: ["Read", "Glob", "Grep", "mcp__phoenix-editor__getEditorState", + "mcp__phoenix-editor__searchEditorBuffers", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", "mcp__phoenix-editor__editorDocs"] @@ -1100,6 +1425,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, " only for new files.", tools: ["Read", "Edit", "Write", "Glob", "Grep", "mcp__phoenix-editor__getEditorState", + "mcp__phoenix-editor__searchEditorBuffers", "mcp__phoenix-editor__takeScreenshot", "mcp__phoenix-editor__execJsInLivePreview", "mcp__phoenix-editor__execJsInEditor", @@ -1237,8 +1563,19 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "Respond in this language unless they write in a different language." : ""), includePartialMessages: true, + canUseTool: _onPermissionRequest, abortController: currentAbortController, - env: envOverrides ? Object.assign({}, process.env, envOverrides) : undefined, + // Background tasks off: the CLI otherwise auto-backgrounds a + // subagent that runs longer than ~10s, hands the main agent an + // "async agent launched" result, and once the main turn ends the + // backgrounded agent's tool context stays aborted — every tool it + // calls afterwards comes back as "The user doesn't want to take + // this action right now", and the model stops and waits for a + // user who never said no. Keeping subagents synchronous is the + // flow the panel can actually drive (and the CLI's own default + // for non-interactive use is the same waiting behaviour). + env: Object.assign({}, process.env, + { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" }, envOverrides || {}), hooks: { PreToolUse: [ { @@ -1273,10 +1610,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } catch (err) { console.warn("[Phoenix AI] Failed to edit plan file:", err.message); } - let planReason = "Plan file updated."; - if (_queuedClarification) { - planReason += CLARIFICATION_HINT; - } + const planReason = "Plan file updated." + _clarificationHintFor(input); return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -1285,6 +1619,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } + const outsideDenial = await _denyUnlessOutsideWriteAllowed( + "Edit", input.tool_input, signal); + if (outsideDenial) { + return outsideDenial; + } // Plan mode + user-file Edit: ask the user whether // to switch to Edit Mode. Mirrors the Bash confirm // pattern (matcher: "Bash"). Once approved, the @@ -1292,36 +1631,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // prompt for subsequent edits in the same turn. const filePath = input.tool_input.file_path; if (permissionMode === "plan" && !_planExitApprovedThisTurn) { - nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { - requestId: requestId, - toolName: "Edit", - filePath: filePath - }); - let response; - try { - response = await new Promise((resolve, reject) => { - _planModeConfirmResolve = resolve; - if (signal.aborted) { - _planModeConfirmResolve = null; - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - _planModeConfirmResolve = null; - reject(new Error("Aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - } catch (err) { - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "Edit cancelled." - } - }; - } - if (!response.approved) { + const approved = await _askPlanModeWriteConfirm( + requestId, "Edit", filePath, signal); + if (!approved) { return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -1357,13 +1669,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // found". Phoenix sees the buffer state the SDK // can't, so this is a more useful failure. if (oldString && (captured.content || "").indexOf(oldString) === -1) { - let reason = "Edit FAILED: the text you wanted to replace is not " + + const reason = "Edit FAILED: the text you wanted to replace is not " + "present in the file. It may have been modified by the user " + "or by another tool since you last read it. Read the file again " + - "to see the current content before retrying."; - if (_queuedClarification) { - reason += CLARIFICATION_HINT; - } + "to see the current content before retrying." + + _clarificationHintFor(input); return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -1435,10 +1745,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } catch (err) { console.warn("[Phoenix AI] Failed to write plan file:", err.message); } - let planReason = "Plan file saved."; - if (_queuedClarification) { - planReason += CLARIFICATION_HINT; - } + const planReason = "Plan file saved." + _clarificationHintFor(input); return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -1447,40 +1754,18 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } + const outsideDenial = await _denyUnlessOutsideWriteAllowed( + "Write", input.tool_input, signal); + if (outsideDenial) { + return outsideDenial; + } // Plan mode + user-file Write: same confirmation // path as Edit. See Edit hook above for rationale. const filePath = input.tool_input.file_path; if (permissionMode === "plan" && !_planExitApprovedThisTurn) { - nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { - requestId: requestId, - toolName: "Write", - filePath: filePath - }); - let response; - try { - response = await new Promise((resolve, reject) => { - _planModeConfirmResolve = resolve; - if (signal.aborted) { - _planModeConfirmResolve = null; - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - _planModeConfirmResolve = null; - reject(new Error("Aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - } catch (err) { - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "Write cancelled." - } - }; - } - if (!response.approved) { + const approved = await _askPlanModeWriteConfirm( + requestId, "Write", filePath, signal); + if (!approved) { return { hookSpecificOutput: { hookEventName: "PreToolUse", @@ -1572,26 +1857,19 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, }; } console.log("[Phoenix AI] Bash confirmation requested:", command.slice(0, 80)); - nodeConnector.triggerPeer("aiBashConfirm", { - requestId: requestId, - command: command, - toolId: toolCounter - }); - const response = await new Promise((resolve, reject) => { - _bashConfirmResolve = resolve; - if (signal.aborted) { - _bashConfirmResolve = null; - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - _bashConfirmResolve = null; - reject(new Error("Aborted")); + const allowed = await _askToolConfirm( + requestId, "Bash", input.tool_input, signal); + if (allowed) { + // Explicit allow, not {}: with Bash off the + // allow list, "no opinion" would send a + // command the user just approved on to the + // CLI's own permission check. + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - if (response.allowed) { - return {}; } return { hookSpecificOutput: { @@ -1603,43 +1881,63 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } ] }, + { + // Reading a preference is free of side effects, so allow + // it outright: no card, and no classifier round-trip to + // sit through either. Writes return {} and take the + // normal route — the classifier in Auto, the card + // elsewhere. + matcher: EDITOR_PREFS_TOOL, + hooks: [ + async (input) => { + if (!_isPreferenceRead(input && input.tool_input)) { + return {}; + } + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; + } + ] + }, + { + // Built-in agents (Explore, Plan, general-purpose) inherit + // every tool, including this one. Keep the user's follow-up + // for the main agent — see _clarificationHintFor. + matcher: "mcp__phoenix-editor__getUserClarification", + hooks: [ + async (input) => { + if (!input || !input.agent_id) { + return {}; + } + console.log("[Phoenix AI] Blocked getUserClarification from subagent"); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Only the main agent reads the user's " + + "follow-up. Finish your task and return your findings; " + + "the main agent will handle the user's message." + } + }; + } + ] + }, { matcher: "AskUserQuestion", hooks: [ async (input) => { console.log("[Phoenix AI] Intercepted AskUserQuestion"); const questions = input.tool_input.questions || []; - nodeConnector.triggerPeer("aiQuestion", { - requestId: requestId, - questions: questions - }); // Wait for the user's answer from the browser UI - const answer = await new Promise((resolve, reject) => { - _questionResolve = resolve; - if (signal.aborted) { - _questionResolve = null; - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - _questionResolve = null; - reject(new Error("Aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - // Format answers as readable text for the AI - let answerText = ""; - if (answer.answers) { - const keys = Object.keys(answer.answers); - keys.forEach(function (q) { - answerText += "Q: " + q + "\nA: " + answer.answers[q] + "\n\n"; - }); - } + const answer = await _askUserQuestions(requestId, questions, signal); return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", - permissionDecisionReason: answerText.trim() || "No answer provided" + permissionDecisionReason: _formatAnswers(answer) || "No answer provided" } }; } @@ -1647,6 +1945,32 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } ], PostToolUse: [ + { + // Model flipped itself into plan mode: keep the runtime + // tracker honest so the Bash hook and canUseTool see it. + matcher: "EnterPlanMode", + hooks: [ + async () => { + _runtimePermissionMode = "plan"; + return {}; + } + ] + }, + { + // Runs only when ExitPlanMode was allowed, i.e. the + // user approved the plan. + matcher: "ExitPlanMode", + hooks: [ + async () => { + return { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: PLAN_APPROVED_HINT + } + }; + } + ] + }, { matcher: "Edit", hooks: [ @@ -1847,8 +2171,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // cleared by _takeLivePreviewHint, so that half cannot repeat. function _buildPostToolUseHint(input) { const parts = []; - if (_queuedClarification) { - parts.push(CLARIFICATION_HINT); + const clarificationHint = _clarificationHintFor(input); + if (clarificationHint) { + parts.push(clarificationHint); } const lpHint = _takeLivePreviewHint(input && input.tool_name ? [input.tool_name] : null); if (lpHint) { @@ -1929,9 +2254,15 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, let accumulatedText = ""; let lastStreamTime = 0; - // Tool input tracking (parent-level) + // Tool input tracking (parent-level). activeToolCounter is the id + // announced at content_block_start; every later event for the block + // must reuse it. toolCounter itself keeps moving while the block + // streams — a subagent's batched tool_use can land in between — so + // reading toolCounter at delta/stop time sends the parent's input to + // the subagent's card and leaves the parent card spinning forever. let activeToolName = null; let activeToolIndex = null; + let activeToolCounter = null; let activeToolInputJson = ""; let lastToolStreamTime = 0; @@ -1939,6 +2270,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, let subagentToolName = null; let subagentToolIndex = null; let subagentToolInputJson = ""; + let subagentToolCounter = null; + let subagentParentToolId; let lastSubagentToolStreamTime = 0; // Trace counters (logged at tool/query completion, not per-delta) @@ -2021,9 +2354,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, const parentToolId = _toolUseIdToCounter[message.parent_tool_use_id]; for (const block of message.message.content) { if (block && block.type === "tool_use") { + if (block.id && _toolUseIdToCounter[block.id] !== undefined) { + // Already announced from the stream_event path. + continue; + } toolCounter++; if (block.id) { _toolUseIdToCounter[block.id] = toolCounter; + _toolUseIdToName[block.id] = block.name; } _log("Subagent tool:", block.name, "#" + toolCounter, "parent=#" + (parentToolId !== undefined ? parentToolId : "?")); @@ -2120,13 +2458,23 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, subagentToolName = event.content_block.name; subagentToolIndex = event.index; subagentToolInputJson = ""; + subagentParentToolId = _toolUseIdToCounter[message.parent_tool_use_id]; toolCounter++; + subagentToolCounter = toolCounter; lastSubagentToolStreamTime = 0; - _log("Subagent tool start:", subagentToolName, "#" + toolCounter); + // Register the id so the batched assistant message + // for the same call is skipped and tool_result maps + // back to this indicator. + if (event.content_block.id) { + _toolUseIdToCounter[event.content_block.id] = subagentToolCounter; + } + _log("Subagent tool start:", subagentToolName, "#" + subagentToolCounter, + "parent=#" + (subagentParentToolId !== undefined ? subagentParentToolId : "?")); nodeConnector.triggerPeer("aiProgress", { requestId: requestId, toolName: subagentToolName, - toolId: toolCounter, + toolId: subagentToolCounter, + parentToolId: subagentParentToolId, phase: "tool_use" }); } @@ -2142,7 +2490,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, lastSubagentToolStreamTime = now; nodeConnector.triggerPeer("aiToolStream", { requestId: requestId, - toolId: toolCounter, + toolId: subagentToolCounter, toolName: subagentToolName, partialJson: subagentToolInputJson }); @@ -2156,7 +2504,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (subagentToolInputJson) { nodeConnector.triggerPeer("aiToolStream", { requestId: requestId, - toolId: toolCounter, + toolId: subagentToolCounter, toolName: subagentToolName, partialJson: subagentToolInputJson }); @@ -2167,16 +2515,18 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } catch (e) { // ignore parse errors } - _log("Subagent tool done:", subagentToolName, "#" + toolCounter, + _log("Subagent tool done:", subagentToolName, "#" + subagentToolCounter, "json=" + subagentToolInputJson.length + "ch"); nodeConnector.triggerPeer("aiToolInfo", { requestId: requestId, toolName: subagentToolName, - toolId: toolCounter, + toolId: subagentToolCounter, + parentToolId: subagentParentToolId, toolInput: toolInput }); subagentToolName = null; subagentToolIndex = null; + subagentToolCounter = null; subagentToolInputJson = ""; } @@ -2206,19 +2556,21 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, activeToolIndex = event.index; activeToolInputJson = ""; toolCounter++; + activeToolCounter = toolCounter; toolDeltaCount = 0; toolStreamSendCount = 0; lastToolStreamTime = 0; // Map the SDK's tool_use id → our toolCounter so we can // correlate later tool_result blocks back to the indicator. if (event.content_block.id) { - _toolUseIdToCounter[event.content_block.id] = toolCounter; + _toolUseIdToCounter[event.content_block.id] = activeToolCounter; + _toolUseIdToName[event.content_block.id] = activeToolName; } - _log("Tool start:", activeToolName, "#" + toolCounter); + _log("Tool start:", activeToolName, "#" + activeToolCounter); nodeConnector.triggerPeer("aiProgress", { requestId: requestId, toolName: activeToolName, - toolId: toolCounter, + toolId: activeToolCounter, phase: "tool_use" }); } @@ -2236,7 +2588,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, toolStreamSendCount++; nodeConnector.triggerPeer("aiToolStream", { requestId: requestId, - toolId: toolCounter, + toolId: activeToolCounter, toolName: activeToolName, partialJson: activeToolInputJson }); @@ -2252,7 +2604,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, toolStreamSendCount++; nodeConnector.triggerPeer("aiToolStream", { requestId: requestId, - toolId: toolCounter, + toolId: activeToolCounter, toolName: activeToolName, partialJson: activeToolInputJson }); @@ -2263,56 +2615,19 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } catch (e) { // ignore parse errors } - _log("Tool done:", activeToolName, "#" + toolCounter, + _log("Tool done:", activeToolName, "#" + activeToolCounter, "deltas=" + toolDeltaCount, "sent=" + toolStreamSendCount, "json=" + activeToolInputJson.length + "ch"); nodeConnector.triggerPeer("aiToolInfo", { requestId: requestId, toolName: activeToolName, - toolId: toolCounter, + toolId: activeToolCounter, toolInput: toolInput }); - // ExitPlanMode: show plan to user and wait for approval - // Plan text comes from a prior Write to .claude/plans/ (captured in hook) - if (activeToolName === "ExitPlanMode") { - const planText = toolInput.plan || _lastPlanContent || ""; - _lastPlanContent = null; - if (planText) { - _log("ExitPlanMode plan detected (" + planText.length + "ch), sending to browser"); - nodeConnector.triggerPeer("aiPlanProposed", { - requestId: requestId, - plan: planText - }); - // Pause stream processing until user approves/rejects - const planResponse = await new Promise((resolve, reject) => { - _planResolve = resolve; - if (signal.aborted) { - _planResolve = null; - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - _planResolve = null; - reject(new Error("Aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - if (!planResponse.approved) { - _log("Plan rejected by user, aborting"); - currentAbortController.abort(); - _planRejectionFeedback = planResponse.feedback || ""; - } else { - _log("Plan approved by user, will send proceed prompt"); - _planApproved = true; - } - } else { - _log("ExitPlanMode with no plan content, skipping UI"); - } - } - activeToolName = null; activeToolIndex = null; + activeToolCounter = null; activeToolInputJson = ""; } @@ -2365,10 +2680,22 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // on the corresponding tool indicator (errored vs ran). const counterId = _toolUseIdToCounter[block.tool_use_id]; if (counterId !== undefined) { + // A question is answered by DENYING the tool call and + // handing the user's answer back as the denial reason + // (see the AskUserQuestion PreToolUse hook). The CLI + // reports every deny as an error result, so without + // this the panel painted a red "failed" badge on a + // question the user had just answered normally — and + // counted every question as a tool error in metrics. + // The deny is our transport, not a failure; an + // unanswered question means the user cancelled or + // stopped the turn, which is not a failure either. + const resultToolName = _toolUseIdToName[block.tool_use_id]; + const isAnsweredByDeny = resultToolName === "AskUserQuestion"; nodeConnector.triggerPeer("aiToolResult", { requestId: requestId, toolId: counterId, - isError: !!block.is_error, + isError: !!block.is_error && !isAnsweredByDeny, preview: preview }); } @@ -2390,36 +2717,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, _log("Complete: tools=" + toolCounter, "edits=" + editCount, "textDeltas=" + textDeltaCount, "textSent=" + textStreamSendCount); - // Check if plan was approved — send follow-up to proceed with implementation - if (_planApproved) { - _planApproved = false; - _log("Plan approved, sending proceed prompt"); - nodeConnector.triggerPeer("aiComplete", { - requestId: requestId, - sessionId: currentSessionId, - planApproved: true - }); - return; - } - - // Check if stream ended due to plan rejection (abort + break) - if (_planRejectionFeedback !== null) { - const feedback = _planRejectionFeedback; - _planRejectionFeedback = null; - _log("Plan rejected, sending revision request"); - nodeConnector.triggerPeer("aiComplete", { - requestId: requestId, - sessionId: currentSessionId, - planRejected: true, - planFeedback: feedback - }); - return; - } - // Signal completion nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: currentSessionId + sessionId: currentSessionId, + sessionTitle: await _getAISessionTitle(currentSessionId, projectPath) }); } catch (err) { @@ -2428,26 +2730,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, const isAbort = signal.aborted || /abort/i.test(errMsg); if (isAbort) { - // Check if this was a plan rejection — if so, send feedback as follow-up - if (_planRejectionFeedback !== null) { - const feedback = _planRejectionFeedback; - _planRejectionFeedback = null; - _log("Plan rejected, sending revision request"); - // Don't clear session — resume with feedback - nodeConnector.triggerPeer("aiComplete", { - requestId: requestId, - sessionId: currentSessionId, - planRejected: true, - planFeedback: feedback - }); - return; - } _log("Cancelled"); // Keep currentSessionId so the next prompt can resume the same SDK // session — the abort just leaves an interrupt marker in the log. nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: currentSessionId + sessionId: currentSessionId, + sessionTitle: await _getAISessionTitle(currentSessionId, projectPath) }); return; } @@ -2503,7 +2792,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // Always send aiComplete after aiError so the UI exits streaming state nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: currentSessionId + sessionId: currentSessionId, + sessionTitle: await _getAISessionTitle(currentSessionId, projectPath) }); } } diff --git a/src-node/cli-locator.js b/src-node/cli-locator.js new file mode 100644 index 0000000000..8dd6c94e0f --- /dev/null +++ b/src-node/cli-locator.js @@ -0,0 +1,633 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Locates the coding-agent CLIs Phoenix drives (`claude`, `codex`) on the + * user's machine. + * + * Every CLI is described by one entry in CLI_REGISTRY — binary name, the + * per-platform places its installers drop it, and how to recognise its + * `--version` output. Adding a third CLI is a registry entry, not new code. + * + * Resolution order for each CLI is: the user's configured override path (if + * any) → "native" candidates, whose installers are known to drop a real + * executable so existence alone is trusted → "fallback" candidates from PATH + * and known locations, each proved by actually running `--version`. + */ + +const { execSync, spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const isWindows = process.platform === "win32"; + +// PATH lookups (`where`/`which`) run synchronously and block this whole Node +// process — the integrated terminal, file watchers and MCP all share it. An +// unreachable network drive on PATH can wedge them, so cap the wait. +const PATH_LOOKUP_TIMEOUT_MS = 3000; + +// How long a `--version` probe may take before we give up on a candidate. +const VERSION_PROBE_TIMEOUT_MS = 3000; + +// Negative results expire so a fresh install completed during a session is +// detected on the next lookup (the install-poll flow depends on this). +// Positive results are cached indefinitely — the self-heal in +// checkAvailability handles the mid-session-uninstall case by forcing. +const NULL_CACHE_TTL_MS = 15000; + +// Characters that must never reach a user-supplied override path. Windows +// `.cmd`/`.bat` shims are spawned with shell:true (see spawnCli), where the +// command name is quoted but an embedded quote would break out of it. +const UNSAFE_OVERRIDE_CHARS = /["<>|\r\n]/; + +/** + * Why a lookup failed. The browser turns these into different advice, so + * they are part of the peer contract — do not collapse them. + */ +const ERROR_CODES = { + NOT_FOUND: "NOT_FOUND", // nothing on the chain worked + OVERRIDE_MISSING: "OVERRIDE_MISSING", // configured path does not exist + OVERRIDE_NOT_EXECUTABLE: "OVERRIDE_NOT_EXECUTABLE", // exists but has no +x bit + OVERRIDE_INVALID: "OVERRIDE_INVALID", // runs, but is not this CLI + OVERRIDE_TIMEOUT: "OVERRIDE_TIMEOUT", // probe timed out — retry, don't repath + OVERRIDE_REJECTED: "OVERRIDE_REJECTED" // unsafe characters in the path +}; + +/** + * The CLIs we know how to find. + * + * `versionPattern` recognises that CLI's own `--version` output. It cannot + * be one shared rule: `claude --version` prints "2.1.263 (Claude Code)" but + * `codex --version` prints "codex-cli 0.153.4", which does not start with a + * digit. Loosening the check to "any output" instead would make any exit-0 + * binary that happens to be named `codex` on PATH a match — and `codex` is a + * short, generic name. + */ +const CLI_REGISTRY = { + claude: { + id: "claude", + bin: "claude", + versionArgs: ["--version"], + versionPattern: /^\d/, + // claude.ai/install.sh and the desktop installer drop real binaries + // here — no node/cli.js shim chain to break, so existence is enough. + winNative: function () { + const userHome = process.env.USERPROFILE || process.env.HOME || ""; + return [ + path.join(userHome, ".local", "bin", "claude.exe"), + path.join(process.env.LOCALAPPDATA || "", "Programs", "claude", "claude.exe") + ]; + }, + winExtra: function () { + return [path.join(process.env.APPDATA || "", "npm", "claude.cmd")]; + }, + posixNative: function (home) { + return [path.join(home, ".local", "bin", "claude")]; // claude.ai/install.sh default + }, + posixExtra: function (home) { + return [ + "/usr/local/bin/claude", // System-wide / Intel Mac Homebrew + "/usr/bin/claude", // Distro package + ..._nvmCandidates(home, "claude"), // npm global via nvm + "/opt/homebrew/bin/claude", // Homebrew on Apple Silicon + "/home/linuxbrew/.linuxbrew/bin/claude" // Linuxbrew + ]; + } + }, + codex: { + id: "codex", + bin: "codex", + versionArgs: ["--version"], + versionPattern: /^codex(-cli)?\s+v?\d/i, + // Deliberately asymmetric with claude: only the standalone + // installer's location is trusted without proof. Codex's other + // Windows locations are educated guesses, and a native-tier entry + // returns an unvalidated path straight to pty.spawn. + winNative: function () { + const userHome = process.env.USERPROFILE || process.env.HOME || ""; + return [path.join(userHome, ".local", "bin", "codex.exe")]; + }, + winExtra: function () { + const userHome = process.env.USERPROFILE || process.env.HOME || ""; + return [ + path.join(process.env.APPDATA || "", "npm", "codex.cmd"), + path.join(process.env.LOCALAPPDATA || "", "Programs", "codex", "codex.exe"), + path.join(userHome, ".codex", "bin", "codex.exe") + ]; + }, + posixNative: function (home) { + return [ + path.join(home, ".local", "bin", "codex"), // chatgpt.com/codex/install.sh + // What that installer's symlink points at. Listed too so a + // shell alias shadowing ~/.local/bin still resolves. + path.join(home, ".codex", "packages", "standalone", "current", "bin", "codex") + ]; + }, + posixExtra: function (home) { + return [ + "/usr/local/bin/codex", + "/usr/bin/codex", + ..._nvmCandidates(home, "codex"), + "/opt/homebrew/bin/codex", // brew install --cask codex + "/home/linuxbrew/.linuxbrew/bin/codex" + ]; + } + } +}; + +const CLI_IDS = Object.keys(CLI_REGISTRY); + +// cliId -> { path, at, overrideSig, source, version, errorCode, override, searched } +const _cache = new Map(); +// cliId -> in-flight discovery promise, so concurrent callers share one walk +// of the fallback chain instead of each spawning their own --version probes. +const _inFlight = new Map(); +// cliId -> user-configured override path ("" when unset) +const _overrides = new Map(); + +/** + * Build candidate nvm-installed paths. The obvious `process.version` is the + * Node that Phoenix ships, not the Node the user selected in nvm — which + * mismatched in practice for ~every nvm user. + * + * Strategy: prefer the version named in `~/.nvm/alias/default` (or whatever + * `$NVM_DIR` points at). Fall back to enumerating installed versions, newest + * first, so we still find the CLI when the default alias is a label like + * `lts/*` or `node` that we don't expand here. + */ +function _nvmCandidates(home, bin) { + const nvmRoot = process.env.NVM_DIR || path.join(home, ".nvm"); + const versionsDir = path.join(nvmRoot, "versions", "node"); + const candidates = []; + try { + const aliasFile = path.join(nvmRoot, "alias", "default"); + if (fs.existsSync(aliasFile)) { + const alias = fs.readFileSync(aliasFile, "utf8").trim(); + if (/^v?\d/.test(alias)) { + const v = alias.startsWith("v") ? alias : "v" + alias; + candidates.push(path.join(versionsDir, v, "bin", bin)); + } + } + } catch { /* nvm not installed or unreadable */ } + try { + if (fs.existsSync(versionsDir)) { + const versions = fs.readdirSync(versionsDir) + .filter(v => /^v\d/.test(v)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions) { + candidates.push(path.join(versionsDir, v, "bin", bin)); + } + } + } catch { /* ignore */ } + return candidates; +} + +/** + * Drop duplicates, preserving order. Case-insensitive on Windows, where the + * same file reaches us as both `C:\Users\...` and `c:\users\...` from + * different sources and would otherwise be probed twice. + */ +function _dedupe(paths) { + const seen = new Set(); + const out = []; + for (const p of paths) { + if (!p) { continue; } + const key = isWindows ? p.toLowerCase() : p; + if (seen.has(key)) { continue; } + seen.add(key); + out.push(p); + } + return out; +} + +/** + * Ask the OS where `bin` lives. Returns [] when the lookup tool is missing, + * finds nothing, or takes too long. + */ +function _pathLookup(bin) { + try { + const cmd = isWindows + ? "where " + bin + : "which -a " + bin + " 2>/dev/null || which " + bin; + const out = execSync(cmd, { + encoding: "utf8", + timeout: PATH_LOOKUP_TIMEOUT_MS, + windowsHide: true + }).trim(); + let paths = out.split(isWindows ? "\r\n" : "\n") + .map(p => p.trim()) + .filter(p => p && !p.includes("node_modules")); + if (isWindows) { + // Filter to executable extensions — extensionless POSIX scripts + // and .ps1 both come back from `where` and neither can be run by + // our spawn path — and prefer .exe over .cmd/.bat shims. + paths = paths.filter(p => /\.(exe|cmd|bat)$/i.test(p)); + const exes = paths.filter(p => /\.exe$/i.test(p)); + const others = paths.filter(p => !/\.exe$/i.test(p)); + paths = [...exes, ...others]; + } + return paths; + } catch { + return []; + } +} + +/** + * Ordered candidate paths for a CLI, split into two tiers: + * - `native`: installers known to drop a real executable. No shim chain to + * break, so file existence is enough confidence — we skip `--version`. + * - `fallback`: PATH discovery and known locations. Broken installs are + * common here (an orphan `.cmd` whose cli.js got deleted), so every + * candidate is proved by running `--version` before we return it. + * @param {Object} cli - a CLI_REGISTRY entry + * @return {{native: Array, fallback: Array}} + */ +function _candidates(cli) { + const home = (isWindows ? process.env.USERPROFILE : process.env.HOME) || process.env.HOME || ""; + const native = isWindows ? cli.winNative() : cli.posixNative(home); + const extra = isWindows ? cli.winExtra() : cli.posixExtra(home); + const nativePaths = _dedupe(native); + // Dedupe the fallback tier against native too: `which` reports the same + // file the native tier already listed, and a user reading searchedPaths + // should not see it twice. + const seenNative = new Set(nativePaths.map(p => (isWindows ? p.toLowerCase() : p))); + const fallback = _dedupe([..._pathLookup(cli.bin), ...extra]) + .filter(p => !seenNative.has(isWindows ? p.toLowerCase() : p)); + return { native: nativePaths, fallback: fallback }; +} + +/** + * Existence + executability check. On Windows executability is derived from + * extension/PATHEXT not a file attribute, so existsSync is the right test; + * on posix we want the +x bit. + */ +function canAccess(p) { + if (!p) { return false; } + try { + if (isWindows) { + return fs.existsSync(p); + } + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Spawn a CLI with argv and resolve to { stdout, stderr, status, error }. + * Async so callers don't block the event loop while it runs — `claude auth + * status` can take up to 10 s, `--version` up to 3 s, and the integrated + * terminal and file watchers share this Node process. + * + * For .exe/posix binaries: shell-less spawn, paths-with-spaces and special + * chars pass through verbatim. For Windows .cmd/.bat shims: shell:true + * (Node refuses to spawn batch files without it per CVE-2024-27980 + * hardening) plus manual command-name quoting (Node intentionally does NOT + * escape the command name under shell:true). + * + * Mimics the spawnSync result shape so callers read .status/.error/.stdout + * unchanged. `opts.timeout` (ms) kills the process with SIGKILL on expiry + * and surfaces an Error with message "timeout". + */ +function spawnCli(cliPath, args, opts) { + return new Promise(function (resolve) { + const isCmdShim = isWindows && /\.(cmd|bat)$/i.test(cliPath); + const spawnCmd = isCmdShim ? `"${cliPath}"` : cliPath; + const spawnOpts = isCmdShim ? Object.assign({ shell: true }, opts) : opts; + const encoding = (opts && opts.encoding) || "utf8"; + const timeoutMs = (opts && opts.timeout) || 0; + let child; + try { + child = spawn(spawnCmd, args, spawnOpts); + } catch (err) { + resolve({ stdout: "", stderr: "", status: null, error: err }); + return; + } + let stdout = ""; + let stderr = ""; + let settled = false; + let timer = null; + function finish(result) { + if (settled) { return; } + settled = true; + if (timer) { clearTimeout(timer); } + resolve(result); + } + if (child.stdout) { + child.stdout.setEncoding(encoding); + child.stdout.on("data", function (chunk) { stdout += chunk; }); + } + if (child.stderr) { + child.stderr.setEncoding(encoding); + child.stderr.on("data", function (chunk) { stderr += chunk; }); + } + child.on("error", function (err) { + finish({ stdout, stderr, status: null, error: err }); + }); + child.on("close", function (code) { + finish({ stdout, stderr, status: code, error: null }); + }); + if (timeoutMs > 0) { + timer = setTimeout(function () { + try { child.kill("SIGKILL"); } catch { /* already exited */ } + finish({ stdout, stderr, status: null, error: new Error("timeout") }); + }, timeoutMs); + } + }); +} + +/** + * Whether `--version` output belongs to this CLI. The registry pattern is + * the primary rule; the generic floor below it ("names itself, then a + * version number") keeps a future output change like `codex 1.0.0` working + * without letting an unrelated binary through. + */ +function _versionOutputValid(cli, stdout) { + const out = (stdout || "").trim(); + if (!out) { return false; } + if (cli.versionPattern.test(out)) { return true; } + return out.toLowerCase().startsWith(cli.bin.toLowerCase()) && /\bv?\d+\.\d+/.test(out); +} + +/** + * Run a candidate's `--version` and report what happened. Catches broken + * installs the existence check misses — e.g. an npm `.cmd` shim whose + * referenced cli.js was deleted by a half-completed uninstall — and, for an + * override, tells apart "does not run" from "runs, but is a different tool". + * @return {Promise<{ok: boolean, version: ?string, errorCode: ?string, stderr: string}>} + */ +async function _probeVersion(cli, cliPath) { + let result; + try { + result = await spawnCli(cliPath, cli.versionArgs, { + encoding: "utf8", + timeout: VERSION_PROBE_TIMEOUT_MS + }); + } catch (err) { + return { ok: false, version: null, errorCode: ERROR_CODES.OVERRIDE_INVALID, stderr: err.message }; + } + if (result.error && /timeout/i.test(result.error.message || "")) { + return { ok: false, version: null, errorCode: ERROR_CODES.OVERRIDE_TIMEOUT, stderr: "" }; + } + const version = (result.stdout || "").trim(); + if (result.error || result.status !== 0 || !_versionOutputValid(cli, version)) { + return { + ok: false, + version: version || null, + errorCode: ERROR_CODES.OVERRIDE_INVALID, + stderr: (result.stderr || "").slice(0, 200) + }; + } + return { ok: true, version, errorCode: null, stderr: "" }; +} + +/** + * Resolve and validate a user-configured override path. + * + * Unlike a discovered candidate, an override is ALWAYS proved by running + * `--version` — even on Windows, even for a .exe. The whole point of the + * setting is to tell the user when their path has gone stale. + * @return {Promise} an override report; `.path` is set only on success + */ +async function _resolveOverride(cli, override) { + const raw = (override || "").trim(); + if (!raw) { return null; } + if (UNSAFE_OVERRIDE_CHARS.test(raw)) { + return { used: true, valid: false, path: raw, errorCode: ERROR_CODES.OVERRIDE_REJECTED }; + } + // A bare name with no separator means "find this on PATH" — the same + // affordance the Git extension's gitPath setting allows. + let resolved = raw; + if (!raw.includes("/") && !raw.includes("\\")) { + const found = _pathLookup(raw)[0]; + if (!found) { + return { used: true, valid: false, path: raw, errorCode: ERROR_CODES.OVERRIDE_MISSING, onPath: true }; + } + resolved = found; + } + if (!canAccess(resolved)) { + // Split "no such file" from "there but not executable": the latter is + // the common chmod mistake and deserves its own advice. + const code = (!isWindows && fs.existsSync(resolved)) + ? ERROR_CODES.OVERRIDE_NOT_EXECUTABLE + : ERROR_CODES.OVERRIDE_MISSING; + return { used: true, valid: false, path: resolved, errorCode: code }; + } + const probe = await _probeVersion(cli, resolved); + if (!probe.ok) { + return { + used: true, valid: false, path: resolved, + errorCode: probe.errorCode, version: probe.version, stderr: probe.stderr + }; + } + return { used: true, valid: true, path: resolved, version: probe.version }; +} + +/** The override currently configured for a CLI, or "". */ +function getOverride(cliId) { + return _overrides.get(cliId) || ""; +} + +/** + * Record the user's configured override paths. Cache entries carry the + * override they were built from, so changing one invalidates its entry on + * the next lookup without any explicit cache-clearing call — which cannot + * desync the way a separate clear step could. + * @param {Object} paths - { claude?: string, codex?: string } + * @return {Object} the applied overrides, by cli id + */ +function setOverrides(paths) { + const applied = {}; + for (const cliId of CLI_IDS) { + if (paths && Object.prototype.hasOwnProperty.call(paths, cliId)) { + _overrides.set(cliId, (paths[cliId] || "").trim()); + } + applied[cliId] = getOverride(cliId); + } + return applied; +} + +function _cacheHit(cliId, overrideSig) { + const entry = _cache.get(cliId); + if (!entry || entry.overrideSig !== overrideSig) { + return null; + } + const fresh = entry.path !== null || (Date.now() - entry.at) < NULL_CACHE_TTL_MS; + return fresh ? entry : null; +} + +function _toResult(cliId, entry) { + return { + cli: cliId, + path: entry.path, + source: entry.source || null, + version: entry.version || null, + errorCode: entry.errorCode || null, + override: entry.override || null, + searchedPaths: entry.searched || [] + }; +} + +/** + * Find a CLI's executable. + * + * @param {string} cliId - "claude" | "codex" + * @param {Object} [opts] - `{force}` bypasses the cache (and the in-flight + * share) after a runtime spawn failure; `{override}` uses that path for + * this call only instead of the stored one, so the settings UI can + * preview a path without committing it. + * @return {Promise} `{cli, path, source, version, errorCode, override, searchedPaths}` + */ +function locateCli(cliId, opts) { + const cli = CLI_REGISTRY[cliId]; + if (!cli) { + return Promise.reject(new Error("Unknown CLI: " + cliId)); + } + const force = !!(opts && opts.force); + const override = (opts && opts.override !== undefined) ? opts.override : getOverride(cliId); + const overrideSig = (override || "").trim(); + + if (!force) { + const hit = _cacheHit(cliId, overrideSig); + if (hit) { + return Promise.resolve(_toResult(cliId, hit)); + } + const pending = _inFlight.get(cliId); + if (pending) { + return pending; + } + } + + const discovery = (async function () { + const entry = { path: null, at: Date.now(), overrideSig, searched: [] }; + + if (overrideSig) { + const report = await _resolveOverride(cli, overrideSig); + entry.override = report; + if (report && report.valid) { + entry.path = report.path; + entry.source = "override"; + entry.version = report.version; + console.log("[Phoenix AI] Using configured " + cli.bin + " path:", report.path); + } else { + // Deliberately NOT falling through to auto-discovery: quietly + // running a different binary than the one the user configured + // is the worst kind of bug report. Clearing the setting is how + // you get discovery back. + entry.errorCode = report ? report.errorCode : ERROR_CODES.NOT_FOUND; + console.log("[Phoenix AI] Configured " + cli.bin + " path unusable:", entry.errorCode); + } + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + } + + const { native, fallback } = _candidates(cli); + entry.searched = [...native, ...fallback]; + for (const p of native) { + if (canAccess(p)) { + console.log("[Phoenix AI] Found native " + cli.bin + " CLI at:", p); + entry.path = p; + entry.source = "native"; + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + } + } + for (const p of fallback) { + if (!canAccess(p)) { continue; } + const probe = await _probeVersion(cli, p); + if (probe.ok) { + console.log("[Phoenix AI] Validated " + cli.bin + " CLI at:", p); + entry.path = p; + entry.source = "fallback"; + entry.version = probe.version; + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + } + } + console.log("[Phoenix AI] Global " + cli.bin + " CLI not found"); + entry.errorCode = ERROR_CODES.NOT_FOUND; + entry.at = Date.now(); + _cache.set(cliId, entry); + return _toResult(cliId, entry); + }()); + + if (!force) { + _inFlight.set(cliId, discovery); + discovery.finally(function () { + if (_inFlight.get(cliId) === discovery) { + _inFlight.delete(cliId); + } + }); + } + return discovery; +} + +/** + * Check one specific path without touching the cache — for a "test this + * path" affordance in settings, so the UI never reimplements validation. + * @return {Promise<{ok: boolean, version: ?string, errorCode: ?string}>} + */ +async function validateCliPath(cliId, cliPath) { + const cli = CLI_REGISTRY[cliId]; + if (!cli) { + return { ok: false, version: null, errorCode: "UNKNOWN_CLI" }; + } + const report = await _resolveOverride(cli, cliPath); + if (!report) { + return { ok: false, version: null, errorCode: ERROR_CODES.OVERRIDE_MISSING }; + } + return { + ok: !!report.valid, + version: report.version || null, + errorCode: report.valid ? null : report.errorCode, + path: report.path + }; +} + +/** + * How to hand a resolved binary to node-pty. + * + * node-pty goes through CreateProcess, which runs .exe/.com only — a + * `.cmd`/`.bat` shim (what `npm i -g` leaves on Windows) has to be run via + * `cmd.exe /c`. Passing the shim straight through as the PTY's shell fails + * to spawn, so every terminal caller must resolve through here rather than + * using the raw path. + * @return {{command: string, args: Array}} + */ +function getSpawnProfile(cliPath) { + if (isWindows && /\.(cmd|bat)$/i.test(cliPath || "")) { + return { command: process.env.COMSPEC || "cmd.exe", args: ["/c", cliPath] }; + } + return { command: cliPath, args: [] }; +} + +exports.CLI_IDS = CLI_IDS; +exports.ERROR_CODES = ERROR_CODES; +exports.canAccess = canAccess; +exports.spawnCli = spawnCli; +exports.locateCli = locateCli; +exports.validateCliPath = validateCliPath; +exports.getSpawnProfile = getSpawnProfile; +exports.setOverrides = setOverrides; +exports.getOverride = getOverride; diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index d0dac2729a..cca2a8d21f 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -54,7 +54,8 @@ const EXEC_PEER_TIMEOUT_MS = { getEditorState: 5000, takeScreenshot: 15000, controlEditor: 5000, - resizeLivePreview: 5000 + resizeLivePreview: 5000, + searchEditorBuffers: 3000 }; // Floor for caller-provided timeouts (e.g. execJsInLivePreview's @@ -111,11 +112,15 @@ function _maybeAppendHint(result, hasClarification) { * @param {Object} nodeConnector - The NodeConnector instance for communicating with the browser * @param {Object} [clarificationAccessors] - Optional accessors for user clarification queue * @param {Function} clarificationAccessors.hasClarification - Returns true if a clarification is queued - * @param {Function} clarificationAccessors.getAndClearClarification - Returns {text} and clears the queue + * @param {Function} clarificationAccessors.getAndClearClarification - Returns {text, images} and clears the queue * @returns {McpSdkServerConfigWithInstance} MCP server config ready for queryOptions.mcpServers */ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) { const hasClarification = clarificationAccessors && clarificationAccessors.hasClarification; + // Tools that only look at the editor carry readOnlyHint so the CLI treats + // them like Read/Grep: no permission prompt, and still callable in Plan + // Mode. Without it every MCP tool is assumed to mutate and plan mode + // asks the user before each call. const getEditorStateTool = sdkModule.tool( "getEditorState", "Get the current Phoenix editor state: active file, working set (open files with isDirty flag), live preview file, " + @@ -149,6 +154,57 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) }; } return _maybeAppendHint(result, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + alwaysLoad: true, + searchHint: "which file the user has open in Phoenix Code editor, plus cursor, selection, and what the live preview (an embedded browser rendering their HTML or Markdown) is showing" + } + ); + + const searchEditorBuffersTool = sdkModule.tool( + "searchEditorBuffers", + "Regex search over the UNSAVED open files only — the ones the editor-state line at the top of " + + "the prompt lists as unsaved. Those are the only files where Grep is wrong: Grep reads disk, and " + + "disk is stale for a buffer the user has edited but not saved. Use Grep for everything else; it " + + "is faster and covers the whole project. Only call this when the editor-state line names unsaved " + + "files. Returns matches {file, line, text}, searchedFiles (what this actually covered) and truncated.", + { + pattern: z.string().describe("Regex (default) or literal text to find"), + isRegex: z.boolean().optional().describe("false to match the pattern literally. Default true"), + caseSensitive: z.boolean().optional().describe("Default false"), + fileGlob: z.string().optional().describe("Limit to matching files, e.g. *.css"), + maxResults: z.number().optional().describe("Cap on matches returned. Default 50, max 200") + }, + async function (args) { + let result; + try { + const found = await _execPeerWithTimeout(nodeConnector, "searchEditorBuffers", + args || {}, "searchEditorBuffers"); + let text; + if (found && found.error) { + text = JSON.stringify(found); + } else if (!found || !found.searchedFiles || !found.searchedFiles.length) { + text = "No unsaved files, so nothing in the editor differs from disk. Use Grep — " + + "it is authoritative for the whole project right now."; + } else { + text = JSON.stringify(found) + + "\n\nThis searched ONLY the unsaved files in searchedFiles. Every other file " + + "matches disk — use Grep for the rest of the project."; + } + result = { content: [{ type: "text", text: text }] }; + } catch (err) { + result = { + content: [{ type: "text", text: "Error searching unsaved files: " + err.message }], + isError: true + }; + } + return _maybeAppendHint(result, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + alwaysLoad: true, + searchHint: "search the unsaved editor buffers, where Grep would see stale disk content" } ); @@ -213,6 +269,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) }; } return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + alwaysLoad: true, + searchHint: "screenshot the user's Phoenix Code editor app window, or the page rendered in their live preview browser" } ); @@ -258,6 +319,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) }; } return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + alwaysLoad: true, + searchHint: "run JS in the user's live preview browser to inspect the rendered page's DOM, console or JS state" } ); @@ -322,6 +388,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) isError: hasError }; return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + alwaysLoad: true, + searchHint: "open, close or switch files in Phoenix Code, toggle the live preview browser" } ); @@ -355,6 +426,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) }; } return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + alwaysLoad: true, + searchHint: "resize the user's live preview browser viewport to check a responsive layout" } ); @@ -373,6 +449,10 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) content: [{ type: "text", text: "Waited " + args.seconds + " seconds." }] }; return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + searchHint: "pause before re-checking the user's live preview browser" } ); @@ -447,6 +527,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) }; } return _maybeAppendHint(toolResult, hasClarification); + }, + { + searchHint: "run JS against Phoenix Code's own editor API, not the page in its live preview" } ); @@ -517,6 +600,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) }; } return _maybeAppendHint(toolResult, hasClarification); + }, + { + searchHint: "read or change the user's Phoenix Code editor preferences" } ); @@ -563,6 +649,10 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }; return _maybeAppendHint(toolResult, hasClarification); + }, + { + annotations: { readOnlyHint: true }, + searchHint: "look up Phoenix Code editor feature or API documentation" } ); @@ -576,10 +666,6 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) if (clarificationAccessors && clarificationAccessors.getAndClearClarification) { const result = await clarificationAccessors.getAndClearClarification(); if (result && (result.text || (result.images && result.images.length > 0))) { - // Notify browser with the text so it can show it as a user message bubble - nodeConnector.triggerPeer("aiClarificationRead", { - text: result.text || "" - }); const content = []; if (result.text) { content.push({ type: "text", text: "User clarification: " + result.text }); @@ -599,12 +685,16 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) return { content: [{ type: "text", text: "No clarification queued." }] }; + }, + { + annotations: { readOnlyHint: true }, + searchHint: "read a follow-up the user typed into this conversation while you were still working" } ); return sdkModule.createSdkMcpServer({ name: "phoenix-editor", - tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, + tools: [getEditorStateTool, searchEditorBuffersTool, takeScreenshotTool, execJsInLivePreviewTool, execJsInEditorTool, editorPreferencesTool, editorDocsTool, controlEditorTool, resizeLivePreviewTool, waitTool, getUserClarificationTool] }); diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 9cbf71d1f0..276f8c809a 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,19 +1,19 @@ { "name": "@phcode/node-core", - "version": "5.3.0-0", + "version": "5.5.3-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.3.0-0", + "version": "5.5.3-0", "hasInstallScript": true, "license": "GNU-AGPL3.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.126", "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", - "@vtsls/language-server": "^0.3.0", + "@vtsls/language-server": "0.3.0", "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", diff --git a/src-node/package.json b/src-node/package.json index 24216e5391..c42ccd85fd 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -1,8 +1,8 @@ { "name": "@phcode/node-core", "description": "Phoenix Node Core", - "version": "5.5.2-0", - "apiVersion": "5.5.2", + "version": "5.5.3-0", + "apiVersion": "5.5.3", "keywords": [], "author": "arun@core.ai", "homepage": "https://github.com/phcode-dev/phoenix", @@ -23,7 +23,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.126", "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", - "@vtsls/language-server": "^0.3.0", + "@vtsls/language-server": "0.3.0", "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", @@ -35,4 +35,4 @@ "ws": "^8.17.1", "zod": "^4.0.0" } -} \ No newline at end of file +} diff --git a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js index 85842984b6..eaf420dc83 100644 --- a/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js +++ b/src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js @@ -138,10 +138,18 @@ * Evaluate an expresion and return its result. */ evaluate: function (msg) { - var result = eval(msg.params.expression); - MessageBroker.respond(msg, { - result: JSON.stringify(result) // TODO: in original protocol this is an object handle - }); + // an unanswered request leaves the editor side waiting forever + try { + var result = eval(msg.params.expression); + MessageBroker.respond(msg, { + result: JSON.stringify(result) // TODO: in original protocol this is an object handle + }); + } catch (e) { + console.error("[Brackets LiveDev] Runtime.evaluate failed", e); + MessageBroker.respond(msg, { + error: String(e && e.message || e) + }); + } } }; diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 29862d96b7..3272d1f8ad 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -12,6 +12,7 @@ function RemoteFunctions(config = {}) { // to distinguish between phoenix internal vs user created elements PHCODE_INTERNAL_ATTR: "data-phcode-internal-c15r5a9", DATA_BRACKETS_ID_ATTR: "data-brackets-id", // data attribute used to track elements for live preview operations + LP_REF_ATTR: "data-phcode-lp-ref", // identity of a script-added element, stamped only when it is selected HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview }; @@ -41,7 +42,15 @@ function RemoteFunctions(config = {}) { // this will store the element that was clicked previously (before the new click) // we need this so that we can remove click styling from the previous element when a new element is clicked let previouslySelectedElement = null; + let _sourcelessObserver = null; + let _sourcelessCheckTimer = null; + let _sourcelessPath = null; + let _sourcelessTag = null; + let _sourcelessClass = null; + const SOURCELESS_RECOVER_DELAY_MS = 60; let _selectedFromEditor = false; + // the element selected by name (layers panel row), not by pointer + let _namedSelection = null; // Expose the currently selected element globally for external access window.__current_ph_lp_selected = null; @@ -121,6 +130,9 @@ function RemoteFunctions(config = {}) { * Elements opted out via `phcode-no-lp-edit` (cascades to descendants) or * `phcode-no-lp-edit-this` (this element only) are also non-inspectable so * every downstream tool inherits the opt-out automatically. + * + * @param {DOMElement} element + * @param {boolean} [onlyHighlight=false] - If true, bypasses the mode check */ function isElementInspectable(element, onlyHighlight = false) { if(config.mode !== 'edit' && !onlyHighlight) { @@ -128,18 +140,40 @@ function RemoteFunctions(config = {}) { } if(element && // element should exist - element.tagName.toLowerCase() !== "body" && // shouldn't be the body tag + (!isBodyElement(element) || _isNamedSelection(element)) && // body only when selected by name element.tagName.toLowerCase() !== "html" && // shouldn't be the HTML tag // this attribute is used by phoenix internal elements !element.closest(`[${GLOBALS.PHCODE_INTERNAL_ATTR}]`) && !_isInsideHeadTag(element) && // shouldn't be inside the head tag like meta tags and all - !element.closest('.phcode-no-lp-edit') && - !(element.classList && element.classList.contains('phcode-no-lp-edit-this'))) { + !_isEditOptedOut(element)) { return true; } return false; } + // The body is never selected by pointer (blank clicks deselect, hover stays quiet), + // only by name from the layers panel or the caret, and then without the structural tools. + function isBodyElement(element) { + return !!(element && element.tagName && element.tagName.toLowerCase() === "body"); + } + + // a named selection lifts the `phcode-no-lp-edit` opt-out and the body block, both pointer-only guards + function _isNamedSelection(element) { + return !!element && element === _namedSelection; + } + + /** + * `phcode-no-lp-edit` cascades to descendants, `phcode-no-lp-edit-this` covers + * the one element. + */ + function _isEditOptedOut(element) { + if (_isNamedSelection(element)) { + return false; + } + return !!(element.closest('.phcode-no-lp-edit') || + (element.classList && element.classList.contains('phcode-no-lp-edit-this'))); + } + /** * This is a checker function for editable elements, it makes sure that the element satisfies all the required check * - When onlyHighlight is false → config.mode must be 'edit' @@ -153,6 +187,40 @@ function RemoteFunctions(config = {}) { return isElementInspectable(element, onlyHighlight) && element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); } + // no data-brackets-id means a script added the element, so there is no HTML source for it + function isSourceless(element) { + return !!element && !element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); + } + + let _lpRefCounter = 0; + const LP_REF_PREFIX = "j"; + const RE_NUMERIC_ID = /^\d+$/; + + function getElementRef(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return null; + } + const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); + if (tagId) { + return tagId; + } + let ref = element.getAttribute(GLOBALS.LP_REF_ATTR); + if (!ref) { + ref = LP_REF_PREFIX + (++_lpRefCounter); + element.setAttribute(GLOBALS.LP_REF_ATTR, ref); + } + return ref; + } + + function getElementByRef(ref) { + if (ref === null || ref === undefined || ref === "") { + return null; + } + const text = String(ref); + const attr = RE_NUMERIC_ID.test(text) ? GLOBALS.DATA_BRACKETS_ID_ATTR : GLOBALS.LP_REF_ATTR; + return window.document.querySelector("[" + attr + '="' + text + '"]'); + } + /** * this function calc the screen offset of an element * @@ -182,9 +250,15 @@ function RemoteFunctions(config = {}) { getAllToolHandlers: getAllToolHandlers, isElementEditable: isElementEditable, isElementInspectable: isElementInspectable, + isBodyElement: isBodyElement, + isSourceless: isSourceless, + getElementRef: getElementRef, + getElementByRef: getElementByRef, isElementVisible: isElementVisible, screenOffset: screenOffset, selectElement: selectElement, + isSelectedFromEditor: function () { return _selectedFromEditor; }, + sendSelectionToEditor: sendSelectionToEditor, brieflyDisableHoverListeners: brieflyDisableHoverListeners, handleElementClick: handleElementClick, cleanupPreviousElementState: cleanupPreviousElementState, @@ -376,34 +450,78 @@ function RemoteFunctions(config = {}) { _overlayPool.push(overlay); } - // Update an existing overlay's position, dimensions, and colors to match the target element. - // No DOM elements are created or destroyed — only style properties are updated. - function _updateOverlay(overlay, element) { + // Everything an overlay needs read off the page. Split from the painting + // below so a batch of overlays can read first and write after: interleaving + // the two forces a layout per element. + // What screenOffset() needs off the body, read once for a whole batch + // instead of once per element. + function _bodyOffsetContext() { + const body = window.document.body; + if (window.getComputedStyle(body).position === "static") { + return { isStatic: true, x: window.pageXOffset, y: window.pageYOffset }; + } + const bodyBounds = body.getBoundingClientRect(); + return { isStatic: false, x: bodyBounds.left, y: bodyBounds.top }; + } + + function _offsetFromBounds(bounds, bodyOffset) { + if (bodyOffset.isStatic) { + return { left: bounds.left + bodyOffset.x, top: bounds.top + bodyOffset.y }; + } + return { left: bounds.left - bodyOffset.x, top: bounds.top - bodyOffset.y }; + } + + function _measureOverlay(element, bodyOffset) { const bounds = element.getBoundingClientRect(); if (bounds.width === 0 && bounds.height === 0) { + return null; + } + const cs = window.getComputedStyle(element); + return { + bounds: bounds, + scroll: _offsetFromBounds(bounds, bodyOffset || _bodyOffsetContext()), + bt: parseFloat(cs.borderTopWidth) || 0, + br: parseFloat(cs.borderRightWidth) || 0, + bb: parseFloat(cs.borderBottomWidth) || 0, + bl: parseFloat(cs.borderLeftWidth) || 0, + pt: parseFloat(cs.paddingTop) || 0, + pr: parseFloat(cs.paddingRight) || 0, + pb: parseFloat(cs.paddingBottom) || 0, + pl: parseFloat(cs.paddingLeft) || 0, + mt: parseFloat(cs.marginTop) || 0, + mr: parseFloat(cs.marginRight) || 0, + mb: parseFloat(cs.marginBottom) || 0, + ml: parseFloat(cs.marginLeft) || 0 + }; + } + + function _measureAll(elements) { + const bodyOffset = _bodyOffsetContext(); + const measured = []; + for (let i = 0; i < elements.length; i++) { + measured.push(_measureOverlay(elements[i], bodyOffset)); + } + return measured; + } + + // Update an existing overlay's position, dimensions, and colors to match the target element. + // No DOM elements are created or destroyed — only style properties are updated. + function _paintOverlay(overlay, element, measured) { + if (!measured) { overlay.classList.add('hidden'); return; } - const cs = window.getComputedStyle(element); + const bounds = measured.bounds; // Parse box model values (getComputedStyle always resolves to px) - const bt = parseFloat(cs.borderTopWidth) || 0, - br = parseFloat(cs.borderRightWidth) || 0, - bb = parseFloat(cs.borderBottomWidth) || 0, - bl = parseFloat(cs.borderLeftWidth) || 0; - const pt = parseFloat(cs.paddingTop) || 0, - pr = parseFloat(cs.paddingRight) || 0, - pb = parseFloat(cs.paddingBottom) || 0, - pl = parseFloat(cs.paddingLeft) || 0; - const mt = parseFloat(cs.marginTop) || 0, - mr = parseFloat(cs.marginRight) || 0, - mb = parseFloat(cs.marginBottom) || 0, - ml = parseFloat(cs.marginLeft) || 0; + const bt = measured.bt, br = measured.br, bb = measured.bb, bl = measured.bl; + const pt = measured.pt, pr = measured.pr, pb = measured.pb, pl = measured.pl; + const mt = measured.mt, mr = measured.mr, mb = measured.mb, ml = measured.ml; // Compute the 4 absolute boxes exactly like dev tools: // getBoundingClientRect() always returns the border box regardless of box-sizing. - const scroll = LivePreviewView.screenOffset(element); + const scroll = measured.scroll; const borderBox = { left: scroll.left, top: scroll.top, @@ -477,6 +595,10 @@ function RemoteFunctions(config = {}) { outlineStyle.border = `1px solid ${outlineColor}`; } + function _updateOverlay(overlay, element) { + _paintOverlay(overlay, element, _measureOverlay(element)); + } + function Highlight(trigger) { this.trigger = !!trigger; this.elements = []; @@ -499,6 +621,28 @@ function RemoteFunctions(config = {}) { _updateOverlay(overlay, element); }, + addAll: function (elements) { + const seen = new Set(this.elements); + const fresh = []; + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (element !== window.document && !seen.has(element)) { + seen.add(element); + fresh.push(element); + } + } + const measured = _measureAll(fresh); + for (let i = 0; i < fresh.length; i++) { + if (this.trigger) { + _trigger(fresh[i], "highlight", 1); + } + this.elements.push(fresh[i]); + const overlay = _getOverlay(); + this._overlays.push(overlay); + _paintOverlay(overlay, fresh[i], measured[i]); + } + }, + clear: function () { this._overlays.forEach(function (overlay) { _releaseOverlay(overlay); @@ -537,8 +681,9 @@ function RemoteFunctions(config = {}) { this.elements = elements; // Update all overlays in place — no DOM creation or destruction + const measured = _measureAll(elements); for (let i = 0; i < elements.length; i++) { - _updateOverlay(this._overlays[i], elements[i]); + _paintOverlay(this._overlays[i], elements[i], measured[i]); } } }; @@ -598,17 +743,13 @@ function RemoteFunctions(config = {}) { if (SHARED_STATE.isAutoScrolling || SHARED_STATE._isDraggingSVG) { return; } - if (customReturns.selectorBox && customReturns.selectorBox.isOpen && - customReturns.selectorBox.isOpen()) { - return; - } - const element = event.target; if (element === _lastHoverTarget) { return; } - if(!LivePreviewView.isElementInspectable(element) || element.nodeType !== Node.ELEMENT_NODE) { + if(isBodyElement(element) || !LivePreviewView.isElementInspectable(element) || + element.nodeType !== Node.ELEMENT_NODE) { return; } _lastHoverTarget = element; @@ -667,9 +808,11 @@ function RemoteFunctions(config = {}) { * @param {Element} element - The DOM element to select * @param {boolean} [fromEditor] - If true, this is an editor-cursor-driven selection; * only lightweight highlights (outline, margin/padding overlay) are shown, not interactive - * UI like control box, spacing handles, or measurements. + * UI like the control box or the styles bar. + * @param {boolean} [byName] - Selected by name (a layers panel row), so the edit + * opt-out and the body block don't apply while it stays selected. */ - function selectElement(element, fromEditor) { + function selectElement(element, fromEditor, byName) { // When a cursor-based highlight re-selects the already-selected element, // just refresh the highlight overlay without dismissing existing UI panels // (control box, editor box, element-info). This prevents cursor activity @@ -685,6 +828,8 @@ function RemoteFunctions(config = {}) { } dismissUIAndCleanupState(); + // set after the dismissal, which clears the previous selection's exemption + _namedSelection = byName ? element : null; // this should also be there when users are in highlight mode scrollElementToViewPort(element); @@ -724,6 +869,78 @@ function RemoteFunctions(config = {}) { previouslySelectedElement = element; _selectedFromEditor = fromEditor || false; window.__current_ph_lp_selected = element; + if (isSourceless(element)) { + _watchSourcelessSelection(element); + } + } + + function _elementIndexPath(element) { + const path = []; + let el = element; + while (el && el !== window.document.body) { + const parent = el.parentElement; + if (!parent) { + return null; + } + path.unshift(Array.prototype.indexOf.call(parent.children, el)); + el = parent; + } + return el === window.document.body ? path : null; + } + + function _elementAtIndexPath(path) { + let el = window.document.body; + for (let i = 0; i < path.length && el; i++) { + el = el.children[path[i]]; + } + return el || null; + } + + // a re-render replaces a script-added node, so re-select the same tag in the same place or dismiss + function _watchSourcelessSelection(element) { + _unwatchSourcelessSelection(); + _sourcelessPath = _elementIndexPath(element); + if (!_sourcelessPath) { + return; + } + _sourcelessTag = element.tagName; + _sourcelessClass = typeof element.className === "string" ? element.className : ""; + _sourcelessObserver = new MutationObserver(function () { + if (_sourcelessCheckTimer || !previouslySelectedElement || previouslySelectedElement.isConnected) { + return; + } + _sourcelessCheckTimer = setTimeout(_recoverSourcelessSelection, SOURCELESS_RECOVER_DELAY_MS); + }); + _sourcelessObserver.observe(window.document.body, { childList: true, subtree: true }); + } + + function _unwatchSourcelessSelection() { + if (_sourcelessObserver) { + _sourcelessObserver.disconnect(); + _sourcelessObserver = null; + } + if (_sourcelessCheckTimer) { + clearTimeout(_sourcelessCheckTimer); + _sourcelessCheckTimer = null; + } + _sourcelessPath = null; + } + + function _recoverSourcelessSelection() { + _sourcelessCheckTimer = null; + const old = previouslySelectedElement; + if (!old || old.isConnected || !_sourcelessPath) { + return; + } + const fresh = _elementAtIndexPath(_sourcelessPath); + const className = fresh && typeof fresh.className === "string" ? fresh.className : ""; + if (fresh && fresh.tagName === _sourcelessTag && className === _sourcelessClass && + isSourceless(fresh) && isElementInspectable(fresh, true) && isElementVisible(fresh)) { + const fromEditor = _selectedFromEditor; + selectElement(fresh, fromEditor); + } else { + dismissUIAndCleanupState(); + } } function disableHoverListeners() { @@ -793,7 +1010,8 @@ function RemoteFunctions(config = {}) { if(element && (element.closest('.phcode-no-lp-edit') || element.classList.contains('phcode-no-lp-edit-this'))) { return; } - if (!LivePreviewView.isElementInspectable(element)) { + // a blank-space click lands on the body and deselects, even a body selected by name + if (isBodyElement(element) || !LivePreviewView.isElementInspectable(element)) { dismissUIAndCleanupState(); return; } @@ -804,24 +1022,38 @@ function RemoteFunctions(config = {}) { selection.removeAllRanges(); } - // send cursor movement message to editor so cursor jumps to clicked element - if (element.hasAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR) && - config.syncSourceAndPreview !== false) { - MessageBroker.send({ - "tagId": element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR), - "nodeID": element.id, - "nodeClassList": element.classList, - "nodeName": element.nodeName, - "allSelectors": window.getAllInheritedSelectorsInOrder(element), - "contentEditable": element.contentEditable === "true", - "clicked": true - }); - } + sendSelectionToEditor(element); brieflyDisableHoverListeners(); selectElement(element); } + /** + * Tells the editor which element is now selected, so the cursor jumps to it and + * the css reverse highlight follows. Split out of the click handler because a + * selection can also be asked for from the editor side, which must report itself + * the same way without a pointer gesture ever touching the page. + * + * @param {HTMLElement} element + */ + function sendSelectionToEditor(element) { + if (config.syncSourceAndPreview === false) { + return; + } + // sent without a tagId too, so a css file in the editor can still jump to the rule + const tagId = element.getAttribute(GLOBALS.DATA_BRACKETS_ID_ATTR); + MessageBroker.send({ + "tagId": tagId || null, + "sourceless": !tagId, + "nodeID": element.id, + "nodeClassList": element.classList, + "nodeName": element.nodeName, + "allSelectors": window.getAllInheritedSelectorsInOrder(element), + "contentEditable": element.contentEditable === "true", + "clicked": true + }); + } + // clear CSS selector highlights function clearCssSelectorHighlight() { if (_cssSelectorHighlightTimer) { @@ -842,13 +1074,15 @@ function RemoteFunctions(config = {}) { // Highlight all matching elements except the selected one // (it already has a click highlight) _cssSelectorHighlight = new Highlight(); + const wanted = []; for (let i = 0; i < nodes.length; i++) { if (nodes[i] !== previouslySelectedElement && LivePreviewView.isElementInspectable(nodes[i], true) && nodes[i].nodeType === Node.ELEMENT_NODE) { - _cssSelectorHighlight.add(nodes[i]); + wanted.push(nodes[i]); } } + _cssSelectorHighlight.addAll(wanted); _cssSelectorHighlight.selector = rule; } @@ -878,6 +1112,20 @@ function RemoteFunctions(config = {}) { } } + function highlightAll(elements) { + if (!_clickHighlight) { + _clickHighlight = new Highlight(); + } + const wanted = []; + for (let i = 0; i < elements.length; i++) { + if (LivePreviewView.isElementInspectable(elements[i], true) && + elements[i].nodeType === Node.ELEMENT_NODE) { + wanted.push(elements[i]); + } + } + _clickHighlight.addAll(wanted); + } + /** * Find the best element to select from a list of matched nodes * Prefers: previously selected element > parent of selected > first valid element @@ -933,7 +1181,7 @@ function RemoteFunctions(config = {}) { hideHighlight(); // Filter out the universal selector (*) from the rule - highlighting everything - // is not useful, similar to how we skip html/body in isElementInspectable. + // is not useful, similar to how we skip the html tag in isElementInspectable. // The rule can be a comma-separated list of selectors (from multi-cursor), // so we filter out any standalone * segments and keep valid ones. rule = rule.split(",").map(s => s.trim()).filter(s => s !== "*").join(","); @@ -944,17 +1192,6 @@ function RemoteFunctions(config = {}) { const nodes = window.document.querySelectorAll(rule); - // Highlight all matching nodes. selectElement() will narrow _clickHighlight - // down to the chosen element below; createCssSelectorHighlight() then - // re-highlights the siblings in a separate overlay. - for (let i = 0; i < nodes.length; i++) { - highlight(nodes[i]); - } - - if (_clickHighlight) { - _clickHighlight.selector = rule; - } - // Both edit and highlight modes go through the same selection path: // selectElement() handles scroll-to-view and the prominent click-highlight, // createCssSelectorHighlight() shows siblings dimly. fromEditor=true @@ -962,8 +1199,15 @@ function RemoteFunctions(config = {}) { // highlighting/scroll behavior without any UI boxes. const { element, skipSelection } = findBestElementToSelect(nodes, rule); - if (!skipSelection) { + if (skipSelection) { + // A recent preview click owns the selection and its open tools. + // Keep the existing selector highlight without re-selecting it. + highlightAll(nodes); + _clickHighlight.selector = rule; + } else { if (element) { + // Select first: drawing every match here would immediately be + // cleared by selectElement() and drawn again as siblings below. selectElement(element, true); } else { // No valid element found, dismiss UI @@ -1500,6 +1744,8 @@ function RemoteFunctions(config = {}) { previouslySelectedElement = null; window.__current_ph_lp_selected = null; } + _unwatchSourcelessSelection(); + _namedSelection = null; // Reset hover tracking so the same-element skip doesn't suppress // re-highlighting after a full state cleanup (e.g. Escape, dismiss). @@ -1599,7 +1845,7 @@ function RemoteFunctions(config = {}) { /** * This function dismisses all UI elements and cleans up application state - * Called when user presses Esc key, clicks on HTML/Body tags, or other dismissal events + * Called when user presses Esc key, clicks on blank page space (the body), or other dismissal events */ function dismissUIAndCleanupState() { getAllToolHandlers().forEach(handler => (handler.dismiss && handler.dismiss())); // to dismiss all UI boxes diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js index 9d5beb6260..5401a88df9 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js @@ -35,6 +35,10 @@ define(function (require, exports, module) { */ var SYNC_ERROR_CLASS = "live-preview-sync-error"; + // A held arrow key moves the caret far faster than the rule under it can be + // resolved, so the highlight follows the caret once it settles. + const CURSOR_HIGHLIGHT_DEBOUNCE_MS = 80; + function _simpleHash(str) { let hash = 5381; for (let i = 0; i < str.length; ) { @@ -166,12 +170,14 @@ define(function (require, exports, module) { */ LiveDocument.prototype._detachFromEditor = function () { if (this.editor) { + this._cancelPendingHighlight(); this.hideHighlight(); this.editor.off("cursorActivity", this._onCursorActivity); } }; let _disableHighlightOnCursor = false; + let _cursorHighlightGeneration = 0; /** * If tur, it will disable highlights in live preview on cursor movement in editor @@ -180,6 +186,20 @@ define(function (require, exports, module) { LiveDocument.prototype.disableHighlightOnCursorActivity = function (shouldDisable) { // intentionally global. see usage for details _disableHighlightOnCursor = shouldDisable; + if (shouldDisable) { + // A preview click or source edit also supersedes timers queued by + // other live documents (for example the active CSS editor). + _cursorHighlightGeneration++; + this._cancelPendingHighlight(); + } + }; + + /** + * Whether live preview highlights on cursor movement are currently disabled. + * @return {boolean} + */ + LiveDocument.prototype.isCursorHighlightDisabled = function () { + return _disableHighlightOnCursor; }; /** @@ -189,11 +209,24 @@ define(function (require, exports, module) { * @param {Editor} editor */ LiveDocument.prototype._onCursorActivity = function (event, editor) { - if (!this.editor) { + this._cancelPendingHighlight(); + if (!this.editor || _disableHighlightOnCursor) { return; } - if(!_disableHighlightOnCursor){ - this.updateHighlight(); + const self = this; + const generation = _cursorHighlightGeneration; + this._highlightTimer = window.setTimeout(function () { + self._highlightTimer = null; + if (self.editor && !_disableHighlightOnCursor && generation === _cursorHighlightGeneration) { + self.updateHighlight(); + } + }, CURSOR_HIGHLIGHT_DEBOUNCE_MS); + }; + + LiveDocument.prototype._cancelPendingHighlight = function () { + if (this._highlightTimer) { + window.clearTimeout(this._highlightTimer); + this._highlightTimer = null; } }; @@ -288,6 +321,8 @@ define(function (require, exports, module) { if (!temporary) { this._lastHighlight = null; } + // The preview can have been selected directly or by another live + // document, so this document's cached selector cannot prove it is clear. this.protocol.evaluate("_LD.hideHighlight()"); }; diff --git a/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js b/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js index 401a60787b..c1c70941ef 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js +++ b/src/LiveDevelopment/MultiBrowserImpl/language/HTMLInstrumentation.js @@ -867,20 +867,29 @@ define(function (require, exports, module) { _cachedValues = {}; } - function getPositionFromTagId(editor, tagId) { - var marks = editor._codeMirror.getAllMarks(), - markFound; - - markFound = _.find(marks, function (mark) { - return (mark.tagID === tagId); + function _rebuildTagIdMarkMap(cm) { + const map = new Map(); + cm.getAllMarks().forEach(function (mark) { + if (mark.hasOwnProperty("tagID")) { + map.set(mark.tagID, mark); + } }); - if (markFound) { - return { - from: markFound.find().from, - to: markFound.find().to - }; + cm._phTagIdMarks = map; + return map; + } + + function getPositionFromTagId(editor, tagId) { + const cm = editor._codeMirror; + let map = cm._phTagIdMarks || _rebuildTagIdMarkMap(cm); + let mark = map.get(tagId); + // a cleared mark has no range, so the map is rebuilt from the live marks + let range = mark && mark.find(); + if (!range) { + map = _rebuildTagIdMarkMap(cm); + mark = map.get(tagId); + range = mark && mark.find(); } - return null; + return range ? { from: range.from, to: range.to } : null; } // private methods diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index f62b91229b..85ff6e3920 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -209,8 +209,24 @@ define(function (require, exports, module) { document.body.dispatchEvent(event); } + // A selection Phoenix asked the live preview to make - the layers panel picking + // an element - never took focus away from the editor side, so there is nothing + // to restore and pulling focus into the editor would take it off whatever asked + // for the selection. Time boxed so a selection that never reports back cannot + // leave the next real click in the preview without its focus. + const KEEP_FOCUS_WINDOW_MS = 1500; + let _keepFocusUntil = 0; + + function keepFocusOnNextSelect() { + _keepFocusUntil = Date.now() + KEEP_FOCUS_WINDOW_MS; + } + + function _shouldKeepFocus() { + return Date.now() < _keepFocusUntil; + } + function _focusEditorIfNeeded(editor, tagName, contentEditable) { - if (WorkspaceManager.isInDesignMode()) { + if (WorkspaceManager.isInDesignMode() || _shouldKeepFocus()) { return; } const focusShouldBeInLivePreview = ['INPUT', 'TEXTAREA'].includes(tagName) || contentEditable; @@ -271,11 +287,18 @@ define(function (require, exports, module) { activeEditorPath = activeEditor ? activeEditor.document.file.fullPath : null, activeFullEditorPath = activeFullEditor ? activeFullEditor.document.file.fullPath : null; if(!liveDocPath){ - if (activeEditor && !WorkspaceManager.isInDesignMode()) { + if (activeEditor && !WorkspaceManager.isInDesignMode() && !_shouldKeepFocus()) { activeEditor.focus(); // restore focus from live preview } return; } + // a script-added element has no place in the html, only a css file can show it + if (!tagId) { + if (activeEditor && (liveDoc.isRelated(activeEditorPath) || _isLessOrSCSS(activeEditor))) { + _searchAndCursorIfCSS(activeEditor, allSelectors, nodeName); + } + return; + } const allOpenFileCount = MainViewManager.getWorkingSetSize(MainViewManager.ALL_PANES); function selectInHTMLEditor(fullHtmlEditor) { const positionResult = HTMLInstrumentation.getPositionFromTagId(fullHtmlEditor, parseInt(tagId, 10)); @@ -369,7 +392,7 @@ define(function (require, exports, module) { } } else if (msg.keyForward) { _forwardKeyboardShortcutFromIframe(msg); - } else if (msg.clicked && msg.tagId) { + } else if (msg.clicked && (msg.tagId || msg.sourceless)) { // While previewing an html file, and if css related file is active in the editor, then clicking on the // live preview, here we set the cursor position in the css file. but this will also trigger a css // highlight as the cursor changes which jumps the live preview selection. @@ -384,8 +407,10 @@ define(function (require, exports, module) { } catch (e) { console.error("error in tag selection", e); } + _keepFocusUntil = 0; editMode && liveDoc && liveDoc.disableHighlightOnCursorActivity(false); - liveDoc && liveDoc.updateHighlight(); + // the caret did not move for a script-added element, re-highlighting would drop its selection + liveDoc && !msg.sourceless && liveDoc.updateHighlight(); } else { // enrich received message with clientId msg.clientId = clientId; @@ -767,6 +792,7 @@ define(function (require, exports, module) { exports.setLivePreviewMessageHandler = setLivePreviewMessageHandler; exports.setCustomRemoteFunctionProvider = setCustomRemoteFunctionProvider; // lp communication functions + exports.keepFocusOnNextSelect = keepFocusOnNextSelect; exports.registerPhoenixFn = registerPhoenixFn; exports.triggerLPFn = triggerLPFn; exports.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME; diff --git a/src/assets/default-project/en/Newly_added_features.md b/src/assets/default-project/en/Newly_added_features.md index 12b16ef3e1..4fca55f8b9 100644 --- a/src/assets/default-project/en/Newly_added_features.md +++ b/src/assets/default-project/en/Newly_added_features.md @@ -235,7 +235,7 @@ Automatically rename paired HTML/XML/SVG tags as you type at the start or end of All new native ChromeOS app is now available on the Google Play Store. The ChromeOS app is a highly requested feature and is specially made for education and student use. -[![Get phcode.io on google play](https://github.com/user-attachments/assets/0a7f20ce-653c-43a8-ac3e-3875ea74df5b)](https://play.google.com/store/apps/details?id=prod.phcode.twa) +[![Get Phoenix Code on google play](https://github.com/user-attachments/assets/0a7f20ce-653c-43a8-ac3e-3875ea74df5b)](https://play.google.com/store/apps/details?id=prod.phcode.twa) ## Drag and Drop Files and Folders in Desktop Apps - Experimental @@ -315,7 +315,7 @@ All new search filters to find exactly what you want. `Search in files` or `Excl Brand new Native Desktop Apps for Mac (M1 and Intel), Linux, and Windows, a milestone release that addresses one of our most requested features. Built from the ground up with the latest Tauri/Rust technology, this update modernizes the core platform with enhanced performance, security, and a seamless cross-platform experience. -Download your copy from [phcode.io](https://phcode.io) +Download your copy from [phcode.dev](https://phcode.dev) ![Desktop apps](https://github.com/phcode-dev/phoenix/assets/5336369/9ea4f9cc-5ebd-4d67-bf36-0a6ae7611767) diff --git a/src/assets/new-project/assets/js/code-editor.js b/src/assets/new-project/assets/js/code-editor.js index 5eea795ef6..e6031c24cd 100644 --- a/src/assets/new-project/assets/js/code-editor.js +++ b/src/assets/new-project/assets/js/code-editor.js @@ -260,7 +260,7 @@ function initCodeEditor() { const banner = document.getElementById("download-phcode-banner"); banner.onclick = function() { Metrics.countEvent(Metrics.EVENT_TYPE.NEW_PROJECT, "getApp.Click", window.top.Phoenix.platform); - window.top.Phoenix.app.openURLInDefaultBrowser("https://phcode.io"); + window.top.Phoenix.app.openURLInDefaultBrowser("https://phcode.dev"); }; if(!window.top.Phoenix.isNativeApp && !window.top.Phoenix.browser.isChromeOS && window.top.Phoenix.browser.isDeskTop) { banner.classList.remove("forced-hidden"); diff --git a/src/assets/phoenix-splash/live-preview-error.html b/src/assets/phoenix-splash/live-preview-error.html index eed67140e5..d35bce94e7 100644 --- a/src/assets/phoenix-splash/live-preview-error.html +++ b/src/assets/phoenix-splash/live-preview-error.html @@ -9,7 +9,7 @@ // innerHTML — that would be reflected XSS. Build the DOM with // textContent and createElement only. The strings are plain text: // mainHeading uses "\n" for line breaks, mainSpan uses "{0}" as - // the placeholder for the phcode.io link. + // the placeholder for the phcode.dev link. function applyTranslations() { const params = Object.fromEntries( new URLSearchParams(window.location.search).entries() @@ -32,9 +32,9 @@ parts.forEach(function(part, i) { if (i > 0) { const a = document.createElement("a"); - a.href = "https://phcode.io"; + a.href = "https://phcode.dev"; a.style.color = "white"; - a.textContent = "phcode.io"; + a.textContent = "phcode.dev"; el.appendChild(a); } el.appendChild(document.createTextNode(part)); @@ -51,7 +51,7 @@

Uh Oh!
Your current browser doesn't support live preview.

- Get the best live preview experience by downloading our native apps for Windows, Mac, and Linux from phcode.io. + Get the best live preview experience by downloading our native apps for Windows, Mac, and Linux from phcode.dev.
diff --git a/src/config.json b/src/config.json index 41ab8d3343..bf6202c647 100644 --- a/src/config.json +++ b/src/config.json @@ -8,7 +8,7 @@ "about_icon": "styles/images/phoenix-icon.svg", "account_url": "https://account.phcode.dev/", "promotions_url": "https://promotions.phcode.dev/dev/", - "purchase_url": "https://phcode.io/pricing", + "purchase_url": "https://phcode.dev/pricing", "license_url": "https://www.gnu.org/licenses/agpl-3.0.en.html", "how_to_use_url": "https://github.com/adobe/brackets/wiki/How-to-Use-Brackets", "docs_url": "https://docs.phcode.dev/", @@ -17,8 +17,8 @@ "report_issue_url": "https://github.com/phcode-dev/phoenix/issues/new/choose", "get_involved_url": "https://github.com/phcode-dev/phoenix/discussions/77", "release_notes_url": "https://github.com/adobe/brackets/wiki/Release-Notes", - "homepage_url": "https://phcode.io", - "update_download_page": "https://phcode.io/", + "homepage_url": "https://phcode.dev", + "update_download_page": "https://phcode.dev/", "twitter_url": "https://twitter.com/phcodedev", "youtube_url": "https://www.youtube.com/channel/UCNK2a8DKqPQQe3GlfTk-RHg", "troubleshoot_url": "https://github.com/adobe/brackets/wiki/Troubleshooting#wiki-livedev", @@ -35,8 +35,8 @@ "extensionTakedownURL": "https://updates.phcode.io/extension_takedown.json", "lsp_server_pins": { "intelephense": "1.18.5", - "pyrefly": "1.1.1", - "ruff": "0.15.20" + "pyrefly": "1.2.0", + "ruff": "0.16.5" }, "linting.enabled_by_default": true, "build_timestamp": "", @@ -51,8 +51,8 @@ "bugsnagEnv": "development" }, "name": "Phoenix Code", - "version": "5.5.2-0", - "apiVersion": "5.5.2", + "version": "5.5.3-0", + "apiVersion": "5.5.3", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" diff --git a/src/download/index.html b/src/download/index.html index 223e81f5c9..98bc60eca9 100644 --- a/src/download/index.html +++ b/src/download/index.html @@ -14,16 +14,16 @@

Download Phoenix Code

Phoenix Code is a text editor designed to make coding as intuitive and fun as playing a video game - specially crafted for web developers, designers, and students.


- + diff --git a/src/extensionsIntegrated/Phoenix-live-preview/FILE_PROTOCOL_PREVIEW_NOTES.md b/src/extensionsIntegrated/Phoenix-live-preview/FILE_PROTOCOL_PREVIEW_NOTES.md new file mode 100644 index 0000000000..5333796cc0 --- /dev/null +++ b/src/extensionsIntegrated/Phoenix-live-preview/FILE_PROTOCOL_PREVIEW_NOTES.md @@ -0,0 +1,577 @@ +# Static `file://` live preview for HTML files outside the project (not shipped yet) + +Status: **prototyped and verified on the Electron desktop shell on 2026-09-05, then rolled back**. Nothing +in this document is live in the product. Keep it until the feature is picked up again; the full working +patch for this repo is at the bottom, and the Electron main-process code is in the middle. + +## Problem + +Opening an HTML file that is not inside the current project shows the "Preview Unavailable!" page +(`Strings.DESCRIPTION_LIVEDEV_PREVIEW_RESTRICTED*`, built in `NodeStaticServer._getExternalPreviewURL`). +We deliberately refuse to serve such files over the live preview http server: a page served from a +non-project location could walk the disk with relative URLs (`../../.aws/credentials`), read the content +and post it to a remote server. Only project files are served, and only from the project root. + +The degraded but safe alternative is to show the file as a plain `file://` page (what a browser does when +you double-click an html file) that reloads on save. No live edit transport, no instrumentation, no popout +tab routing. This works only on the desktop app; the browser build has no local file access at all. + +## What we found (read this before re-implementing) + +1. **A plain ` + `; + ++ /** ++ * Electron desktop only: HTML files outside the project are previewed as plain `file://` pages inside a ++ * `` (Chromium refuses `file://` in an `