diff --git a/CLAUDE.md b/CLAUDE.md
index f127fea18c..011f189c80 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,6 +14,9 @@
- No trailing whitespace.
- Use `const` and `let` instead of `var`.
+## 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.
+
## Translations / i18n
- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files.
- Use `const Strings = require("strings");` then `Strings.KEY_NAME`.
diff --git a/build/api-docs-generator.js b/build/api-docs-generator.js
index 126c9ccbd1..73fec93b67 100644
--- a/build/api-docs-generator.js
+++ b/build/api-docs-generator.js
@@ -205,8 +205,34 @@ async function generateMarkdown(file, relativePath) {
const modifiedContent = modifyJs(content, fileName);
- // Generate markdown using jsdoc-to-markdown as a library
- const markdownContent = await jsdoc2md.render({ source: modifiedContent });
+ // Generate markdown using jsdoc-to-markdown as a library. jsdoc-api spawns
+ // a child `jsdoc` process per call; under our BATCH_SIZE parallelism the
+ // child occasionally produces truncated/empty stdout (manifesting as
+ // "Unexpected end of JSON input"/"Unterminated string in JSON"). Retry
+ // serially so a single transient failure doesn't abort the whole batch.
+ let markdownContent;
+ let lastErr;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ markdownContent = await jsdoc2md.render({ source: modifiedContent });
+ lastErr = undefined;
+ break;
+ } catch (err) {
+ lastErr = err;
+ const causeMsg = err && err.cause && err.cause.message
+ ? err.cause.message
+ : '';
+ const transient = /Unexpected end of JSON input|Unterminated string in JSON|Jsdoc failed/.test(
+ (err && err.message) || ''
+ ) || /JSON/.test(causeMsg);
+ if (!transient) break;
+ await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
+ }
+ }
+ if (lastErr) {
+ lastErr.message = `[${file}] ${lastErr.message}`;
+ throw lastErr;
+ }
const newContent = normalizeLineEndings(
modifyMarkdown(markdownContent, path.join(relativePath, fileName))
);
diff --git a/docs/API-Reference/view/WorkspaceManager.md b/docs/API-Reference/view/WorkspaceManager.md
index bb0c11ed12..eff290c760 100644
--- a/docs/API-Reference/view/WorkspaceManager.md
+++ b/docs/API-Reference/view/WorkspaceManager.md
@@ -111,7 +111,7 @@ The panel's size & visibility are automatically saved & restored as a view-state
| $panel | jQueryObject | DOM content to use as the panel. Need not be in the document yet. Must have an id attribute, for use as a preferences key. |
| [minSize] | number | @deprecated No longer used. Pass `undefined`. |
| [title] | string | Display title shown in the bottom panel tab bar. |
-| [options] | Object | Optional settings: - {string} iconSvg Path to an SVG icon for the panel tab (e.g. "styles/images/icon.svg"). If omitted, a generic default icon is used. |
+| [options] | Object | Optional settings: - `iconSvg` (string): Path to an SVG icon for the panel tab (e.g. "styles/images/icon.svg"). If omitted, a generic default icon is used. |
diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js
index 9a8ee23d3a..8d1b186de1 100644
--- a/gulpfile.js/index.js
+++ b/gulpfile.js/index.js
@@ -909,7 +909,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) {
}
const minifyableExtensions = ["CloseOthers", "CodeFolding", "DebugCommands", "Git",
- "HealthData", "JavaScriptCodeHints", "JavaScriptRefactoring", "QuickView"];
+ "HealthData", "JavaScriptCodeHints", "JavaScriptRefactoring", "QuickView", "TypeScriptSupport"];
// extensions that nned not be minified either coz they are single file extensions or some other reason.
const nonMinifyExtensions = ["CSSAtRuleCodeHints", "CSSCodeHints",
"CSSPseudoSelectorHints", "DarkTheme", "HandlebarsSupport", "HTMLCodeHints", "HtmlEntityCodeHints",
diff --git a/gulpfile.js/thirdparty-lib-copy.js b/gulpfile.js/thirdparty-lib-copy.js
index e23579f6b3..cc064520a2 100644
--- a/gulpfile.js/thirdparty-lib-copy.js
+++ b/gulpfile.js/thirdparty-lib-copy.js
@@ -266,7 +266,10 @@ let copyThirdPartyLibs = series(
renameFile.bind(renameFile, 'node_modules/@xterm/addon-webgl/lib/addon-webgl.js.map',
'addon-webgl.js.map', 'src/thirdparty/xterm'),
copyLicence.bind(copyLicence, 'node_modules/@xterm/xterm/LICENSE', 'xterm')
-
+ // vtsls language server (bundled in src-node is not installed in pipline tests as the destop app building
+ // does it for desktop LSP). we ran it once and copied the license here.
+ // copyLicence.bind(copyLicence, 'src-node/node_modules/@vtsls/language-server/LICENSE', 'vtsls'),
+ // copyLicence.bind(copyLicence, 'src-node/node_modules/typescript/LICENSE.txt', 'typescript')
);
/**
diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js
index 95417a7adf..d3911a7492 100644
--- a/gulpfile.js/validate-build.js
+++ b/gulpfile.js/validate-build.js
@@ -35,9 +35,12 @@ const LARGE_FILE_LIST_DEV = {
// Size limits for production/staging builds (in MB)
const PROD_MAX_FILE_SIZE_MB = 2;
const PROD_MAX_TOTAL_SIZE_MB = 80;
-// Custom size limits for known large files (size in MB) For staging/production builds
+// Custom size limits for known large files (size in MB) For staging/production builds.
+// Margin policy: keep ~1 MB of headroom over a file's current size for individual files (and ~5 MB
+// for the aggregate/total). Bump a limit only enough to restore that margin when a file legitimately
+// grows - don't pad it large, so unexpected size jumps still get caught.
const LARGE_FILE_LIST_PROD = {
- 'dist/brackets.js': 10, // this is the full minified file itself renamed in prod
+ 'dist/brackets.js': 11, // this is the full minified file itself renamed in prod (~10 MB + 1 MB margin)
'dist/phoenix/virtualfs.js.map': 3
};
diff --git a/package.json b/package.json
index 5b00324382..356dd413f3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "phoenix",
- "version": "5.1.22-0",
- "apiVersion": "5.1.22",
+ "version": "5.2.0-0",
+ "apiVersion": "5.2.0",
"homepage": "https://core.ai",
"issues": {
"url": "https://github.com/phcode-dev/phoenix/issues"
diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js
index 3bda06f8b9..94adaa99c6 100644
--- a/src-node/claude-code-agent.js
+++ b/src-node/claude-code-agent.js
@@ -673,8 +673,11 @@ exports.resumeSession = async function (params) {
* Destroy the current session (clear session ID).
*/
exports.destroySession = async function () {
+ if (currentAbortController) {
+ currentAbortController.abort();
+ currentAbortController = null;
+ }
currentSessionId = null;
- currentAbortController = null;
_queuedClarification = null;
return { success: true };
};
diff --git a/src-node/index.js b/src-node/index.js
index a40bc1c678..4ad5376e00 100644
--- a/src-node/index.js
+++ b/src-node/index.js
@@ -70,6 +70,9 @@ require("./test-connection");
require("./utils");
require("./terminal");
require("./git/cli");
+// Note: "./lsp-client" is intentionally NOT required here. It is lazy-loaded on demand the
+// first time the desktop LSP client is used (via NodeUtils._loadNodeExtensionModule), so the
+// LSP framework adds nothing to node boot time. See src/languageTools/LSPClient.js.
require("./claude-code-agent");
function randomNonce(byteLength) {
const randomBuffer = new Uint8Array(byteLength);
diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js
new file mode 100644
index 0000000000..f8929ec9f2
--- /dev/null
+++ b/src-node/lsp-client.js
@@ -0,0 +1,344 @@
+/*
+ * 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.
+ *
+ */
+
+/**
+ * Pluggable LSP Client Infrastructure (Node side)
+ *
+ * Provides a reusable Language Server Protocol (LSP) client that supports multiple
+ * language servers simultaneously via a serverId-based registry. Browser extensions only
+ * configure their language server and make high-level calls; this module owns the process
+ * spawning and JSON-RPC framing.
+ *
+ * ```
+ * BROWSER (Phoenix) NODE (this module)
+ * TypeScript/Python/Rust ─NodeConnector──▶ lsp-client.js ──stdio──▶ vtsls / pylsp / rust-analyzer
+ * extensions ("ph-lsp") (spawn + JSON-RPC, Content-Length framing)
+ * ```
+ *
+ * API (called from the browser via `lspConnector.execPeer(, params)`):
+ * - startServer({ serverId, command, args=['--stdio'], rootUri }) -> { success, serverId, pid }
+ * - sendRequest({ serverId, method, params }) -> LSP result (awaits response)
+ * - sendNotification({ serverId, method, params }) -> { success } (fire and forget)
+ * - stopServer({ serverId }) -> { success, serverId }
+ * - listServers() -> [{ serverId, pid, rootUri }]
+ * - ping() -> { status: "pong", activeServers }
+ *
+ * Events emitted to the browser (`lspConnector.on(, ...)`):
+ * - 'lspNotification' { serverId, method, params } (e.g. textDocument/publishDiagnostics)
+ * - 'serverExit' { serverId, code }
+ * - 'serverError' { serverId, error }
+ *
+ * Server resolution order when starting: `src-node/node_modules/.bin/` (bundled),
+ * then the system PATH. Messages use JSON-RPC 2.0 over stdio with Content-Length headers.
+ */
+
+// Create connector at module load time (same pattern as src-node/git/cli.js)
+const nodeConnector = global.createNodeConnector("ph-lsp", exports);
+
+const { spawn } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+
+// Path to node_modules/.bin for bundled LSP servers
+const NODE_MODULES_BIN = path.join(__dirname, 'node_modules', '.bin');
+
+// Registry of active servers: serverId -> serverState
+const servers = new Map();
+let globalRequestId = 0;
+
+// Timeout for LSP requests (2 minutes)
+const LSP_REQUEST_TIMEOUT = 120000;
+
+/**
+ * Encode a JSON-RPC message with an LSP Content-Length header.
+ * @param {Object} message - The JSON-RPC message object
+ * @returns {string} The encoded message with headers
+ */
+function encode(message) {
+ const content = JSON.stringify(message);
+ return `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n${content}`;
+}
+
+/**
+ * Create a stream parser for LSP messages from a specific server.
+ * Uses Buffer operations because Content-Length is measured in bytes.
+ * @param {string} serverId - The server identifier
+ * @returns {Function} Parser function that processes incoming data chunks
+ */
+function createParser(serverId) {
+ let buffer = Buffer.alloc(0);
+ const HEADER_DELIMITER = Buffer.from('\r\n\r\n');
+
+ return (data) => {
+ buffer = Buffer.concat([buffer, data]);
+
+ while (true) {
+ const headerEnd = buffer.indexOf(HEADER_DELIMITER);
+ if (headerEnd === -1) {
+ break;
+ }
+
+ const header = buffer.slice(0, headerEnd).toString('utf8');
+ const match = header.match(/Content-Length: (\d+)/i);
+ if (!match) {
+ // Invalid header - skip a byte and resync.
+ buffer = buffer.slice(1);
+ continue;
+ }
+
+ const contentLength = parseInt(match[1], 10);
+ const contentStart = headerEnd + HEADER_DELIMITER.length;
+
+ if (buffer.length < contentStart + contentLength) {
+ break; // Wait for more data.
+ }
+
+ const json = buffer.slice(contentStart, contentStart + contentLength).toString('utf8');
+ buffer = buffer.slice(contentStart + contentLength);
+
+ try {
+ handleMessage(serverId, JSON.parse(json));
+ } catch (e) {
+ console.error(`[lsp-client][${serverId}] parse error:`, e.message);
+ }
+ }
+ };
+}
+
+/**
+ * Handle a single incoming LSP message from a server.
+ * @param {string} serverId - The server identifier
+ * @param {Object} msg - The parsed JSON-RPC message
+ */
+function handleMessage(serverId, msg) {
+ const server = servers.get(serverId);
+ if (!server) {
+ return;
+ }
+
+ if (msg.id !== undefined && server.pending.has(msg.id)) {
+ // Response to a request we sent.
+ const { resolve, reject } = server.pending.get(msg.id);
+ server.pending.delete(msg.id);
+ if (msg.error) {
+ reject(msg.error);
+ } else {
+ resolve(msg.result);
+ }
+ } else if (msg.method) {
+ // Notification or server-initiated request - forward to the browser.
+ nodeConnector.triggerPeer('lspNotification', { serverId, ...msg });
+ }
+}
+
+/**
+ * Ping endpoint to verify the LSP connector is alive.
+ * @returns {Promise