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